Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ ocx login kiro # import kiro-cli credentials (or token fallback)
ocx login google-antigravity
ocx login cursor # standalone Cursor PKCE login
ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json)
ocx login devin # Cognition/Devin Auth0 browser sign-in
ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE
ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business)
ocx login chatgpt # standalone ChatGPT OAuth login
Expand All @@ -125,6 +126,7 @@ ocx logout <provider>
| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` &#124; `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` &#124; `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. |
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. |
| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. |
| `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login opens Auth0 browser sign-in, then exchanges the token via Cognition's `RegisterUser` for a long-lived API key. Live model discovery via `GetCascadeModelConfigs`; `runTurn`-only streaming over Connect-RPC. Not shown in the dashboard preset by default — enable manually. |
| `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. |

Expand Down
22 changes: 22 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,28 @@ bare `exec_command` and `shell_command` names are reserved for non-freeform shel
bridges. Namespace a custom freeform tool that uses either name. These schema
declarations do not grant approval or change execution policy.

## `devin`

**Targets:** Cognition's `exa.api_server_pb.ApiServerService/GetChatMessage` over HTTPS Connect
streaming at `server.codeium.com`.
**Auth:** Devin/Cognition API key from `provider.apiKey` or the forwarded authorization header.
Login opens Auth0 browser sign-in, then exchanges the Firebase ID token via
`SeatManagementService.RegisterUser` for a long-lived API key.

- Uses `runTurn` rather than the ordinary fetch/parse path. Requests and server events are encoded
with manual protobuf framing in `devin/cloud-direct/wire.ts`; the ordinary `buildRequest` /
`parseStream` path is disabled.
- Live model discovery via `GetCascadeModelConfigs`; the static seed is filtered against the
account's live roster so models not on the plan drop out instead of failing at request time.
- Tool definitions are encoded in the request and tool-call events are decoded from the response
stream. Cognition enforces a per-tool-description length limit (6,998 chars) and an exact-phrase
blocklist; the adapter sanitizes known triggers and truncates over-long descriptions before
encoding.
- Devin/Cognition API keys do not refresh. Run `ocx login devin` again when the key expires or is
revoked.
- Experimental unofficial bridge; not shown in the dashboard preset by default. See the
[provider guide](/guides/providers/) for login instructions.

## `azure-openai` (alias: `azure`)

**Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`).
Expand Down
274 changes: 274 additions & 0 deletions src/adapters/devin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/**
* Devin / Cognition / Windsurf adapter.
*
* Uses the unofficial cloud-direct Connect-RPC client (GetChatMessage).
* OpenCodex injects the OAuth API key onto provider.apiKey
* before runTurn. This adapter maps OcxContext <-> ChatHistoryItem and
* streams CloudChatEvent into AdapterEvent.
*/
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxToolCall, OcxToolResultMessage, OcxUsage } from "../types";
import type { IncomingMeta, ProviderAdapter } from "./base";
import { streamChatEvents, allocateCascadeId, CloudChatError, type ChatHistoryItem, type ToolDef } from "./devin/cloud-direct";
import { getCachedCatalog } from "./devin/cloud-direct/catalog";
import { DEVIN_DEFAULT_API_SERVER } from "../oauth/devin";

export const DEVIN_API_SERVER = DEVIN_DEFAULT_API_SERVER;

const EFFORT_SUFFIXES = new Set(["low", "medium", "high", "xhigh", "max", "none", "1m", "max-1m", "none-1m", "fast"]);

function hasEffortSuffix(modelId: string): boolean {
const parts = modelId.split("-");
return parts.length > 1 && EFFORT_SUFFIXES.has(parts[parts.length - 1]!);
}

/**
* Resolve the wire model UID using the live catalog as the source of truth.
* Cognition's catalog lists most models with an effort suffix
* (e.g. `gpt-5-6-sol-high`); the base id alone is not accepted for those.
*
* If the catalog is available: use the exact UID when it exists, otherwise
* append the reasoning effort (or `medium` default) and pick a variant the
* account actually has.
*
* If the catalog is unavailable (degraded mode): append the effort suffix
* for any base id that doesn't already carry one, mirroring the catalog shape.
*/
async function resolveWireModelUid(
modelId: string,
apiKey: string,
host: string,
reasoningEffort?: string,
): Promise<string> {
if (hasEffortSuffix(modelId)) return modelId;
const catalog = await getCachedCatalog(apiKey, host);
if (catalog) {
if (catalog.byUid.has(modelId)) return modelId;
const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium";
const suffixed = `${modelId}-${effort}`;
if (catalog.byUid.has(suffixed)) return suffixed;
// Fall back to any enabled variant of this base model.
for (const uid of catalog.byUid.keys()) {
if (uid.startsWith(modelId + "-") && !catalog.byUid.get(uid)?.disabled) return uid;
}
}
// Degraded mode: append the default effort suffix.
const effort = reasoningEffort && EFFORT_SUFFIXES.has(reasoningEffort) ? reasoningEffort : "medium";
return `${modelId}-${effort}`;
}

export class DevinMissingCredentialError extends Error {
constructor() {
super("Devin live transport requires a Devin API key. Run ocx login devin to sign in with your Cognition/Devin account.");
this.name = "DevinMissingCredentialError";
}
}

export function resolveDevinToken(provider: OcxProviderConfig, headers?: Headers): string {
const providerKey = provider.apiKey?.trim();
if (providerKey) return providerKey;
const forwarded = headers?.get("authorization") ?? headers?.get("Authorization");
if (forwarded?.toLowerCase().startsWith("bearer ")) return forwarded.slice("bearer ".length).trim();
const envToken = process.env.OPENCODEX_DEVIN_TEST_TOKEN?.trim();
if (envToken) return envToken;
throw new DevinMissingCredentialError();
}

function textFromParts(content: string | OcxContentPart[] | undefined): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map((part) => (part.type === "text" ? part.text : "")).filter(Boolean).join("\n");
}

function toolResultText(message: OcxToolResultMessage): string {
const body = textFromParts(message.content);
return message.isError ? ("ERROR: " + body) : body;
}

function assistantToolCalls(message: OcxAssistantMessage): Array<{ id: string; name: string; arguments: string }> {
return message.content
.filter((part): part is OcxToolCall => part.type === "toolCall")
.map((part) => ({
id: part.id,
name: part.name,
arguments: JSON.stringify(part.arguments ?? {}),
}));
}

function assistantText(message: OcxAssistantMessage): string {
return message.content
.map((part) => (part.type === "text" ? part.text : part.type === "thinking" ? part.thinking : ""))
.filter(Boolean)
.join("\n");
}

export function mapOcxMessagesToDevin(parsed: OcxParsedRequest): ChatHistoryItem[] {
const items: ChatHistoryItem[] = [];
const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n");
if (system) items.push({ role: "system", content: system });

for (const message of parsed.context.messages) {
const mapped = mapOneMessage(message);
if (mapped) items.push(mapped);
}
return items;
}

function mapOneMessage(message: OcxMessage): ChatHistoryItem | undefined {
if (message.role === "user" || message.role === "developer") {
const text = textFromParts(message.content).trim();
if (!text) return undefined;
return { role: message.role === "developer" ? "system" : "user", content: text };
}
if (message.role === "assistant") {
const toolCalls = assistantToolCalls(message);
const text = assistantText(message);
if (!text && toolCalls.length === 0) return undefined;
return {
role: "assistant",
content: text || "",
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
};
}
if (message.role === "toolResult") {
return {
role: "tool",
content: toolResultText(message),
tool_call_id: message.toolCallId,
};
}
return undefined;
}

export function mapOcxToolsToDevin(tools: OcxTool[] | undefined): ToolDef[] | undefined {
if (!tools || tools.length === 0) return undefined;
return tools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
parameters: tool.parameters ?? { type: "object", properties: {} },
}));
}

export function createDevinAdapter(provider: OcxProviderConfig): ProviderAdapter {
const cascadeIds = new Map<string, string>();
const CASCADE_ID_MAX = 256;

return {
name: "devin",

buildRequest() {
return {
url: provider.baseUrl || DEVIN_API_SERVER,
method: "POST",
headers: {},
body: "",
};
},

async *parseStream(): AsyncGenerator<AdapterEvent> {
yield {
type: "error",
message: "Devin adapter uses runTurn; the fetch/parseStream path is disabled.",
};
},

async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) {
if (incoming.abortSignal?.aborted) {
emit({ type: "error", message: "Devin turn was aborted before start." });
return;
}
let apiKey: string;
try {
apiKey = resolveDevinToken(provider, incoming.headers);
} catch (error) {
emit({ type: "error", message: error instanceof Error ? error.message : String(error) });
return;
}

const threadKey = parsed._clientThreadId || parsed.previousResponseId || "default";
let cascadeId = cascadeIds.get(threadKey);
if (!cascadeId) {
// Evict oldest entries to bound memory in long-running proxy processes.
if (cascadeIds.size >= CASCADE_ID_MAX) {
const firstKey = cascadeIds.keys().next().value;
if (firstKey) cascadeIds.delete(firstKey);
}
cascadeId = allocateCascadeId();
cascadeIds.set(threadKey, cascadeId);
}

const rawModelId = parsed.modelId.includes("/") ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1) : parsed.modelId;
const host = (provider.baseUrl || DEVIN_API_SERVER).replace(/\/$/, "");
const modelUid = await resolveWireModelUid(rawModelId, apiKey, host, parsed.options.reasoning);
let openToolId: string | undefined;
let usage: OcxUsage | undefined;
let stopReason: string | undefined;

const closeOpenTool = () => {
if (!openToolId) return;
emit({ type: "tool_call_end" });
openToolId = undefined;
};

try {
for await (const event of streamChatEvents({
apiKey,
apiServerUrl: provider.baseUrl || DEVIN_API_SERVER,
modelUid,
messages: mapOcxMessagesToDevin(parsed),
tools: mapOcxToolsToDevin(parsed.context.tools),
cascadeId,
signal: incoming.abortSignal,
})) {
if (incoming.abortSignal?.aborted) break;
if (event.kind === "text") {
closeOpenTool();
if (event.text) emit({ type: "text_delta", text: event.text });
continue;
}
if (event.kind === "reasoning") {
if (event.text) emit({ type: "thinking_delta", thinking: event.text });
continue;
}
if (event.kind === "tool_call_start") {
closeOpenTool();
openToolId = event.id;
emit({ type: "tool_call_start", id: event.id, name: event.name });
continue;
}
if (event.kind === "tool_call_args") {
if (event.argsDelta) emit({ type: "tool_call_delta", arguments: event.argsDelta });
continue;
}
if (event.kind === "finish") {
closeOpenTool();
stopReason = event.reason === "length" ? "max_tokens" : event.reason;
continue;
}
if (event.kind === "usage") {
const total = event.totalTokens ?? ((event.promptTokens ?? 0) + (event.completionTokens ?? 0));
usage = {
inputTokens: event.promptTokens ?? 0,
outputTokens: event.completionTokens ?? 0,
...(total > 0 ? { totalTokens: total } : {}),
...(event.cachedInputTokens !== undefined ? { cachedInputTokens: event.cachedInputTokens } : {}),
...(event.cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens: event.cacheCreationInputTokens } : {}),
...(event.reasoningTokens !== undefined ? { reasoningOutputTokens: event.reasoningTokens } : {}),
};
continue;
}
}
closeOpenTool();
if (!incoming.abortSignal?.aborted) {
emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) });
}
} catch (error) {
closeOpenTool();
const message = error instanceof CloudChatError
? ("Devin cloud error" + (error.code ? " " + error.code : "") + ": " + error.message)
: error instanceof Error ? error.message : String(error);
emit({ type: "error", message });
}
},
};
}

Loading
Loading