diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 31bb2e3d42..a738664742 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -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 @@ -125,6 +126,7 @@ ocx logout | `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` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `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. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 596fc2255f..cebb656974 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -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`). diff --git a/src/adapters/devin.ts b/src/adapters/devin.ts new file mode 100644 index 0000000000..4174945f33 --- /dev/null +++ b/src/adapters/devin.ts @@ -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 { + 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(); + const CASCADE_ID_MAX = 256; + + return { + name: "devin", + + buildRequest() { + return { + url: provider.baseUrl || DEVIN_API_SERVER, + method: "POST", + headers: {}, + body: "", + }; + }, + + async *parseStream(): AsyncGenerator { + 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 }); + } + }, + }; +} + diff --git a/src/adapters/devin/cloud-direct/auth.ts b/src/adapters/devin/cloud-direct/auth.ts new file mode 100644 index 0000000000..66194463e2 --- /dev/null +++ b/src/adapters/devin/cloud-direct/auth.ts @@ -0,0 +1,238 @@ +/** + * Mint the short-lived `user_jwt` that every chat RPC needs alongside the + * persistent OAuth-issued `api_key`. + * + * POST https://server.codeium.com/exa.auth_pb.AuthService/GetUserJwt + * Content-Type: application/proto ← unary, NOT streaming + * Body: GetUserJwtRequest { metadata: Metadata } + * Response: GetUserJwtResponse { user_jwt: string } (field 1) + * + * The returned JWT has a payload like: + * { + * "api_key": "devin-synthetic-apikey$account-…$user-…", + * "auth_uid": "devin-auth-uid$…", + * "email": "user@example.com", + * "exp": , ← ~24 minute TTL + * "pro": true, + * "teams_tier": "TEAMS_TIER_DEVIN_PRO", + * ... + * } + * + * The JWT is signed HS256 by the server — can't be forged client-side. We + * cache it and refresh shortly before `exp`. + */ + +import * as crypto from 'crypto'; +import { encodeMessage, iterFields } from './wire.js'; +import { buildMetadata } from './metadata.js'; +import { anySignal } from '../../../lib/abort.js'; + +const DEFAULT_HOST = 'https://server.codeium.com'; + +export interface MintedUserJwt { + jwt: string; + /** Unix epoch seconds when the JWT expires. */ + expiresAt: number; +} + +export class CloudAuthError extends Error { + constructor(message: string, public readonly status?: number) { + super(message); + this.name = 'CloudAuthError'; + } +} + +/** + * Default mint timeout — 30s is generous (the endpoint responds in ~200ms + * in steady state) but enough headroom for slow networks. Callers can pass + * a tighter `signal` to override. + */ +const MINT_TIMEOUT_MS = 30_000; + +/** + * Mint a fresh user_jwt by calling exa.auth_pb.AuthService/GetUserJwt. + * `host` defaults to https://server.codeium.com — pass your tenant URL if your + * RegisterUser response gave a different host. + * + * Always applies an internal 30s timeout so a network stall here can't + * deadlock every concurrent chat request. If the caller passes a `signal`, + * we honor whichever fires first via AbortSignal.any. + */ +export async function mintUserJwt( + apiKey: string, + host: string = DEFAULT_HOST, + signal?: AbortSignal, +): Promise { + const metadata = buildMetadata({ + apiKey, + sessionId: crypto.randomUUID(), + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + }); + // GetUserJwtRequest { metadata: Metadata } — Metadata is field 1 + const req = encodeMessage(1, metadata); + + // Compose caller signal with our internal timeout via `anySignal` — a + // small polyfill of `AbortSignal.any` for runtimes (Node 18 / older + // Bun) that lack the built-in. The previous fallback silently dropped + // the CALLER's signal on those runtimes, so a chat-cancel during a + // GetUserJwt mint would keep the network request alive for up to the + // full 30s timeout. + const timeoutSignal = AbortSignal.timeout(MINT_TIMEOUT_MS); + const combinedSignal: AbortSignal = signal + ? anySignal([signal, timeoutSignal]) + : timeoutSignal; + + const resp = await fetch(`${host.replace(/\/$/, '')}/exa.auth_pb.AuthService/GetUserJwt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/proto', + 'Connect-Protocol-Version': '1', + }, + body: new Uint8Array(req), + signal: combinedSignal, + }); + const buf = Buffer.from(await resp.arrayBuffer()); + + if (!resp.ok) { + const text = buf.toString('utf8'); + throw new CloudAuthError(`GetUserJwt HTTP ${resp.status}: ${text.slice(0, 400)}`, resp.status); + } + + // Response is GetUserJwtResponse { user_jwt: string } where user_jwt is + // field 1, length-delimited. Decode the field properly instead of + // regex-scanning the whole buffer — the previous regex would pick up + // any JWT-shaped substring in the response (trace IDs, signature + // headers, any cached token inadvertently logged) and could even land + // on a non-user_jwt if Cognition ever embeds another JWT in a sibling + // field. + let jwt: string | null = null; + for (const f of iterFields(buf)) { + if (f.num === 1 && f.wire === 2 && Buffer.isBuffer(f.value)) { + const s = (f.value as Buffer).toString('utf8'); + // Sanity-check the shape — defensive: if the cloud ever moves user_jwt + // out from field 1 we want a clean error, not silently wrong creds. + // base64url with OPTIONAL `=` padding on each segment. Most modern + // JWTs omit the `=`, but the spec allows it and a future server-side + // change could re-introduce it; either way it's still a valid token. + if (/^eyJ[A-Za-z0-9_-]{10,}={0,2}\.[A-Za-z0-9_-]+={0,2}\.[A-Za-z0-9_-]+={0,2}$/.test(s)) { + jwt = s; + break; + } + } + } + if (!jwt) { + throw new CloudAuthError( + `GetUserJwt 200 but no field-1 JWT found (${buf.length} bytes): ${buf.toString('utf8').slice(0, 200)}`, + ); + } + + // Decode the payload to get the expiry. + let expiresAt = Math.floor(Date.now() / 1000) + 600; // fallback: 10 min + try { + const parts = jwt.split('.'); + const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4); + const payload = JSON.parse( + Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'), + ); + if (typeof payload.exp === 'number') expiresAt = payload.exp; + } catch { /* fall back to default */ } + + return { jwt, expiresAt }; +} + +// ---------------------------------------------------------------------------- +// In-memory cache — refresh ~60s before expiry +// ---------------------------------------------------------------------------- + +interface CacheEntry { + jwt: string; + expiresAt: number; + apiKey: string; + host: string; +} + +/** + * Cache is keyed by (apiKey, host). A single shared `cache` slot only holds + * the MOST RECENTLY USED entry — common case is one account at a time, so + * a single slot is enough. inFlight is a per-key map so a JWT mint for + * account A doesn't get returned to a concurrent request for account B. + * + * Previously `inFlight` was a singleton — if account A's mint was in flight + * and a request for account B arrived, B got A's JWT. That's the M1 + * "concurrent requests after account switch get wrong JWT" bug. + */ +let cache: CacheEntry | null = null; +const inFlight = new Map>(); +/** + * Monotonic epoch counter. Incremented on every `clearCachedUserJwt()` + * call so an in-flight mint that started BEFORE the clear can't + * repopulate the cache after-the-fact. Without this, a logout that + * happened concurrently with a mint would silently get its just- + * invalidated JWT cached and served for the next ~24 minutes. + */ +let cacheEpoch = 0; + +function flightKey(apiKey: string, host: string): string { + return `${host}\x1f${apiKey}`; +} + +/** + * Get a cached user_jwt or mint a new one. Refreshes when the cached JWT is + * within 60s of expiry. Multiple concurrent callers for the SAME (apiKey, host) + * share the same in-flight mint; concurrent callers for DIFFERENT keys each + * get their own mint. + */ +export async function getCachedUserJwt(apiKey: string, host: string = DEFAULT_HOST, signal?: AbortSignal): Promise { + const now = Math.floor(Date.now() / 1000); + if (cache && cache.apiKey === apiKey && cache.host === host && cache.expiresAt > now + 60) { + return cache.jwt; + } + // Race the caller's signal against the shared promise so one caller's + // cancellation doesn't propagate to unrelated callers sharing the mint. + // mintUserJwt has its own MINT_TIMEOUT_MS guard for the shared lifetime. + const raceSignal = (p: Promise): Promise => + signal + ? Promise.race([ + p, + new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]) + : p; + const key = flightKey(apiKey, host); + const existing = inFlight.get(key); + if (existing) { + const minted = await raceSignal(existing); + return minted.jwt; + } + const promise = mintUserJwt(apiKey, host); + inFlight.set(key, promise); + // Snapshot the epoch BEFORE awaiting the mint. If clearCachedUserJwt() + // fires while we're awaiting (logout-during-mint), the epoch changes + // and we won't repopulate the cache with the just-invalidated JWT. + const epochAtStart = cacheEpoch; + try { + const minted = await raceSignal(promise); + if (cacheEpoch === epochAtStart) { + cache = { jwt: minted.jwt, expiresAt: minted.expiresAt, apiKey, host }; + } + return minted.jwt; + } finally { + inFlight.delete(key); + } +} + +/** + * Drop the in-memory JWT cache. Call after credential changes (logout, + * account switch) so long-running opencode processes don't keep using a + * JWT minted from a now-invalid api_key. Also bumps the cache epoch so + * any in-flight mint racing with this clear can't repopulate cache + * with the stale JWT after-the-fact. + */ +export function clearCachedUserJwt(): void { + cache = null; + inFlight.clear(); + cacheEpoch++; +} diff --git a/src/adapters/devin/cloud-direct/catalog.ts b/src/adapters/devin/cloud-direct/catalog.ts new file mode 100644 index 0000000000..ef99b0aef8 --- /dev/null +++ b/src/adapters/devin/cloud-direct/catalog.ts @@ -0,0 +1,272 @@ +/** + * Per-account model catalog from Cognition's `GetCascadeModelConfigs`. + * + * Why this exists — issue #14: + * The cloud's `GetChatMessage` returns a single Connect-streaming EOS frame + * containing `{"error":{"code":"permission_denied","message":"an internal + * error occurred (trace ID: )"}}` whenever the caller's account tier + * does not include the requested `model_uid`. Reproduced byte-identical on a + * `TEAMS_TIER_DEVIN_FREE` account for every Anthropic/Gemini/Premium UID + * (only `swe-1-6-slow` streamed a real reply). The user-facing message is + * indistinguishable from a transient server fault — issue #14's reporter + * spent multiple sessions guessing. + * + * The pre-flight here checks the per-account catalog (`disabled` flag on + * `ClientModelConfig` field #4) BEFORE we spend a roundtrip on a request + * the cloud will refuse. When the lookup fails (network, auth, schema + * drift) we silently fall back to the chat path so a transient catalog + * outage can't take chat down with it. + * + * Schema (verified against the bundled `extension.js`, + * `exa.codeium_common_pb.ClientModelConfig`): + * + * GetCascadeModelConfigsResponse { + * #1 client_model_configs: repeated ClientModelConfig + * } + * ClientModelConfig { + * #1 label string + * #4 disabled bool ← the gate this module reads + * #22 model_uid string ← what `GetChatMessage` accepts + * } + * + * Disabled semantics: TRUE means "this UID exists in the catalog but the + * caller's account/tier cannot run inference against it." BYOK models + * surface as `disabled: false` so users with their own provider keys still + * pass through — the only way they fail at chat time is a missing key, + * which surfaces with a different message. + * + * Cache: per (apiServerUrl, apiKey) for {@link CATALOG_TTL_MS}. Cognition + * doesn't bump catalog entries mid-session in normal operation, so a 10-min + * TTL trades one extra roundtrip per ~10 min for clear errors on every chat. + */ + +import * as crypto from 'crypto'; +import { buildMetadata } from './metadata.js'; +import { getCachedUserJwt } from './auth.js'; +import { encodeMessage, iterFields } from './wire.js'; + +/** 10 minutes — see header. */ +const CATALOG_TTL_MS = 10 * 60 * 1000; + +/** Catalog endpoint inactivity timeout. Cognition responds in <500ms steady-state. */ +const CATALOG_FETCH_TIMEOUT_MS = 10_000; + +export interface ModelCatalogEntry { + /** Cloud-side `model_uid` (e.g. `claude-opus-4-7-medium`). */ + modelUid: string; + /** Human label (e.g. `Claude Opus 4.7 Medium`) — used in error messages. */ + label: string; + /** True when the caller's account tier cannot use this UID for chat. */ + disabled: boolean; +} + +export interface CacheEntry { + /** Lookup keyed by `model_uid`. */ + byUid: Map; + fetchedAt: number; + /** Cache key components, captured for invalidation/log purposes. */ + apiKey: string; + host: string; +} + +let cached: CacheEntry | null = null; +let inFlight: Promise | null = null; +let inFlightKey: string | null = null; +// Bumped on clearCachedCatalog so an in-flight fetch racing with a clear +// can't repopulate the cache with a just-invalidated catalog. +let cacheEpoch = 0; + +function flightKey(apiKey: string, host: string): string { + return `${host}\x1f${apiKey}`; +} + +/** + * Parse a GetCascadeModelConfigsResponse buffer into a UID-keyed map. + * A malformed catalog returns an empty map. + */ +function parseCatalogBuffer(buf: Buffer, apiKey: string, host: string): CacheEntry { + // GetCascadeModelConfigsResponse #1 (repeated ClientModelConfig) + const byUid = new Map(); + for (const f of iterFields(buf)) { + if (f.num !== 1 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue; + let label = ''; + let modelUid = ''; + let disabled = false; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.num === 1 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + label = (sf.value as Buffer).toString('utf8'); + } else if (sf.num === 4 && sf.wire === 0) { + // #4 = disabled (bool, varint 0/1) + disabled = sf.value === 1n; + } else if (sf.num === 22 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + modelUid = (sf.value as Buffer).toString('utf8'); + } + } + if (modelUid.length > 0) { + byUid.set(modelUid, { modelUid, label: label || modelUid, disabled }); + } + } + return { byUid, fetchedAt: Date.now(), apiKey, host }; +} + +/** + * Fetch the cascade model catalog for `(apiKey, host)` and parse the + * subset of `ClientModelConfig` we care about into a UID-keyed map. + * + * Throws on transport/auth failure so the caller can decide whether to fall + * back to "skip pre-flight". Does NOT throw on an unexpected response body — + * a malformed catalog returns an empty map, treated the same as "model not + * listed" by the chat pre-flight. + * + * Uses only an internal timeout — caller cancellation is handled by + * getCachedCatalog racing each caller's signal against the shared promise. + */ +async function fetchCatalog(apiKey: string, host: string): Promise { + const userJwt = await getCachedUserJwt(apiKey, host); + + const metadata = buildMetadata({ + apiKey, + userJwt, + sessionId: crypto.randomUUID(), + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + }); + // GetCascadeModelConfigsRequest { metadata: Metadata } — Metadata is #1. + const reqBody = encodeMessage(1, metadata); + + // Internal 10s timeout so a stalled catalog endpoint can't deadlock chat. + // The shared fetch uses only this internal timeout — caller cancellation is + // handled by racing each caller's signal against the shared promise in + // getCachedCatalog, so one caller's abort never propagates to unrelated + // callers sharing the same in-flight fetch. + const ac = new AbortController(); + const timer = setTimeout( + () => ac.abort(new Error(`catalog: fetch timeout (${CATALOG_FETCH_TIMEOUT_MS}ms)`)), + CATALOG_FETCH_TIMEOUT_MS, + ); + + let resp: Response; + try { + resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs`, { + method: 'POST', + headers: { 'Content-Type': 'application/proto', 'Connect-Protocol-Version': '1' }, + body: new Uint8Array(reqBody), + signal: ac.signal, + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GetCascadeModelConfigs HTTP ${resp.status}: ${text.slice(0, 200)}`); + } + // Read the body BEFORE clearing the timeout — fetch resolves on headers, + // not body completion. A stalled body would otherwise block indefinitely. + const buf = Buffer.from(await resp.arrayBuffer()); + return parseCatalogBuffer(buf, apiKey, host); + } finally { + clearTimeout(timer); + } +} + +/** + * Get the cached catalog for `(apiKey, host)`, fetching when missing or stale. + * + * Concurrent callers for the SAME (apiKey, host) share one in-flight fetch + * (no thundering herd on startup). Concurrent callers for DIFFERENT keys + * serialise the in-flight slot but only one of them holds it at a time — + * good enough for opencode's single-account-at-a-time usage pattern. + * + * Returns `null` on fetch failure (network, transient 5xx, auth issue). The + * caller treats `null` as "skip pre-flight and let the chat path surface the + * server-side error itself." + */ +export async function getCachedCatalog( + apiKey: string, + host: string, + signal?: AbortSignal, +): Promise { + if (cached && cached.apiKey === apiKey && cached.host === host) { + if (Date.now() - cached.fetchedAt < CATALOG_TTL_MS) { + return cached; + } + } + + const key = flightKey(apiKey, host); + // Race the caller's signal against the shared promise so one caller's + // cancellation doesn't propagate to unrelated callers sharing the fetch. + const raceSignal = (p: Promise): Promise => + signal + ? Promise.race([ + p, + new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + else signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + ]) + : p; + + if (inFlight && inFlightKey === key) { + try { + return await raceSignal(inFlight); + } catch { + return null; + } + } + + const promise = fetchCatalog(apiKey, host); + inFlight = promise; + inFlightKey = key; + const epochAtStart = cacheEpoch; + try { + const result = await raceSignal(promise); + if (cacheEpoch === epochAtStart) { + cached = result; + } + return result; + } catch { + return null; + } finally { + if (inFlight === promise) { + inFlight = null; + inFlightKey = null; + } + } +} + +/** + * Drop the cached catalog. Call after logout/account switch so a fresh + * sign-in doesn't see a previous account's allow-list. Bumps the cache + * epoch so an in-flight fetch racing with this clear can't repopulate + * the cache with the just-invalidated catalog. + */ +export function clearCachedCatalog(): void { + cached = null; + inFlight = null; + inFlightKey = null; + cacheEpoch++; +} + +/** + * Tier-disabled error — thrown by the chat pre-flight when the catalog lists + * a model as `disabled: true` for this account. The message names the model + * and points at the plan page, replacing Cognition's opaque + * "an internal error occurred" trailer. + */ +export class ModelNotAvailableError extends Error { + constructor( + public readonly modelUid: string, + public readonly label: string, + public readonly reason: 'disabled' | 'not_listed', + ) { + super( + reason === 'disabled' + ? `Model "${label}" (uid=${modelUid}) is not enabled for your Cognition account. ` + + `The Cognition catalog returned it with disabled=true — meaning your current plan/tier ` + + `does not include this model. ` + + `Check the model picker on https://codeium.com/account, or pick a different model. ` + + `(This message replaces Cognition's "an internal error occurred" — same root cause.)` + : `Model uid "${modelUid}" is not listed in the Cognition catalog for your account. ` + + `Either the UID has been retired upstream or your account/region doesn't serve it. ` + + `Run \`curl http://127.0.0.1:42100/v1/models\` to see the canonical names your plan accepts.`, + ); + this.name = 'ModelNotAvailableError'; + } +} diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts new file mode 100644 index 0000000000..6d6871e0a8 --- /dev/null +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -0,0 +1,1128 @@ +/** + * Cloud-direct streaming chat. Talks to + * `server.codeium.com/exa.api_server_pb.ApiServerService/GetChatMessage` + * with no local language_server in the path. Returns an async iterable of + * CloudChatEvent deltas (text, reasoning, tool calls, usage, finish) so the + * caller can stream straight into opencodex's internal AdapterEvent model. + * + * What this supports: + * - Single- or multi-turn chat using the prompt-and-history pattern the LS + * uses (flatten history into one ChatMessagePrompt list) + * - All free Windsurf/Cognition models (swe-1-7, swe-1-7-lightning, etc.) + * and any model the user's api_key is entitled to + * - Streaming (uses Connect-streaming envelope, emits deltas as they arrive) + * - Tool definitions (encoded via `encodeToolDef`) and tool-call events + * (tool_call_start, tool_call_args) decoded from the response stream + * - Usage and finish-reason events for terminal completion + * + * Wire-protocol: Connect-RPC streaming over HTTPS with manual protobuf + * encoding (see `wire.ts`). + */ + +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import { + encodeMessage, + encodeString, + encodeVarintField, + frameConnectStream, + iterFields, + parseConnectFrames, +} from './wire.js'; +import { buildMetadata } from './metadata.js'; +import { getCachedUserJwt } from './auth.js'; +import { getCachedCatalog, ModelNotAvailableError } from './catalog.js'; +import { anySignal } from '../../../lib/abort.js'; + +/** + * Connect-RPC streaming inactivity timeout. If the cloud sends zero bytes + * for this long after the last chunk, we abort the fetch. The cloud's own + * idle limit is around 90s on most models; we set ours a little above so + * we only trigger when the server has genuinely stopped responding. + */ +const CLOUD_STREAM_IDLE_MS = 120_000; +/** Time-to-first-byte timeout. */ +const CLOUD_STREAM_TTFB_MS = 60_000; +/** Maximum acceptable Connect-RPC frame length (16 MB). */ +const MAX_FRAME_LEN = 16 * 1024 * 1024; + +/** + * Per-(apiKey, host) session/cascade ID cache. Cloud uses these for + * server-side context caching across turns of the same conversation; if we + * mint a fresh sessionId on every call (which we used to), every turn looks + * like a brand-new session and the prompt-cache hit ratio is zero. + * Single-process scope is enough: opencode lives in one runtime for a TUI + * session, and CLI one-shots don't benefit from caching anyway. + */ +interface SessionIds { + sessionId: string; + cascadeId: string; +} +const sessionCache = new Map(); +function getOrAllocateSessionIds(apiKey: string, host: string, cascadeIdOverride?: string): SessionIds { + const key = `${host}\x1f${apiKey}`; + let ids = sessionCache.get(key); + if (!ids) { + ids = { + sessionId: crypto.randomUUID(), + cascadeId: cascadeIdOverride ?? allocateCascadeId(), + }; + sessionCache.set(key, ids); + } else if (cascadeIdOverride && ids.cascadeId !== cascadeIdOverride) { + // Caller explicitly requested a different cascadeId — honor it. + ids = { sessionId: ids.sessionId, cascadeId: cascadeIdOverride }; + sessionCache.set(key, ids); + } + return ids; +} + +/** Drop the cached session IDs — call after logout so a new sign-in starts fresh. */ +export function clearSessionIds(): void { + sessionCache.clear(); +} + +// ---------------------------------------------------------------------------- +// Per-conversation cascade state — generated client-side; cloud lazy-registers +// ---------------------------------------------------------------------------- + +/** + * Allocate a fresh cascade UUID. The cloud lazy-registers cascade_id on first + * use — confirmed empirically (random UUID accepted, model responded). One + * cascade_id per opencode-CLI conversation is fine; reuse across turns to + * preserve server-side context. + */ +export function allocateCascadeId(): string { + return crypto.randomUUID(); +} + +// ---------------------------------------------------------------------------- +// Request encoders +// ---------------------------------------------------------------------------- + +/** + * ChatMessagePrompt { + * #2 source: enum CHAT_MESSAGE_SOURCE_USER=1 / ASSISTANT=2 / SYSTEM=3 / TOOL=4 + * #3 prompt: string (text content) + * #4 num_tokens: int (rough estimate) + * #5 safe_for_code_telemetry: bool (1 = ok to log) + * #10 images: repeated ImageData (multimodal) + * } + * + * ImageData (exa.codeium_common_pb.ImageData) { + * #1 base64_data: string + * #2 mime_type: string + * #3 caption: string (optional) + * } + */ +function encodeImageData(img: { mimeType: string; base64Data: string; caption?: string }): Buffer { + const parts: Buffer[] = [ + encodeString(1, img.base64Data), + encodeString(2, img.mimeType), + ]; + if (img.caption) parts.push(encodeString(3, img.caption)); + return Buffer.concat(parts); +} + +/** + * Encode one ChatToolCall sub-message: + * {#1 id, #2 name, #3 arguments_json} + * Verified against `exa.codeium_common_pb.ChatToolCall` from extension.js. + */ +function encodeChatToolCall(tc: { id: string; name: string; arguments: string }): Buffer { + return Buffer.concat([ + encodeString(1, tc.id), + encodeString(2, tc.name), + encodeString(3, tc.arguments), + ]); +} + +function encodeChatMessagePrompt( + content: ContentPart[], + source: number, + opts?: { toolCallId?: string; toolCalls?: Array<{ id: string; name: string; arguments: string }> }, +): Buffer { + const textParts = content.filter((p): p is { type: 'text'; text: string } => p.type === 'text'); + const imageParts = content.filter((p): p is { type: 'image'; mimeType: string; base64Data: string; caption?: string } => p.type === 'image'); + const joined = textParts.map((p) => p.text).join('\n'); + const parts: Buffer[] = [ + encodeVarintField(2, source), + encodeString(3, joined), + encodeVarintField(4, Math.max(1, Math.floor(joined.length / 4))), + encodeVarintField(5, 1), + ]; + // Tool-result message: attach the id of the call this result answers. + // Without it, the model can't pair multi-tool conversations. + if (opts?.toolCallId) { + parts.push(encodeString(7, opts.toolCallId)); + } + // Assistant message with tool_calls: encode each as a ChatToolCall. + if (opts?.toolCalls && opts.toolCalls.length > 0) { + for (const tc of opts.toolCalls) { + parts.push(encodeMessage(6, encodeChatToolCall(tc))); + } + } + for (const img of imageParts) { + parts.push(encodeMessage(10, encodeImageData(img))); + } + return Buffer.concat(parts); +} + +const SOURCE_BY_ROLE: Record = { + user: 1, + assistant: 2, + // NOTE: do not send source=3 (SYSTEM) directly — the Codeium chat backend + // returns "third-party model provider is experiencing issues" when any + // ChatMessagePrompt has source=SYSTEM. The captured LS upstream traffic + // shows the IDE inlines system context into the *user* prompt (source=1) + // wrapped in .... We collapse + // role:'system' messages into the next user turn before building the + // proto — see `collapseSystemIntoUser` below. + system: 1, + tool: 4, +}; + +/** + * Collapse OpenAI-style messages so all `role:'system'` entries are inlined + * into the immediately-following user message, matching the wire format the + * IDE uses. Cognition's chat backend rejects raw role=system entries. + * + * [{system: "S1"}, {system: "S2"}, {user: "U1"}, {assistant: "A1"}, {user: "U2"}] + * + * becomes + * + * [{user: "\nS1\nS2\n\nU1"}, {assistant: "A1"}, {user: "U2"}] + * + * If there's no following user message, the trailing system messages get + * appended as a synthesized user turn. + */ +function collapseSystemIntoUser(messages: ChatHistoryItem[]): ChatHistoryItem[] { + const out: ChatHistoryItem[] = []; + let pendingSystem: string[] = []; + + const flushTextOf = (content: ContentPart[]): string => + content.filter((p): p is { type: 'text'; text: string } => p.type === 'text') + .map((p) => p.text).join('\n'); + + for (const m of messages) { + if (m.role === 'system') { + const parts = normalizeContent(m.content); + const text = flushTextOf(parts); + if (text) pendingSystem.push(text); + } else if (m.role === 'user' && pendingSystem.length > 0) { + const userParts = normalizeContent(m.content); + const userText = flushTextOf(userParts); + const userImages = userParts.filter((p) => p.type === 'image'); + const wrapped = `\n${pendingSystem.join('\n\n')}\n\n${userText}`; + const newContent: ContentPart[] = [{ type: 'text', text: wrapped }, ...userImages]; + out.push({ role: 'user', content: newContent }); + pendingSystem = []; + } else { + // Flush accumulated system text before any non-system, non-user turn + // (assistant / tool) so system instructions keep their leading position + // instead of being deferred to a trailing synthesized user message. + if (pendingSystem.length > 0) { + out.push({ + role: 'user', + content: [{ type: 'text', text: `\n${pendingSystem.join('\n\n')}\n` }], + }); + pendingSystem = []; + } + out.push(m); + } + } + if (pendingSystem.length > 0) { + // Trailing system messages with no following user turn — convert to a + // standalone user message so they still reach the model. + out.push({ + role: 'user', + content: [{ type: 'text', text: `\n${pendingSystem.join('\n\n')}\n` }], + }); + } + return out; +} + +/** + * CompletionConfiguration — mirrors the LS-shipped defaults, lets the caller + * override the obvious knobs. + */ +function encodeCompletionConfiguration(opts: { + maxOutputTokens?: number; + maxInputTokens?: number; + temperature?: number; + topK?: number; + topP?: number; +}): Buffer { + const enc64 = (fieldNum: number, n: number): Buffer => { + const b = Buffer.alloc(8); + b.writeDoubleLE(n, 0); + return Buffer.concat([Buffer.from([(fieldNum << 3) | 1]), b]); + }; + return Buffer.concat([ + encodeVarintField(1, 1), + encodeVarintField(2, opts.maxInputTokens ?? 64000), + // Default to the catalog's most permissive `maxOutputTokens` (128K). + // The cloud clamps to the per-model limit anyway. The old 4096 default + // would silently truncate any callers (tests, CLI users of + // streamChatEvents directly) who didn't override. + encodeVarintField(3, opts.maxOutputTokens ?? 128_000), + enc64(5, opts.temperature ?? 0.7), + enc64(6, opts.topP ?? 0.95), + encodeVarintField(7, opts.topK ?? 50), + enc64(8, 1.0), + enc64(11, 1.0), + ]); +} + +/** + * Multimodal content part — text or image. + * + * Text: `{ type: 'text', text: '...' }` + * Image: `{ type: 'image', mimeType: 'image/png', base64Data: '...' [, caption: '...'] }` + * + * Matches the OpenAI/@ai-sdk multimodal message shape — we accept their + * `image_url: { url: 'data:image/png;base64,...' }` form via {@link parseContent}. + */ +export type ContentPart = + | { type: 'text'; text: string } + | { type: 'image'; mimeType: string; base64Data: string; caption?: string }; + +export interface ChatHistoryItem { + role: 'user' | 'assistant' | 'system' | 'tool'; + /** + * Either a plain string or an array of {@link ContentPart}. Plain strings are + * shorthand for `[{ type: 'text', text: '...' }]`. + */ + content: string | ContentPart[]; + /** + * For `role: 'tool'` only — the id of the assistant's preceding tool_call + * this message answers. Required by the cloud's chat backend to pair + * tool results with calls; without it, multi-tool conversations can't + * tell the model which call produced which result. Encoded as + * ChatMessagePrompt field #7 (verified against the Windsurf bundled + * extension.js proto schema `exa.chat_pb.ChatMessagePrompt`). + */ + tool_call_id?: string; + /** + * For `role: 'assistant'` only — the tool calls the assistant emitted. + * Encoded as ChatMessagePrompt field #6 (repeated ChatToolCall, where + * each ChatToolCall has #1 id, #2 name, #3 arguments_json). + */ + tool_calls?: Array<{ id: string; name: string; arguments: string }>; +} + +/** + * Normalize ChatHistoryItem content into structured parts. Accepts strings, + * OpenAI multimodal `[{type:'text',text}, {type:'image_url',image_url}]`, and + * our own `[{type:'image', mimeType, base64Data}]`. + */ +function normalizeContent(content: string | ContentPart[] | unknown): ContentPart[] { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + if (!Array.isArray(content)) return []; + const out: ContentPart[] = []; + // Each element may follow our own ContentPart shape, the OpenAI multimodal + // `image_url` shape, or be malformed — narrow defensively per branch. + const parts = content as Array>; + for (const p of parts) { + if (!p || typeof p !== 'object') continue; + if (p.type === 'text' && typeof p.text === 'string') { + out.push({ type: 'text', text: p.text }); + } else if (p.type === 'image' && typeof p.base64Data === 'string') { + const mimeType = typeof p.mimeType === 'string' ? p.mimeType : 'image/png'; + const caption = typeof p.caption === 'string' ? p.caption : undefined; + out.push({ type: 'image', mimeType, base64Data: p.base64Data, caption }); + } else if (p.type === 'image_url' && p.image_url) { + // OpenAI/@ai-sdk shape — parse data: URL into base64 + mime. + const imgRef = p.image_url as string | { url?: string }; + const url: string = typeof imgRef === 'string' ? imgRef : (imgRef.url ?? ''); + const m = url.match(/^data:([^;]+);base64,(.+)$/); + if (m) out.push({ type: 'image', mimeType: m[1], base64Data: m[2] }); + else if (url) out.push({ type: 'text', text: `[image url: ${url}]` }); + } + } + return out; +} + +export interface ToolDef { + /** Function name. */ + name: string; + /** Plain-English description. */ + description: string; + /** JSON Schema for the function's arguments. */ + parameters: unknown; +} + +/** + * Streaming event emitted by the cloud-direct chat loop. + * + * - `text` : incremental visible content from the assistant + * - `reasoning` : incremental internal thinking (Anthropic-style, kept + * separate from visible content; @ai-sdk consumers can + * render in a collapsed/grey region) + * - `tool_call_*` : function-calling deltas (id+name once, args streamed) + * - `finish` : stream terminated cleanly with a reason + * - `usage` : final token-accounting block (input/output/total counts) + */ +export type CloudChatEvent = + | { kind: 'text'; text: string } + | { kind: 'reasoning'; text: string } + | { kind: 'tool_call_start'; id: string; name: string } + | { + kind: 'tool_call_args'; + argsDelta: string; + /** + * Tool-call id this delta belongs to, when the cloud surfaced one in + * this frame. Cognition's wire format only carries id on the START + * frame today, so most argsDelta events arrive without one — callers + * route those to the most-recent-start by convention. If Cognition + * ever interleaves args across calls, the consumer should prefer + * `id` over the rolling lastToolCallId. + */ + id?: string; + } + // Note: there is no `tool_call_end` event. Cognition's wire format + // signals the end of a tool call implicitly — args just stop arriving + // for the current id and either a new `tool_call_start` fires or the + // stream finishes. Consumers should treat each `tool_call_start` as + // ending the previous call. + | { kind: 'finish'; reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' } + | { + kind: 'usage'; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + /** + * Tokens served from the cache. Surfaced separately so callers tracking + * cost can distinguish them from fresh input tokens (Anthropic / OpenAI + * both bill cache reads cheaper than fresh prompts). + */ + cachedInputTokens?: number; + /** Tokens written to the cache on this request (Anthropic-style). */ + cacheCreationInputTokens?: number; + /** Reasoning tokens (gpt-5-x reasoning models, Claude thinking variants). */ + reasoningTokens?: number; + }; + +interface BuildArgs { + apiKey: string; + userJwt: string; + modelUid: string; + messages: ChatHistoryItem[]; + cascadeId: string; + promptId: string; + sessionId: string; + requestId: bigint; + triggerId: string; + tools?: ToolDef[]; + /** Default 5 = CHAT_MESSAGE_REQUEST_TYPE_CASCADE (matches captured LS body). */ + requestType?: number; + completionOpts?: { + maxOutputTokens?: number; + maxInputTokens?: number; + temperature?: number; + topK?: number; + topP?: number; + }; +} + +/** + * ChatToolDefinition proto, observed in the LS upstream traffic: + * { #1 name (string), #2 description (string), #3 parameters_schema (JSON string) } + * + * Truncation note: Codeium's tool validator rejects very long descriptions + * with a generic `failed_precondition: "Unable to process request due to an + * MCP configuration issue."` error. opencode ships some tools (notably `bash`) + * with ~9.6 KB descriptions packed with examples and rules. We truncate to a + * conservative `MAX_DESC_LEN` and append an ellipsis so the cloud accepts + * them. The model still gets the first chunk of the description (where the + * essential signature lives); detailed examples are sacrificed for + * compatibility. + */ +/** + * The Codeium tool validator rejects any tool whose description hits exactly + * 7,000 chars (or more) with a misleading `failed_precondition: "Unable to + * process request due to an MCP configuration issue."` error. Binary-search + * verified to char-precision: + * - 6,999 chars → server accepts + * - 7,000 chars → server returns MCP error + * + * The limit is per-description, content-sensitive (plain `a`-repeats up to + * 20K work fine; the bash description's exact byte at position 6999 trips + * it). We truncate to the maximum-1 (6,998) for a one-char safety margin. + * + * We do NOT need to aggregate-cap — 200K total tool descriptions across 200 + * tools was confirmed to pass server-side. Only per-string length is gated. + */ +const MAX_TOOL_DESC_LEN = 6998; + +/** + * Cognition's cloud enforces a case-sensitive, whitespace-exact exact-phrase + * blocklist on tool descriptions. Binary-search isolated the trigger to the + * 7-word phrase "Takes a task_id parameter identifying the task" — verbatim, + * capital T, single spaces — which causes a `permission_denied` trailer error + * regardless of model or account tier. Any deviation (lowercase, reword, + * reorder, extra whitespace) passes. The phrase appears verbatim in Claude + * Code's built-in TaskOutput tool description. + * + * Rewrite known triggers to meaning-preserving forms. This is a + * Cognition-specific constraint alongside the length limit above; if + * Cognition adds more blocklisted phrases, extend this table and add a + * regression test in tests/devin-adapter.test.ts. + */ +const COGNITION_BLOCKLIST_REWRITES: ReadonlyArray<[RegExp, string]> = [ + [/\bTakes a task_id parameter identifying the task\b/g, "Accepts a task_id parameter identifying the task"], +]; + +function sanitizeToolDescriptionForCognition(description: string): string { + let out = description; + for (const [pattern, replacement] of COGNITION_BLOCKLIST_REWRITES) { + out = out.replace(pattern, replacement); + } + return out; +} + +/** Test-only: exercise the Cognition blocklist rewrite directly. */ +export function sanitizeToolDescriptionForCognitionForTests(description: string): string { + return sanitizeToolDescriptionForCognition(description); +} + +function encodeToolDef(tool: ToolDef): Buffer { + const rawDesc = sanitizeToolDescriptionForCognition(tool.description ?? ''); + const desc = + rawDesc.length > MAX_TOOL_DESC_LEN + ? rawDesc.slice(0, MAX_TOOL_DESC_LEN - 24) + '\n…(truncated for cloud)' + : rawDesc; + return Buffer.concat([ + encodeString(1, tool.name), + encodeString(2, desc), + encodeString(3, JSON.stringify(tool.parameters ?? {})), + ]); +} + +function buildGetChatMessageRequest(args: BuildArgs): Buffer { + const metadata = buildMetadata({ + apiKey: args.apiKey, + userJwt: args.userJwt, + sessionId: args.sessionId, + requestId: args.requestId, + triggerId: args.triggerId, + }); + + // System messages must be inlined into the user turn (Cognition cloud + // rejects source=3). See `collapseSystemIntoUser` for the format. + const collapsed = collapseSystemIntoUser(args.messages); + const promptParts = collapsed.map((m) => + encodeMessage( + 3, + encodeChatMessagePrompt( + normalizeContent(m.content), + SOURCE_BY_ROLE[m.role] ?? 1, + // Thread tool_call_id (for tool results) + tool_calls (for assistant + // turns that fired tools) into the proto. Cloud rejects multi-tool + // conversations otherwise — it can't pair a tool result with the + // assistant call that produced it. + { + toolCallId: m.role === 'tool' ? m.tool_call_id : undefined, + toolCalls: m.role === 'assistant' ? m.tool_calls : undefined, + }, + ), + ), + ); + + const completion = encodeCompletionConfiguration(args.completionOpts ?? {}); + + const toolParts: Buffer[] = (args.tools ?? []).map((t) => + encodeMessage(10, encodeToolDef(t)), + ); + + // Field layout from mitm capture of the LS: + // #1 metadata + // #3 chat_message_prompts (repeated — one element per history turn) + // #7 request_type (varint enum) + // #8 completion_configuration + // #10 tools (repeated ChatToolDefinition) + // #16 cascade_id (string) + // #21 chat_model_uid (string) + // #22 prompt_id (string) + return Buffer.concat([ + encodeMessage(1, metadata), + ...promptParts, + encodeVarintField(7, args.requestType ?? 5), + encodeMessage(8, completion), + ...toolParts, + encodeString(16, args.cascadeId), + encodeString(21, args.modelUid), + encodeString(22, args.promptId), + ]); +} + +// ---------------------------------------------------------------------------- +// Response parsing — pull `delta_text` (top-level field #9) out of each frame +// ---------------------------------------------------------------------------- + +/** + * Decode a single streaming ChatMessage proto frame into one or more + * CloudChatEvents. Captured shape (from a tool-using swe-1.6 chat): + * + * ChatMessage { + * #1 bot_id (string) + * #2 timestamp { seconds, nanos } + * #5 finish_reason (varint — 10 = "tool_calls" observed, others unknown) + * #6 ToolCallDelta { + * #1 id (string, only on first tool-call frame) + * #2 name (string, only on first tool-call frame) + * #3 arguments_delta (string, JSON fragment, streamed) + * } + * #7 ChatStatus { #6 status_code, #9 model_name } + * #9 delta_text (string) + * #12 (fixed64) some_hash + * #17 (string) message_uuid + * #28 UsageStats { #1 label, ... } + * } + * + * #9 appears both at top-level (text delta) AND inside #7 (model_name). + * iterFields walks top-level only, so we don't confuse the two. + * + * #5 is the finish_reason. Observed value `10` = tool_calls finish. We map + * any non-zero to 'tool_calls' for now (and let the caller fall back to + * 'stop' if no tool_call deltas were emitted). + */ +function* decodeChatFrame(proto: Buffer): Generator { + for (const f of iterFields(proto)) { + if (f.num === 3 && f.wire === 2 && Buffer.isBuffer(f.value)) { + // Visible delta_text — what the user should SEE in the chat. + // + // We previously had this mapping inverted (#3 = thinking, #9 = visible), + // which produced two compounding bugs in the TUI: + // 1. The model's CoT was rendered as plain content, so the user saw + // "The user wants me to X..." instead of the answer. + // 2. The actual answer (which lives in #3) was silently dropped — so + // the assistant turn appeared to end after the CoT with nothing + // after, matching the "model wrote reasoning then went silent" + // symptom the user reported. + // Verified live: prompted swe-1.6 with "explain then answer 2+2"; #3 + // streamed "2+2=4 because... 4" while #9 streamed the meta-narration + // "The user wants me to perform a reasoning task...". + const s = (f.value as Buffer).toString('utf8'); + if (s) yield { kind: 'text', text: s }; + } else if (f.num === 9 && f.wire === 2 && Buffer.isBuffer(f.value)) { + // Internal thinking / chain-of-thought. Surface as `reasoning` so + // @ai-sdk consumers (opencode TUI) render it in a collapsed grey + // block instead of inline with the answer. + const s = (f.value as Buffer).toString('utf8'); + if (s) yield { kind: 'reasoning', text: s }; + } else if (f.num === 6 && f.wire === 2 && Buffer.isBuffer(f.value)) { + let id: string | undefined; + let name: string | undefined; + let argsDelta: string | undefined; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.wire === 2 && Buffer.isBuffer(sf.value)) { + const s = (sf.value as Buffer).toString('utf8'); + if (sf.num === 1) id = s; + else if (sf.num === 2) name = s; + else if (sf.num === 3) argsDelta = s; + } + } + if (id !== undefined && name !== undefined) { + yield { kind: 'tool_call_start', id, name }; + } + if (argsDelta !== undefined) { + // Pass through `id` when this frame carries one (Cognition only + // sets it on the start frame today, but defending against future + // interleaving). Callers should prefer `id` over their rolling + // lastToolCallId when both are available. + yield { kind: 'tool_call_args', argsDelta, ...(id !== undefined ? { id } : {}) }; + } + } else if (f.num === 5 && f.wire === 0) { + const v = Number(f.value); + // exa.codeium_common_pb.StopReason → OpenAI finish_reason. + // Source of truth: Windsurf extension.js sets `setEnumType("StopReason", [...])` + // 0 UNSPECIFIED → "stop" (no signal — treat as natural end) + // 1 INCOMPLETE → "length" (request cut short, model wanted more) + // 2 STOP_PATTERN → "stop" (model emitted its stop sequence — NORMAL) + // 3 MAX_TOKENS → "length" + // 4-9 internal → "stop" + // 10 FUNCTION_CALL → "tool_calls" + // 11 CONTENT_FILTER → "content_filter" + // 12 NON_INSERTION → "stop" + // 13 ERROR → "stop" (errors come as Connect trailer, not via this) + // + // We had 2 and 3 swapped previously, which made the model's normal + // STOP_PATTERN look like "length" → @ai-sdk treated complete responses + // as truncated. That was the "model wrote reasoning then went silent" + // symptom the user kept hitting. + let reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' = 'stop'; + if (v === 10) reason = 'tool_calls'; + else if (v === 11) reason = 'content_filter'; + else if (v === 1 || v === 3) reason = 'length'; + // else stays 'stop' for 0/2/4-9/12/13 + yield { kind: 'finish', reason }; + } else if (f.num === 28 && f.wire === 2 && Buffer.isBuffer(f.value)) { + const usage = decodeUsageBlock(f.value as Buffer); + if (usage) yield usage; + } + } +} + +/** + * UsageStats block at proto field #28. Captured shape (mitm of a real call): + * + * UsageStats { + * #1 label = "Token Usage" + * #2 entries [ + * UsageEntry { + * #1 label = "Input tokens" / "Output tokens" / "Cached tokens" / ... + * #2 value (fixed32 — IEEE 754 float, OpenAI-style count cast) + * #3 unit = " tokens" + * #5 metric_id = "input_tokens" / "output_tokens" / ... + * }, + * ... + * ] + * } + * + * We extract the standard input/output counts and synthesize a `total`. + * Anything else (cached, reasoning_tokens, …) is dropped for v1. + */ +function decodeUsageBlock(buf: Buffer): CloudChatEvent | null { + let promptTokens: number | undefined; + let completionTokens: number | undefined; + let cachedInputTokens: number | undefined; + let cacheCreationInputTokens: number | undefined; + let reasoningTokens: number | undefined; + + for (const f of iterFields(buf)) { + // Each UsageEntry lives at field 2 (repeated). Field 1 is the block label + // ("Token Usage"); skip. + if (f.num !== 2 || f.wire !== 2 || !Buffer.isBuffer(f.value)) continue; + + // Observed entry shape: + // UsageEntry { + // #4 (sub-message) { + // #1 label = "Input tokens" / "Output tokens" + // #2 (fixed32) value (IEEE 754 LE float — count as float) + // #3 unit = " token" + // #4 unit_plural = " tokens" + // } + // #5 metric_id = "input_tokens" / "output_tokens" / "cached_input_tokens" / ... + // } + let entryMetric: string | undefined; + let entryValue: number | undefined; + for (const sf of iterFields(f.value as Buffer)) { + if (sf.num === 5 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + entryMetric = (sf.value as Buffer).toString('utf8'); + } else if (sf.num === 4 && sf.wire === 2 && Buffer.isBuffer(sf.value)) { + // Recurse into the displayed-dimension submessage to pull the fixed32 + // value at its field 2. + for (const ssf of iterFields(sf.value as Buffer)) { + if (ssf.num === 2 && ssf.wire === 5 && Buffer.isBuffer(ssf.value)) { + entryValue = (ssf.value as Buffer).readFloatLE(0); + break; + } + } + } + } + if (entryMetric && entryValue !== undefined && Number.isFinite(entryValue)) { + const n = Math.round(entryValue); + if (entryMetric === 'input_tokens') promptTokens = n; + else if (entryMetric === 'output_tokens') completionTokens = n; + else if (entryMetric === 'cached_input_tokens' || entryMetric === 'cache_read_input_tokens') { + cachedInputTokens = (cachedInputTokens ?? 0) + n; + } else if (entryMetric === 'cache_creation_input_tokens') { + cacheCreationInputTokens = (cacheCreationInputTokens ?? 0) + n; + } else if (entryMetric === 'reasoning_tokens' || entryMetric === 'output_reasoning_tokens') { + reasoningTokens = (reasoningTokens ?? 0) + n; + } + } + } + if (promptTokens === undefined && completionTokens === undefined) return null; + // totalTokens reflects what OpenAI's API counts as billable: input + + // output. Cached / cache-creation / reasoning subtotals are surfaced as + // additional fields so callers that want a fuller picture (e.g. cost + // breakdown for reasoning models) can read them, but they're NOT + // double-counted into total. + const total = (promptTokens ?? 0) + (completionTokens ?? 0); + return { + kind: 'usage', + promptTokens, + completionTokens, + totalTokens: total > 0 ? total : undefined, + cachedInputTokens, + cacheCreationInputTokens, + reasoningTokens, + }; +} + +// ---------------------------------------------------------------------------- +// Public API: streamChat +// ---------------------------------------------------------------------------- + +export interface CloudChatRequest { + /** Persistent OAuth-issued api_key (`devin-session-token$`). */ + apiKey: string; + /** Pre-resolved API server URL from RegisterUser (falls back to default). */ + apiServerUrl?: string; + /** Model UID — e.g. `swe-1-6`, `kimi-k2-6`, `claude-opus-4-7-medium`. */ + modelUid: string; + /** Chat history. */ + messages: ChatHistoryItem[]; + /** + * Tool definitions available to the model. Cloud encodes these in the + * GetChatMessage request's `tools` field (proto #10). When set, the model + * may emit `tool_call_start`/`_args`/`_end` events instead of plain text. + */ + tools?: ToolDef[]; + /** Cascade ID — reuse across turns of the same conversation. */ + cascadeId?: string; + /** Optional sampling overrides. */ + completionOpts?: BuildArgs['completionOpts']; + /** Override request_type (default = 5, CASCADE). */ + requestType?: number; + /** Abort signal — closes the fetch stream. */ + signal?: AbortSignal; +} + +export class CloudChatError extends Error { + constructor(message: string, public readonly code?: string, public readonly traceId?: string) { + super(message); + this.name = 'CloudChatError'; + } +} + +const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; + +/** + * Stream chat events from the cloud. Yields CloudChatEvent (text deltas, tool + * call deltas, finish reason). Use `streamChatText` for legacy text-only iteration. + * + * On error (auth fail, quota exhausted, malformed request) throws a + * CloudChatError with the cloud's `code` + `traceId` for diagnostics. + */ +export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator { + const host = (req.apiServerUrl ?? 'https://server.codeium.com').replace(/\/$/, ''); + const userJwt = await getCachedUserJwt(req.apiKey, host, req.signal); + + // Pre-flight: consult the per-account model catalog. Cognition's cloud + // returns an opaque `permission_denied: "an internal error occurred (trace + // ID: ...)"` for every chat call that targets a model not enabled on the + // caller's tier — issue #14. The catalog's `disabled` flag is the + // authoritative source for "can this account run this UID"; we surface a + // named error here so the user knows why instead of guessing. + // + // Best-effort: if the catalog fetch fails (network, auth, schema drift) we + // pass through to the chat call. The cloud will still surface its own + // error and the trailer-error path below enriches the message in-place. + // Treat an empty catalog (schema drift / unexpected response) as "no catalog" + // so chat passes through instead of failing every request. + const catalog = await getCachedCatalog(req.apiKey, host, req.signal).catch(() => null); + if (catalog && catalog.byUid.size > 0) { + const entry = catalog.byUid.get(req.modelUid); + if (!entry) { + throw new ModelNotAvailableError(req.modelUid, req.modelUid, 'not_listed'); + } + if (entry.disabled) { + throw new ModelNotAvailableError(req.modelUid, entry.label, 'disabled'); + } + } + + // Reuse session + cascade ids across calls for the same (apiKey, host). + // Without this, every turn looks like a brand-new server-side session + // and the cloud's prompt cache never hits — significant cost regression + // for long conversations. + const sessionIds = getOrAllocateSessionIds(req.apiKey, host, req.cascadeId); + + const proto = buildGetChatMessageRequest({ + apiKey: req.apiKey, + userJwt, + modelUid: req.modelUid, + messages: req.messages, + tools: req.tools, + cascadeId: sessionIds.cascadeId, + promptId: crypto.randomUUID(), + sessionId: sessionIds.sessionId, + requestId: BigInt(Date.now()), + triggerId: crypto.randomUUID(), + requestType: req.requestType, + completionOpts: req.completionOpts, + }); + const framed = frameConnectStream(proto, true); + const body = new Blob([new Uint8Array(framed)], { type: "application/connect+proto" }); + + // Compose caller signal with a TTFB timeout. If the cloud takes longer + // than CLOUD_STREAM_TTFB_MS to start the response, abort. Once any byte + // arrives we cancel the TTFB timer and start the per-chunk idle timer + // inside the read loop instead. + const ttfbController = new AbortController(); + const ttfbTimer = setTimeout(() => ttfbController.abort(new Error(`cloud-direct: time-to-first-byte timeout (${CLOUD_STREAM_TTFB_MS}ms)`)), CLOUD_STREAM_TTFB_MS); + const ttfbSignal = ttfbController.signal; + // Compose req.signal + ttfbSignal. AbortSignal.any was added in Node + // 20.3 / Bun 1.0; our `engines` allows Node ≥18, so on Node 18-20.2 the + // built-in is missing. The previous fallback `req.signal ?? ttfbSignal` + // silently discarded one of the two signals (TTFB if caller passed + // one), defeating the timeout guard. anySignal() is a real polyfill. + const initialSignal: AbortSignal = req.signal ? anySignal([req.signal, ttfbSignal]) : ttfbSignal; + + let resp: Response; + try { + resp = await fetch(`${host}/exa.api_server_pb.ApiServerService/GetChatMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/connect+proto', + 'Connect-Protocol-Version': '1', + 'Connect-Content-Encoding': 'gzip', + 'Connect-Accept-Encoding': 'gzip', + }, + body, + signal: initialSignal, + }); + } finally { + clearTimeout(ttfbTimer); + } + + if (!resp.ok) { + const text = await resp.text(); + throw new CloudChatError(`GetChatMessage HTTP ${resp.status}: ${text.slice(0, 300)}`, undefined); + } + if (!resp.body) { + throw new CloudChatError('GetChatMessage response had no body stream'); + } + + // Incremental parsing. We previously did `pending = Buffer.concat([pending, + // chunk])` per chunk — O(n²) over a long stream because every chunk copies + // every buffered byte again. Now we keep a queue of arriving chunks with a + // running offset; we only `Buffer.concat` when a frame straddles a chunk + // boundary, and we slice/drop fully-consumed chunks immediately. For + // typical 50-200KB responses this is ~5x faster and produces zero waste. + const chunkQueue: Buffer[] = []; + let queuedBytes = 0; + // Bun + Node ReadableStream readers diverge on the type-level shape + // (Bun's includes a `readMany` method); both work the same at runtime. + const reader = resp.body.getReader() as ReadableStreamDefaultReader; + let trailerError: { code?: string; message: string; traceId?: string } | null = null; + let sawEos = false; + + /** + * Try to read the next `n` bytes from the chunk queue WITHOUT consuming + * them. Returns null if not enough buffered. + */ + function peek(n: number): Buffer | null { + if (queuedBytes < n) return null; + if (chunkQueue.length === 1 && chunkQueue[0].length >= n) { + return chunkQueue[0].slice(0, n); + } + // Cross-chunk peek — concat just the prefix we need. + const parts: Buffer[] = []; + let remaining = n; + for (const c of chunkQueue) { + if (remaining <= 0) break; + if (c.length <= remaining) { + parts.push(c); + remaining -= c.length; + } else { + parts.push(c.slice(0, remaining)); + remaining = 0; + } + } + return Buffer.concat(parts, n); + } + + /** Drop the first `n` bytes from the chunk queue. */ + function drop(n: number): void { + queuedBytes -= n; + let remaining = n; + while (remaining > 0 && chunkQueue.length > 0) { + const head = chunkQueue[0]; + if (head.length <= remaining) { + chunkQueue.shift(); + remaining -= head.length; + } else { + chunkQueue[0] = head.slice(remaining); + remaining = 0; + } + } + } + + // Track the idle timer at outer scope so the finally block can clear it + // regardless of how we exit the read loop (clean done, throw, etc). + // Previously this lived inside `try { ... }` and was only cleared on + // normal exit — an error path left a 120s timer in the event loop and + // the process refused to exit promptly. + let idleTimer: ReturnType | null = null; + try { + const resetIdle = (): Promise<{ value?: Uint8Array; done: boolean }> => { + if (idleTimer) clearTimeout(idleTimer); + const idleController = new AbortController(); + idleTimer = setTimeout( + () => idleController.abort(new Error(`cloud-direct: idle timeout (${CLOUD_STREAM_IDLE_MS}ms with no bytes)`)), + CLOUD_STREAM_IDLE_MS, + ); + // Race the reader.read() against idle abort. When abort wins, we + // also actively `cancel()` the underlying body stream so the + // pending read() resolves promptly with done=true instead of + // hanging on the now-dead TCP socket until the OS notices. + // + // Promise-handling carefully: the reader.read() promise can settle + // AFTER the outer race rejects (we cancelled, the read eventually + // sees the cancellation and either resolves with done=true or + // rejects with an abort error). We attach an explicit `.catch(()=>{})` + // on the read promise so any post-race rejection doesn't surface as + // an unhandled-rejection warning in the host runtime. + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn: () => void): void => { + if (settled) return; + settled = true; + fn(); + }; + const readP = reader.read(); + // Defensive: swallow any post-race rejection. If the outer promise + // already settled via the abort listener, we still need a handler + // attached to readP or Node logs an unhandledRejection. + readP.catch(() => { /* swallowed; outer promise already rejected */ }); + + idleController.signal.addEventListener('abort', () => { + try { void resp.body?.cancel(idleController.signal.reason ?? new Error('idle abort')); } catch { /* */ } + settle(() => reject(idleController.signal.reason ?? new Error('idle abort'))); + }, { once: true }); + + readP.then( + (v) => settle(() => resolve(v)), + (e) => settle(() => reject(e)), + ); + }); + }; + + while (true) { + const { value, done } = await resetIdle(); + if (done) break; + if (value) { + chunkQueue.push(Buffer.from(value)); + queuedBytes += value.length; + } + + // Drain every complete frame currently buffered. + while (queuedBytes >= 5) { + const header = peek(5); + if (!header) break; + const flags = header[0]; + const len = header.readUInt32BE(1); + // Cap frame length to prevent memory exhaustion from a corrupt/malicious + // length prefix. 16MB is well above any legitimate Connect-RPC frame. + if (len > MAX_FRAME_LEN) { + throw new CloudChatError(`Connect frame length ${len} exceeds ${MAX_FRAME_LEN} byte cap`); + } + if (queuedBytes < 5 + len) break; // frame still arriving + drop(5); + const raw = peek(len) ?? Buffer.alloc(0); + drop(len); + + let payload = raw; + if (flags & 0x01) { + try { + payload = zlib.gunzipSync(raw); + } catch (gzipErr) { + // Corrupt compressed frame — surface as a CloudChatError instead + // of falling through and re-parsing raw gzip bytes as proto + // (which used to misparse silently downstream). + throw new CloudChatError(`Connect frame gunzip failed: ${(gzipErr as Error).message}`); + } + } + const eos = (flags & 0x02) !== 0; + + if (eos) { + sawEos = true; + // Trailer: {} on success, {"error":{code,message}} on failure. + const text = payload.toString('utf8'); + if (text && text.includes('"error"')) { + let code: string | undefined; + let message = text; + try { + const j = JSON.parse(text) as { error?: { code?: string; message?: string } }; + code = j.error?.code; + if (j.error?.message) message = j.error.message; + } catch { /* keep raw */ } + const traceMatch = message.match(TRACE_ID_RE); + trailerError = { code, message, traceId: traceMatch?.[1] }; + } + continue; + } + yield* decodeChatFrame(payload); + } + } + } finally { + // Always clear the idle timer. The previous "clear on normal exit + // only" path leaked a 120s setTimeout into the event loop on any + // throw (idle timeout, gunzip error, trailer error, etc), keeping + // the process from exiting promptly. + if (idleTimer) clearTimeout(idleTimer); + // Cancel the underlying body stream on any non-clean exit so the TCP + // connection is released. `releaseLock` alone leaves the body in a + // dangling state; we have to call `cancel` on the response body + // itself (cancel-via-reader requires holding the lock). Fire and + // forget — there's nothing meaningful to do if cancel rejects. + try { reader.releaseLock(); } catch { /* */ } + try { void resp.body?.cancel(); } catch { /* */ } + } + + if (trailerError) { + // Cognition uses `permission_denied: "an internal error occurred (trace + // ID: …)"` as a catch-all for "your account can't run this model" — same + // root cause issue #14 reported. The pre-flight above catches this when + // the catalog disagrees with the call, but the catalog can lag (a model + // that was enabled at fetch time may have been gated between then and + // now) or be missing (network failure caused a fall-through). When the + // raw trailer is this exact shape, swap in a message that names the + // model and explains the likely cause rather than re-passing + // Cognition's opaque text. The cloud's original message is appended in + // parens so users (and bug reports) still have it verbatim. + const isOpaquePermissionDenial = + trailerError.code === 'permission_denied' && + /an internal error occurred/i.test(trailerError.message); + if (isOpaquePermissionDenial) { + const enriched = + `Cognition denied this request for model "${req.modelUid}" with the opaque ` + + `"an internal error occurred" message. This almost always means the model ` + + `is not enabled for your account/tier — see https://codeium.com/account. ` + + `(cloud trace ID: ${trailerError.traceId ?? 'n/a'}; raw message: ${trailerError.message})`; + throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); + } + // Cognition also returns `permission_denied` when a tool description + // contains a blocklisted phrase that the sanitizer above did not catch + // (e.g. Cognition added a new phrase). Surface a clear message so the + // user knows to check tool descriptions rather than suspect auth/tier. + if (trailerError.code === 'permission_denied') { + const enriched = + `Cognition denied this request (permission_denied). If tool descriptions ` + + `are present, a blocklisted phrase may have triggered this — see the ` + + `COGNITION_BLOCKLIST_REWRITES table in cloud-direct/chat.ts. ` + + `(cloud trace ID: ${trailerError.traceId ?? 'n/a'}; raw message: ${trailerError.message})`; + throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); + } + throw new CloudChatError(trailerError.message, trailerError.code, trailerError.traceId); + } + // Truncation detection: the cloud always terminates a successful stream + // with an EOS trailer. If we hit `done` from the body reader without one, + // the connection dropped mid-frame and any bytes still in the queue are + // garbage. Previously those leftover bytes were silently discarded and + // the consumer saw a clean stop with no error — looked like the model + // had finished. Now we surface it. + if (!sawEos) { + throw new CloudChatError( + `Cloud stream ended without EOS trailer (${queuedBytes} bytes orphaned). ` + + `Connection likely dropped mid-response.`, + 'truncated_stream', + ); + } +} + +/** + * Back-compat: yield text content only (drops tool calls). The plugin uses + * streamChatEvents directly when it needs to surface tool_calls. + */ +export async function* streamChat(req: CloudChatRequest): AsyncGenerator { + for await (const ev of streamChatEvents(req)) { + if (ev.kind === 'text') yield ev.text; + } +} + +// `parseConnectFrames` is no longer needed by streamChat itself, but exported +// from wire.ts for one-shot callers + tests. +void parseConnectFrames; diff --git a/src/adapters/devin/cloud-direct/index.ts b/src/adapters/devin/cloud-direct/index.ts new file mode 100644 index 0000000000..afd42617be --- /dev/null +++ b/src/adapters/devin/cloud-direct/index.ts @@ -0,0 +1,41 @@ +/** + * Public surface of the cloud-direct module. + * + * Usage: + * import { streamChat } from './cloud-direct/index.js'; + * + * for await (const delta of streamChat({ + * apiKey: creds.apiKey, + * apiServerUrl: creds.apiServerUrl, + * modelUid: 'swe-1-6', + * messages: [{ role: 'user', content: 'hi' }], + * })) { + * process.stdout.write(delta); + * } + */ + +export { + streamChat, + streamChatEvents, + allocateCascadeId, + CloudChatError, + type CloudChatRequest, + type ChatHistoryItem, + type CloudChatEvent, + type ToolDef, +} from './chat.js'; + +export { + mintUserJwt, + getCachedUserJwt, + clearCachedUserJwt, + CloudAuthError, +} from './auth.js'; + +export { + getCachedCatalog, + clearCachedCatalog, + ModelNotAvailableError, + type ModelCatalogEntry, + type CacheEntry, +} from './catalog.js'; diff --git a/src/adapters/devin/cloud-direct/metadata.ts b/src/adapters/devin/cloud-direct/metadata.ts new file mode 100644 index 0000000000..01c7c03086 --- /dev/null +++ b/src/adapters/devin/cloud-direct/metadata.ts @@ -0,0 +1,78 @@ +/** + * `exa.codeium_common_pb.Metadata` proto builder. + * + * Field numbers come from src/plugin/discovery.ts (which reads the bundled + * extension.js for live numbers). For cloud-direct we hard-code the canonical + * set of fields the LS always populates — the IDE-extracted dynamic numbers + * would help if Windsurf renumbers, but we don't have a way to refresh those + * without the bundled extension.js path being present. + * + * Captured from real LS upstream traffic via mitm reverse-proxy. See + * docs/CLOUD_DIRECT.md → "The exact captured request body (annotated)". + */ + +import { + encodeMessage, + encodeString, + encodeTimestampBody, + encodeVarintField, +} from './wire.js'; + +/** + * extension_version + ide_version sent to the cloud. MUST be a string the + * cloud recognizes as a real Windsurf release — Cognition's API silently + * rejects unknown version strings with `failed_precondition: "an internal + * error occurred"`. We previously tried pulling our package.json version + * (e.g. "0.3.0") and the cloud rejected every request. Stays pinned to a + * known-good "2.0.0" until/unless someone explicitly overrides via + * `MetadataInput.windsurfVersion`. + */ +const WINDSURF_VERSION_STRING = '2.0.0'; + +export interface MetadataInput { + /** Persistent api_key from OAuth (`devin-session-token$`). */ + apiKey: string; + /** Fresh user_jwt from GetUserJwt — required for chat methods. */ + userJwt?: string; + /** UUID — one per opencode session is fine. */ + sessionId: string; + /** Monotonic, milliseconds since epoch. */ + requestId: bigint; + /** UUID — one per RPC call. */ + triggerId: string; + /** Optional override for the version string. Cosmetic. */ + windsurfVersion?: string; + /** Optional override for the host OS string. */ + osName?: string; +} + +function osString(): string { + switch (process.platform) { + case 'darwin': return 'darwin'; + case 'linux': return 'linux'; + case 'win32': return 'windows'; + default: return String(process.platform); + } +} + +export function buildMetadata(input: MetadataInput): Buffer { + const version = input.windsurfVersion ?? WINDSURF_VERSION_STRING; + const os = input.osName ?? osString(); + const parts: Buffer[] = [ + encodeString(1, 'windsurf'), // ide_name + encodeString(2, version), // extension_version + encodeString(3, input.apiKey), // api_key + encodeString(4, 'en'), // locale + encodeString(5, os), // os + encodeString(7, version), // ide_version + encodeVarintField(9, input.requestId), // request_id (uint64 monotonic) + encodeString(10, input.sessionId), // session_id + encodeString(12, 'windsurf'), // extension_name + encodeMessage(16, encodeTimestampBody()), // ls_timestamp (google.protobuf.Timestamp) + encodeString(25, input.triggerId), // trigger_id + encodeString(26, 'Unset'), // plan_name + encodeString(28, 'windsurf'), // ide_type + ]; + if (input.userJwt) parts.push(encodeString(21, input.userJwt)); // user_jwt + return Buffer.concat(parts); +} diff --git a/src/adapters/devin/cloud-direct/wire.ts b/src/adapters/devin/cloud-direct/wire.ts new file mode 100644 index 0000000000..772f053719 --- /dev/null +++ b/src/adapters/devin/cloud-direct/wire.ts @@ -0,0 +1,202 @@ +/** + * Manual protobuf + Connect-RPC streaming envelope helpers. + * + * Connect-RPC streaming wire format (HTTPS POST body): + * ┌─────────────┬────────────────┬──────────────┐ + * │ flags 1byte │ length 4B BE │ payload │ + * └─────────────┴────────────────┴──────────────┘ + * flags bit 0x01 = payload is gzip-compressed + * flags bit 0x02 = end-of-stream (trailer frame — JSON {error} or empty {}) + * + * All `Get*` methods on `exa.api_server_pb.ApiServerService` that the + * language_server calls upstream use this format, content-type + * `application/connect+proto`, with `Connect-Protocol-Version: 1`. + * + * Kept tiny and dependency-free — same philosophy as src/plugin/protobuf.ts. + */ + +import * as zlib from 'zlib'; + +// ---------------------------------------------------------------------------- +// Proto wire encode +// ---------------------------------------------------------------------------- + +export function encodeVarint(value: number | bigint): Buffer { + const v0 = BigInt(value); + // Reject negatives at the boundary. Proto3 spec encodes signed types as + // 10-byte sign-extended varints; we don't support that here and the + // current call sites never need it (tags, lengths, request ids — all + // strictly positive). The old loop body would have terminated with + // `Number(-1n)` = -1, producing a malformed single 0xFF byte that the + // server would misparse silently. Throw instead so future regressions + // surface immediately. + if (v0 < 0n) { + throw new RangeError(`encodeVarint: negative input not supported (got ${value})`); + } + const bytes: number[] = []; + let v = v0; + while (v > 127n) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +export function encodeTag(fieldNum: number, wire: number): Buffer { + return encodeVarint((fieldNum << 3) | wire); +} + +export function encodeString(fieldNum: number, s: string): Buffer { + const buf = Buffer.from(s, 'utf8'); + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(buf.length), buf]); +} + +export function encodeMessage(fieldNum: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNum, 2), encodeVarint(body.length), body]); +} + +export function encodeVarintField(fieldNum: number, v: number | bigint): Buffer { + return Buffer.concat([encodeTag(fieldNum, 0), encodeVarint(v)]); +} + +export function encodeFixed64Field(fieldNum: number, v: number): Buffer { + const b = Buffer.alloc(8); + b.writeDoubleLE(v, 0); + return Buffer.concat([encodeTag(fieldNum, 1), b]); +} + +export function encodeTimestampBody(): Buffer { + const now = Date.now(); + const seconds = Math.floor(now / 1000); + const nanos = (now % 1000) * 1_000_000; + return Buffer.concat([ + encodeVarintField(1, seconds), + nanos > 0 ? encodeVarintField(2, nanos) : Buffer.alloc(0), + ]); +} + +// ---------------------------------------------------------------------------- +// Proto wire decode +// ---------------------------------------------------------------------------- + +export function decodeVarint(buf: Buffer, offset: number): [bigint, number] { + let res = 0n; + let shift = 0n; + let i = offset; + while (i < buf.length) { + const b = buf[i++]; + res |= BigInt(b & 0x7f) << shift; + if (!(b & 0x80)) return [res, i]; + shift += 7n; + } + throw new Error('truncated varint'); +} + +export interface ProtoField { + num: number; + wire: number; + /** varint → bigint, fixed → 8/4 byte Buffer, length-delim → payload Buffer. */ + value: bigint | Buffer; +} + +export function* iterFields(buf: Buffer): Generator { + let i = 0; + while (i < buf.length) { + const [tagBig, ai] = decodeVarint(buf, i); + i = ai; + const tag = Number(tagBig); + const num = tag >> 3; + const wire = tag & 0x7; + if (wire === 0) { + const [v, bi] = decodeVarint(buf, i); + i = bi; + yield { num, wire, value: v }; + } else if (wire === 1) { + // Bounds-check: a truncated frame mustn't yield a short fixed64 slice + // that downstream readers treat as a full 8-byte value. Stop iterating + // cleanly instead. + if (i + 8 > buf.length) return; + yield { num, wire, value: buf.slice(i, i + 8) }; + i += 8; + } else if (wire === 2) { + const [n, ci] = decodeVarint(buf, i); + i = ci; + const len = Number(n); + // Bounds-check: when the declared length runs past the buffer, the + // frame is corrupt or truncated. Returning short-buffered slices to + // downstream parsers used to misparse silently (M12). + if (len < 0 || i + len > buf.length) return; + yield { num, wire, value: buf.slice(i, i + len) }; + i += len; + } else if (wire === 5) { + if (i + 4 > buf.length) return; + yield { num, wire, value: buf.slice(i, i + 4) }; + i += 4; + } else if (wire === 3 || wire === 4) { + // Wire types 3 (start group) and 4 (end group) are deprecated in + // proto3 but show up in some Codeium server-generated messages. They + // carry no length info; the safe behavior is to stop iterating + // gracefully rather than tear down the whole frame parse. + return; + } else { + // Unknown wire type — bail rather than misalign. + return; + } + } +} + +// ---------------------------------------------------------------------------- +// Connect-streaming envelope +// ---------------------------------------------------------------------------- + +/** + * Wrap `body` (a serialized proto message) in a Connect-streaming envelope. + * If `compress` is true, gzip the payload and set the 0x01 flag. + */ +export function frameConnectStream(body: Buffer, compress = true): Buffer { + let payload = body; + let flags = 0; + if (compress) { + payload = zlib.gzipSync(body); + flags |= 0x01; + } + const header = Buffer.alloc(5); + header[0] = flags; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +export interface ConnectFrame { + flags: number; + /** Decompressed payload (gzip handled here if flags & 0x01). */ + payload: Buffer; + /** Frame is the trailer (end-of-stream). */ + eos: boolean; +} + +/** + * Parse all Connect-streaming frames out of a response body. + * + * Returns array of decoded frames. Each frame's payload is already gzip-decoded + * if the compression flag was set. + */ +export function parseConnectFrames(buf: Buffer): ConnectFrame[] { + const out: ConnectFrame[] = []; + let i = 0; + while (i + 5 <= buf.length) { + const flags = buf[i]; + const len = buf.readUInt32BE(i + 1); + if (i + 5 + len > buf.length) break; + let payload = buf.slice(i + 5, i + 5 + len); + if (flags & 0x01) { + // Compressed frame. If gunzip fails the frame is genuinely corrupt + // — surfacing as a thrown error beats parsing raw gzip bytes as proto + // (which previously produced misleading "yielded bad wire type" downstream). + payload = zlib.gunzipSync(payload); + } + out.push({ flags, payload, eos: (flags & 0x02) !== 0 }); + i += 5 + len; + } + return out; +} diff --git a/src/adapters/devin/live-models.ts b/src/adapters/devin/live-models.ts new file mode 100644 index 0000000000..de44ae0a27 --- /dev/null +++ b/src/adapters/devin/live-models.ts @@ -0,0 +1,97 @@ +/** + * Live Devin / Cognition model discovery via GetCascadeModelConfigs. + * + * The live catalog is the source of truth for the model roster. The endpoint + * returns effort-suffixed variants (e.g. `gpt-5-6-sol-high`); we collapse those + * to base ids so the picker stays clean and the adapter appends the effort + * suffix at request time. `DEVIN_STATIC_MODELS` is only a degraded-mode + * fallback for when there is no API key or discovery fails. + */ +import { getCachedCatalog, type ModelCatalogEntry } from "./cloud-direct"; + +const DEFAULT_HOST = "https://server.codeium.com"; + +/** + * Degraded-mode fallback shown when there is no API key or live discovery + * fails. The live catalog overrides this whenever discovery succeeds. + */ +export const DEVIN_STATIC_MODELS = [ + "swe-1-7", + "swe-1-7-lightning", + "gpt-5-6-sol", + "gpt-5-6-luna", + "gpt-5-6-terra", + "claude-opus-4-8", + "claude-fable-5-1", + "claude-sonnet-5", + "glm-5-2", + "kimi-k2-7", + "grok-4-5", +] as const; + +/** Per-model context windows for Devin/Cognition models. Source: Cognition model catalog. */ +export const DEVIN_MODEL_CONTEXT_WINDOWS: Record = { + "swe-1-7": 256_000, + "swe-1-7-lightning": 256_000, + "gpt-5-6-sol": 1_050_000, + "gpt-5-6-luna": 1_050_000, + "gpt-5-6-terra": 1_050_000, + "claude-opus-4-8": 200_000, + "claude-fable-5-1": 200_000, + "claude-sonnet-5": 200_000, + "glm-5-2": 200_000, + "kimi-k2-7": 256_000, + "grok-4-5": 256_000, +}; + +/** + * Trailing tokens that the Cognition catalog appends as effort/variant + * suffixes. Stripped to collapse suffixed UIDs to their base id. + */ +const EFFORT_TOKENS = new Set([ + "low", "medium", "high", "xhigh", "max", "none", "fast", "priority", "1m", +]); + +/** Collapse an effort-suffixed UID to its base id (e.g. `gpt-5-6-sol-high` → `gpt-5-6-sol`). */ +export function collapseDevinModelUid(uid: string): string { + const parts = uid.split("-"); + while (parts.length > 1 && EFFORT_TOKENS.has(parts[parts.length - 1]!)) { + parts.pop(); + } + return parts.join("-"); +} + +export type DevinUsableModelsResult = + | { ok: true; models: string[] } + | { ok: false; error: "auth" | "http" | "empty" | "unknown"; detail?: string }; + +/** + * Fetch the live model roster from Cognition's `GetCascadeModelConfigs` and + * collapse effort-suffixed variants to base ids. The returned list is the + * authoritative model roster for the signed-in account. + */ +export async function fetchDevinUsableModels(opts: { + apiKey: string; + baseUrl?: string; + signal?: AbortSignal; +}): Promise { + try { + const host = (opts.baseUrl || DEFAULT_HOST).replace(/\/$/, ""); + const catalog = await getCachedCatalog(opts.apiKey, host, opts.signal); + if (!catalog) return { ok: false, error: "empty" }; + const bases = new Set(); + for (const entry of catalog.byUid.values()) { + if (entry.disabled) continue; + // Skip internal enum constants (e.g. MODEL_GPT_5_2_LOW, MODEL_PRIVATE_*). + // Real chat model UIDs are lowercase dashed strings (swe-1-7, gpt-5-6-sol). + if (entry.modelUid.startsWith("MODEL_")) continue; + bases.add(collapseDevinModelUid(entry.modelUid)); + } + if (bases.size === 0) return { ok: false, error: "empty" }; + return { ok: true, models: [...bases].sort() }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/unauth|401|invalid token|login/i.test(message)) return { ok: false, error: "auth", detail: message }; + return { ok: false, error: "unknown", detail: message }; + } +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index d8edbead92..b77578662d 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -6,6 +6,7 @@ import { createCodeBuddyAdapter } from "./codebuddy/adapter"; import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; +import { createDevinAdapter } from "./devin"; import { createGoogleAdapter } from "./google"; import { createKiroAdapter } from "./kiro"; import { createMimoFreeAdapter } from "./mimo-free"; @@ -30,7 +31,8 @@ export type AdapterWire = | "openai-responses" | "google" | "kiro" - | "cursor"; + | "cursor" + | "devin"; export type AdapterMutationContract = | "codex-owned" @@ -112,6 +114,11 @@ export const ADAPTER_REGISTRY = { mutation: "codex-owned-with-gated-native-fallback", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCursorAdapter(provider), }, + devin: { + wire: "devin", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createDevinAdapter(provider), + }, "mimo-free": { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index add955bb45..cd59b7acc6 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -54,6 +54,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1646,6 +1647,50 @@ async function fetchProviderModelsWithAuth( stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured, ), "degraded"); } + if (prov.adapter === "devin") { + if (!apiKey) return observed(configured, "degraded"); + const cachedDevin = getFreshCached(name, ttlMs); + if (cachedDevin) { + return observed( + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)), + "authoritative", + ); + } + if (isModelsFetchCoolingDown(name)) { + const cooling = getStaleCached(name); + return observed( + withConfiguredRetention( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + ), + "degraded", + ); + } + const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl }); + if (liveResult.ok) { + // Live catalog is the source of truth — use the discovered base models + // directly, not a filtered subset of the static seed. + const result = liveResult.models.map((id) => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + }) as CatalogModel); + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, liveResult.models.length); + return observed(withConfiguredRetention(forCache), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" }); + } + const stale = getStaleCached(name); + return observed( + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured), + "degraded", + ); + } if (prov.adapter === "cursor") { if (!apiKey) return observed(configured, "degraded"); // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed diff --git a/src/lib/abort.ts b/src/lib/abort.ts index e42a4b89b6..11d4b7658f 100644 --- a/src/lib/abort.ts +++ b/src/lib/abort.ts @@ -144,3 +144,25 @@ export function cancelBodyOnAbort(body: ReadableStream | null, signa signal.addEventListener("abort", onAbort, { once: true }); return () => signal.removeEventListener("abort", onAbort); } + +/** + * Compose multiple AbortSignals into a single signal that aborts when ANY input + * aborts. Uses `AbortSignal.any` when available (Node >=20.3 / Bun >=1.0); + * falls back to a manual implementation for older runtimes. + */ +export function anySignal(signals: AbortSignal[]): AbortSignal { + const builtin = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; + if (typeof builtin === "function") return builtin(signals); + const controller = new AbortController(); + const onAbort = (reason: unknown): void => { + if (!controller.signal.aborted) controller.abort(reason); + }; + for (const s of signals) { + if (s.aborted) { + onAbort(s.reason); + break; + } + s.addEventListener("abort", () => onAbort(s.reason), { once: true }); + } + return controller.signal; +} diff --git a/src/oauth/devin.ts b/src/oauth/devin.ts new file mode 100644 index 0000000000..b56975ed34 --- /dev/null +++ b/src/oauth/devin.ts @@ -0,0 +1,94 @@ +/** + * Devin / Cognition OAuth. + * + * Login opens the Auth0 browser sign-in flow (windsurf.com/windsurf/signin + * with redirect_uri=show-auth-token), then exchanges the pasted Firebase ID + * token via Cognition's RegisterUser for a long-lived API key. + */ +import { randomUUID } from "node:crypto"; +import type { OAuthController, OAuthCredentials } from "./types"; +import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types"; +import { registerUser } from "./devin/register-user"; + +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; +const DEFAULT_API_SERVER = "https://server.codeium.com"; +export const DEVIN_DEFAULT_API_SERVER = DEFAULT_API_SERVER; + +function decodeJwtPayload(token: string): Record | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length < 2 || !payload) return undefined; + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record; + } catch { + return undefined; + } +} + +function identityFromApiKey(apiKey: string): { accountId?: string; email?: string } { + const jwtPart = apiKey.includes("$") ? apiKey.slice(apiKey.indexOf("$") + 1) : apiKey; + const payload = decodeJwtPayload(jwtPart); + const email = typeof payload?.email === "string" && payload.email.length > 0 ? payload.email : undefined; + const sub = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined; + const authUid = typeof payload?.auth_uid === "string" && payload.auth_uid.length > 0 ? payload.auth_uid : undefined; + return { ...(email ? { email } : {}), ...(sub || authUid ? { accountId: sub ?? authUid } : {}) }; +} + +function credentialsFromApiKey(apiKey: string, source: OAuthCredentials["source"] = "oauth"): OAuthCredentials { + const identity = identityFromApiKey(apiKey); + return { + access: apiKey, + refresh: "", + expires: Date.now() + ONE_YEAR_MS, + source, + apiBaseUrl: DEFAULT_API_SERVER, + ...identity, + }; +} + +function buildSignInUrl(region: WindsurfRegion): string { + const params = new URLSearchParams({ + response_type: "token", + client_id: region.oauthClientId, + redirect_uri: "show-auth-token", + state: randomUUID(), + prompt: "login", + }); + return region.website + "/windsurf/signin?" + params.toString(); +} + +async function loginDevinBrowser(ctrl: OAuthController, region: WindsurfRegion): Promise { + const url = buildSignInUrl(region); + ctrl.onAuth?.({ + url, + instructions: "Sign in with your Cognition/Devin account, then paste the on-screen auth token here.", + }); + ctrl.onProgress?.("Waiting for the pasted auth token..."); + const pasted = (await ctrl.onManualCodeInput?.())?.trim(); + if (!pasted) throw new Error("No auth token pasted; cannot complete Devin sign-in."); + const result = await registerUser(pasted, region); + return { + ...credentialsFromApiKey(result.apiKey, "oauth"), + ...(result.name ? { email: result.name } : {}), + apiBaseUrl: result.apiServerUrl || DEFAULT_API_SERVER, + }; +} + +export async function loginDevin(ctrl: OAuthController): Promise { + return loginDevinBrowser(ctrl, DEFAULT_REGION); +} + +export async function refreshDevinToken( + _refreshToken: string, + _signal?: AbortSignal, + credential?: OAuthCredentials, +): Promise { + if (credential?.access) { + return { + ...credential, + refresh: credential.refresh ?? "", + expires: Math.max(credential.expires, Date.now() + ONE_YEAR_MS), + }; + } + throw new Error("Devin API keys do not refresh. Run ocx login devin again."); +} diff --git a/src/oauth/devin/login.ts b/src/oauth/devin/login.ts new file mode 100644 index 0000000000..67d4672ea3 --- /dev/null +++ b/src/oauth/devin/login.ts @@ -0,0 +1 @@ +export { loginDevin } from "../devin"; diff --git a/src/oauth/devin/register-user.ts b/src/oauth/devin/register-user.ts new file mode 100644 index 0000000000..2d0c2e0fcf --- /dev/null +++ b/src/oauth/devin/register-user.ts @@ -0,0 +1,144 @@ +/** + * Exchange a Firebase ID token for a long-lived Cognition/Devin API key. + * + * This calls the same Connect-RPC endpoint the Devin desktop client uses + * after browser sign-in completes: + * + * POST https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser + * Content-Type: application/json + * Body: { "firebase_id_token": "" } + * + * Connect-RPC happily accepts plain JSON over HTTPS (no gRPC framing required), + * so we skip @connectrpc/connect entirely and use `fetch`. The response shape + * matches `exa.seat_management_pb.RegisterUserResponse`: + * + * { api_key, name, api_server_url, redirect_url, team_options[] } + */ + +import type { OAuthLoginResult, WindsurfRegion } from './types.js'; +import { anySignal } from '../../lib/abort.js'; + +interface RegisterUserResponseJson { + api_key?: string; + name?: string; + api_server_url?: string; + redirect_url?: string; + team_options?: unknown[]; +} + +interface ConnectErrorJson { + code?: string; + message?: string; +} + +export class WindsurfRegistrationError extends Error { + readonly status: number; + readonly connectCode?: string; + readonly traceId?: string; + + constructor(message: string, status: number, connectCode?: string, traceId?: string) { + super(message); + this.name = 'WindsurfRegistrationError'; + this.status = status; + this.connectCode = connectCode; + this.traceId = traceId; + } +} + +const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; + +/** + * Exchange the Firebase ID token for a Windsurf API key. + * + * `firebaseIdToken` is the `access_token` (or `firebase_id_token`) value the + * Windsurf sign-in page returns in the OAuth callback URL — we treat it as + * opaque. + */ +export async function registerUser( + firebaseIdToken: string, + region: WindsurfRegion, + abortSignal?: AbortSignal, +): Promise { + if (!firebaseIdToken) { + throw new WindsurfRegistrationError('Empty firebase_id_token', 0, 'invalid_argument'); + } + + const url = `${region.registerApiServerUrl.replace(/\/$/, '')}/exa.seat_management_pb.SeatManagementService/RegisterUser`; + + // 30s internal timeout — RegisterUser responds in ~200ms in steady state. + // CLI users on flaky networks need bounded waits or the sign-in command + // hangs forever. Compose with the caller's signal via a small polyfill + // (`anySignal`) because Node 18 / older Bun lack AbortSignal.any; the + // previous fallback `combinedSignal = abortSignal` would drop the + // timeout entirely on those runtimes. + const timeoutSignal = AbortSignal.timeout(30_000); + const combinedSignal: AbortSignal = abortSignal + ? anySignal([abortSignal, timeoutSignal]) + : timeoutSignal; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Connect protocol version header — not strictly required for JSON, but + // matches what the official Connect clients send and avoids accidental + // routing into a non-Connect HTTP handler. + 'Connect-Protocol-Version': '1', + }, + body: JSON.stringify({ firebase_id_token: firebaseIdToken }), + signal: combinedSignal, + }); + + const text = await response.text(); + + if (!response.ok) { + let connectCode: string | undefined; + let message = text || `RegisterUser failed with HTTP ${response.status}`; + try { + const errJson = JSON.parse(text) as ConnectErrorJson; + connectCode = errJson.code; + if (errJson.message) message = errJson.message; + } catch { + // non-JSON error body — keep raw text in `message` + } + const traceMatch = message.match(TRACE_ID_RE); + throw new WindsurfRegistrationError(message, response.status, connectCode, traceMatch?.[1]); + } + + let parsed: RegisterUserResponseJson; + try { + parsed = JSON.parse(text) as RegisterUserResponseJson; + } catch { + throw new WindsurfRegistrationError( + `RegisterUser returned 200 but body is not JSON: ${text.slice(0, 200)}`, + response.status, + 'internal', + ); + } + + const apiKey = parsed.api_key; + // Empty `api_server_url` is normal for single-tenant accounts — the desktop + // extension's `getApiServerUrl` helper falls back to the configured default + // when this is empty/missing. We mirror that behavior here. + const apiServerUrl = parsed.api_server_url && parsed.api_server_url.length > 0 + ? parsed.api_server_url + : 'https://server.codeium.com'; + + if (!apiKey) { + throw new WindsurfRegistrationError( + 'RegisterUser returned 200 but api_key was empty', + response.status, + 'malformed_response', + ); + } + // `name` is optional in the response — default it instead of failing login. + // src/oauth/devin.ts uses it only as a display label for the account email. + const name = parsed.name && parsed.name.length > 0 ? parsed.name : 'Devin account'; + + return { + apiKey, + name, + apiServerUrl, + redirectUrl: parsed.redirect_url, + }; +} diff --git a/src/oauth/devin/types.ts b/src/oauth/devin/types.ts new file mode 100644 index 0000000000..07b317eb0d --- /dev/null +++ b/src/oauth/devin/types.ts @@ -0,0 +1,71 @@ +/** + * Shared types for the OAuth login flow + persisted credentials. + * + * Two distinct token shapes appear in this codebase: + * + * - `firebaseIdToken` — the short-lived JWT minted by Auth0 / Firebase Auth + * during browser sign-in. Lives in the OAuth callback URL fragment/query. + * Treated as opaque and discarded once exchanged. + * + * - `apiKey` — the long-lived credential returned by + * `SeatManagementService.RegisterUser`. Used inside every Cascade RPC's + * `Metadata.api_key` field. Format is provider-defined: + * * Cognition era: `devin-session-token$` + * * Codeium classic: bare UUID v4 + * * Older Windsurf: `sk-ws-01-<...>` / `cog_<...>` + * The plugin treats it as an opaque string — only the cloud cares about format. + */ + +export interface OAuthLoginResult { + /** The opaque API key used as `Metadata.api_key` in every Cascade RPC. */ + apiKey: string; + /** Human-readable account name (`Satvik Kapoor`). */ + name: string; + /** + * Cloud API server (`https://server.codeium.com`, `https://eu.windsurf.com/_route/api_server`, + * `https://windsurf.fedstart.com/_route/api_server`). Driven by the user's + * tenant — language_server needs this as `--api_server_url`. + */ + apiServerUrl: string; + /** Optional cleanup redirect URL returned by RegisterUser. Informational. */ + redirectUrl?: string; +} + +export interface PersistedCredentials extends OAuthLoginResult { + /** ISO timestamp the credentials were minted at — purely informational. */ + issuedAt: string; + /** Optional tag tracking the OAuth client id used (so a future client rotation can invalidate). */ + oauthClientId: string; + /** + * True when these credentials were written as part of the + * `opencode auth login` → authorize() flow (so opencode's auth.json is the + * authoritative copy and `opencode auth logout windsurf` should mirror-clear + * this file). False / absent for credentials written by our standalone + * `opencode-windsurf-auth login` CLI; those survive opencode auth state + * changes. + */ + syncedViaOpencodeAuth?: boolean; +} + +export interface WindsurfRegion { + /** Where to send users for browser sign-in. */ + website: string; + /** Where to POST RegisterUser. */ + registerApiServerUrl: string; + /** Auth0 client id passed in the OAuth URL. */ + oauthClientId: string; +} + +/** + * The single tenant (free / personal) configuration. EU, FedStart, and arbitrary + * portal URLs override `website` + `registerApiServerUrl` at runtime when the + * user passes `--portal-url` to the login command. + */ +export const DEFAULT_REGION: WindsurfRegion = { + website: 'https://windsurf.com', + registerApiServerUrl: 'https://register.windsurf.com', + // From /Applications/Windsurf.app/.../extension.js — the public Windsurf + // Auth0 client. If Windsurf rotates this, sign-in will start failing until + // we re-extract it. + oauthClientId: '3GUryQ7ldAeKEuD2obYnppsnmj58eP5u', +}; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 904674716d..269877da4e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, Re import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt"; import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"; import { loginCursor, refreshCursorToken } from "./cursor"; +import { loginDevin, refreshDevinToken } from "./devin"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; @@ -308,6 +309,13 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("cursor"), defaultModel: oauthDefaultModel("cursor"), }, + devin: { + login: (ctrl) => loginDevin(ctrl), + refresh: refreshDevinToken, + providerConfig: oauthConfig("devin"), + defaultModel: oauthDefaultModel("devin"), + defaultRefreshPolicy: "disabled", + }, "github-copilot": { login: (ctrl) => loginGithubCopilot(ctrl), refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 6d84efe529..bdf1101f39 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,6 +1,7 @@ import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; import type { ProviderBaseUrlChoice } from "./base-url-choices"; import { @@ -1258,6 +1259,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // still advertises image for noVision members so Codex can attach (sidecar option B). noVisionModels: [...CURSOR_NO_VISION_MODELS], }, + { + id: "devin", + label: "Cognition (Devin/Windsurf)", + adapter: "devin", + baseUrl: "https://server.codeium.com", + authKind: "oauth", + featured: false, + dashboardPreset: false, + note: "Experimental unofficial Cognition/Devin bridge. ocx login devin opens Auth0 browser sign-in, then exchanges the token via Cognition's RegisterUser for a long-lived API key.", + models: ["swe-1-7", "swe-1-7-lightning", "gpt-5-6-sol", "gpt-5-6-luna", "gpt-5-6-terra", "claude-opus-4-8", "claude-fable-5-1", "claude-sonnet-5", "glm-5-2", "kimi-k2-7", "grok-4-5"], + liveModels: true, + defaultModel: "swe-1-7", + modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, + }, { id: "xai", label: "xAI Grok", diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 2677864454..36e136f275 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -14,6 +14,7 @@ export function upstreamProtocolForAdapter(adapter: string): string { case "openai-chat": case "command-code": case "cursor": + case "devin": case "azure": case "azure-openai": case "kiro": diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 6fc90aab74..7b389d342e 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -48,6 +48,7 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/ import { capEstimateAtContextWindow } from "../lib/token-estimate"; import { inferCursorContextWindow } from "../adapters/cursor/discovery"; import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models"; import { modelRecordValue } from "../reasoning-effort"; export interface RequestLogContext { @@ -1174,6 +1175,9 @@ function contextWindowForModel(adapter: string, modelId: string | undefined): nu if (adapter === "cursor" || adapter.startsWith("cursor-")) { return inferCursorContextWindow(modelId); } + if (adapter === "devin") { + return modelRecordValue(DEVIN_MODEL_CONTEXT_WINDOWS, modelId); + } return undefined; } diff --git a/tests/adapters/adapter-registry-authority.test.ts b/tests/adapters/adapter-registry-authority.test.ts index d7bac03afe..b0b43c8b19 100644 --- a/tests/adapters/adapter-registry-authority.test.ts +++ b/tests/adapters/adapter-registry-authority.test.ts @@ -21,6 +21,7 @@ const EXPECTED_ADAPTER_NAMES = { azure: "azure-openai", "azure-openai": "azure-openai", cursor: "cursor", + devin: "devin", "mimo-free": "mimo-free", qoder: "qoder", } as const; diff --git a/tests/adapters/adapter-tool-conformance.test.ts b/tests/adapters/adapter-tool-conformance.test.ts index 91fd5a399b..dd8e04bece 100644 --- a/tests/adapters/adapter-tool-conformance.test.ts +++ b/tests/adapters/adapter-tool-conformance.test.ts @@ -425,6 +425,9 @@ describe("registry-derived routed tool conformance", () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); + // Devin is a runTurn-only adapter; its buildRequest returns a placeholder + // and it never carries the apply_patch exec helper over the buildRequest path. + if (contract.wire === "devin") continue; const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); expect(advertised.some(name => name === "exec" || name.endsWith("_exec")), adapterId).toBe(true); @@ -439,6 +442,8 @@ describe("registry-derived routed tool conformance", () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); + // Devin is a runTurn-only adapter; tool_choice is not expressed on buildRequest. + if (contract.wire === "devin") continue; const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); const disabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire, "none")); @@ -451,10 +456,11 @@ describe("registry-derived routed tool conformance", () => { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; - if (!driver.streamingToolCall) { + if (!driver?.streamingToolCall) { // OpenAI Responses is a normal passthrough here and only parses routed compaction; // Cursor's proprietary runTurn stream has focused parser coverage elsewhere. - expect(["openai-responses", "cursor"]).toContain(contract.wire); + // Devin is a runTurn-only adapter with no buildRequest/parseStream wire. + expect(["openai-responses", "cursor", "devin"]).toContain(contract.wire); continue; } expect(await restoredStreamInput(adapterId, contract.wire), adapterId).toBe(PATCH); @@ -465,9 +471,10 @@ describe("registry-derived routed tool conformance", () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") { - // Native Responses passthrough and Cursor's protobuf transport do not use the routed - // adapter tool declaration surface exercised by this registry-wide check. + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin") { + // Native Responses passthrough, Cursor's protobuf transport, and Devin's + // runTurn-only cloud-direct transport do not use the routed adapter tool + // declaration surface exercised by this registry-wide check. continue; } const body = await outbound(adapterId, namespacedCollisionParsed(contract.wire)); @@ -480,7 +487,7 @@ describe("registry-derived routed tool conformance", () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); - if (contract.wire === "openai-responses" || contract.wire === "cursor") continue; + if (contract.wire === "openai-responses" || contract.wire === "cursor" || contract.wire === "devin") continue; const parsed = namespacedCollisionParsed(contract.wire); // parseRequest rejects this shape for real inbound traffic; keeping the policy mutation here // also proves each adapter remains fail-closed when a caller reaches it with a prebuilt AST. @@ -507,8 +514,8 @@ describe("registry-derived routed tool conformance", () => { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; - if (!driver.streamingToolCall || !driver.extractWireToolName) { - expect(["openai-responses", "cursor"]).toContain(contract.wire); + if (!driver?.streamingToolCall || !driver?.extractWireToolName) { + expect(["openai-responses", "cursor", "devin"]).toContain(contract.wire); continue; } @@ -549,6 +556,8 @@ describe("registry-derived routed tool conformance", () => { for (const [adapterId] of adapterDefinitions()) { if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); + // Devin is a runTurn-only adapter; continuation replay is not expressed on buildRequest. + if (contract.wire === "devin") continue; const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); } diff --git a/tests/devin-adapter.test.ts b/tests/devin-adapter.test.ts new file mode 100644 index 0000000000..5610aca22f --- /dev/null +++ b/tests/devin-adapter.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { createDevinAdapter, mapOcxMessagesToDevin, mapOcxToolsToDevin } from "../src/adapters/devin"; +import { sanitizeToolDescriptionForCognitionForTests } from "../src/adapters/devin/cloud-direct/chat"; +import { DEVIN_STATIC_MODELS, collapseDevinModelUid } from "../src/adapters/devin/live-models"; +import { OAUTH_PROVIDERS } from "../src/oauth"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import type { OcxParsedRequest } from "../src/types"; + +describe("devin adapter", () => { + test("is registered as an oauth provider and adapter", () => { + expect(OAUTH_PROVIDERS.devin.defaultModel).toBe("swe-1-7"); + const entry = PROVIDER_REGISTRY.find((row) => row.id === "devin"); + expect(entry?.adapter).toBe("devin"); + expect(entry?.authKind).toBe("oauth"); + expect(entry?.liveModels).toBe(true); + expect(createDevinAdapter({ adapter: "devin", baseUrl: "https://server.codeium.com" }).name).toBe("devin"); + }); + + test("maps user/assistant/tool history and tools", () => { + const parsed: OcxParsedRequest = { + modelId: "swe-1-7", + stream: true, + context: { + systemPrompt: ["be brief"], + messages: [ + { role: "user", content: "hi", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "calling" }, + { type: "toolCall", id: "c1", name: "lookup", arguments: { q: "x" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "c1", toolName: "lookup", content: "ok", isError: false, timestamp: 3 }, + ], + tools: [{ name: "lookup", description: "lookup", parameters: { type: "object" } }], + }, + options: {}, + }; + const history = mapOcxMessagesToDevin(parsed); + expect(history[0]).toEqual({ role: "system", content: "be brief" }); + expect(history[1]).toEqual({ role: "user", content: "hi" }); + expect(history[2]?.role).toBe("assistant"); + expect(history[2]?.tool_calls?.[0]?.id).toBe("c1"); + expect(history[3]).toEqual({ role: "tool", content: "ok", tool_call_id: "c1" }); + expect(mapOcxToolsToDevin(parsed.context.tools)?.[0]?.name).toBe("lookup"); + }); + + test("collapseDevinModelUid strips effort suffixes to base ids", () => { + expect(collapseDevinModelUid("swe-1-7")).toBe("swe-1-7"); + expect(collapseDevinModelUid("swe-1-7-medium")).toBe("swe-1-7"); + expect(collapseDevinModelUid("swe-1-7-lightning")).toBe("swe-1-7-lightning"); + expect(collapseDevinModelUid("swe-1-7-lightning-medium")).toBe("swe-1-7-lightning"); + expect(collapseDevinModelUid("gpt-5-6-sol-high")).toBe("gpt-5-6-sol"); + expect(collapseDevinModelUid("gpt-5-6-sol-high-priority")).toBe("gpt-5-6-sol"); + expect(collapseDevinModelUid("glm-5-2-max-1m")).toBe("glm-5-2"); + expect(collapseDevinModelUid("claude-opus-4-8-high-fast")).toBe("claude-opus-4-8"); + expect(collapseDevinModelUid("claude-fable-5-1-high")).toBe("claude-fable-5-1"); + expect(collapseDevinModelUid("grok-4-5-medium")).toBe("grok-4-5"); + }); + + test("loginDevin is browser-only (no local import option)", () => { + // The devin OAuth entry must not accept importLocal/forceLogin opts — + // login is always the Auth0 browser flow. + const entry = OAUTH_PROVIDERS.devin; + expect(entry.login.length).toBeLessThanOrEqual(1); + }); + + test("rewrites the Cognition blocklist trigger phrase in tool descriptions", () => { + // The exact 7-word phrase (capital T, single spaces) triggers Cognition's + // permission_denied content filter. The rewrite must break the exact match + // while preserving meaning. + const trigger = "Takes a task_id parameter identifying the task"; + expect(sanitizeToolDescriptionForCognitionForTests(trigger)).toBe("Accepts a task_id parameter identifying the task"); + // Case-sensitive: lowercase first letter is NOT rewritten (it doesn't trigger) + expect(sanitizeToolDescriptionForCognitionForTests("takes a task_id parameter identifying the task")) + .toBe("takes a task_id parameter identifying the task"); + // Substring match: the phrase embedded in a larger description is rewritten + const full = "- Retrieves output from a running or completed task\n- Takes a task_id parameter identifying the task\n- Returns the task output"; + const rewritten = sanitizeToolDescriptionForCognitionForTests(full); + expect(rewritten).not.toContain("Takes a task_id parameter identifying the task"); + expect(rewritten).toContain("Accepts a task_id parameter identifying the task"); + // Surrounding text is preserved + expect(rewritten).toContain("- Retrieves output from a running or completed task"); + expect(rewritten).toContain("- Returns the task output"); + // Descriptions without the trigger pass through unchanged + expect(sanitizeToolDescriptionForCognitionForTests("A benign description.")).toBe("A benign description."); + }); +}); +