From 2bea8a2c0fcb1acff85378ac28d6585b190e435f Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:37:45 +0800 Subject: [PATCH 1/5] feat(provider): add official CodeBuddy Global and CN providers --- .../src/content/docs/guides/providers.md | 32 ++ .../docs/reference/configuration/providers.md | 2 +- src/adapters/codebuddy/adapter.ts | 84 ++++ src/adapters/codebuddy/profiles.ts | 52 +++ src/adapters/coding-agent/profile.ts | 100 ++++ src/adapters/coding-agent/protocol.ts | 430 ++++++++++++++++++ src/adapters/coding-agent/turn.ts | 285 ++++++++++++ src/adapters/registry.ts | 7 + src/providers/codebuddy-models.ts | 156 +++++++ src/providers/registry.ts | 72 +++ .../adapter-buffered-tool-conformance.test.ts | 2 + tests/adapter-registry-authority.test.ts | 13 +- tests/adapter-tool-conformance.test.ts | 11 + tests/codebuddy-adapter.test.ts | 286 ++++++++++++ tests/codebuddy-protocol.test.ts | 338 ++++++++++++++ .../adapter-conformance/wire-drivers.ts | 9 + tests/provider-registry-parity.test.ts | 26 ++ 17 files changed, 1899 insertions(+), 6 deletions(-) create mode 100644 src/adapters/codebuddy/adapter.ts create mode 100644 src/adapters/codebuddy/profiles.ts create mode 100644 src/adapters/coding-agent/profile.ts create mode 100644 src/adapters/coding-agent/protocol.ts create mode 100644 src/adapters/coding-agent/turn.ts create mode 100644 src/providers/codebuddy-models.ts create mode 100644 tests/codebuddy-adapter.test.ts create mode 100644 tests/codebuddy-protocol.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 73f7f20620..f7d49a35bc 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -534,6 +534,38 @@ Create a key in [Novita's key manager](https://novita.ai/settings/key-management > hosts and schemas and are not routed by this preset. > Live discovery for this preset is capped at a 1 MiB response and 256 raw model rows. +### Official CodeBuddy Code CLI (Global & CN) + +OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code CLI via the `codebuddy` (Global) and `codebuddy-cn` (China) presets. + +```json +{ + "providers": { + "codebuddy": { + "adapter": "codebuddy", + "baseUrl": "https://www.codebuddy.ai", + "apiKey": "${CODEBUDDY_API_KEY}" + }, + "codebuddy-cn": { + "adapter": "codebuddy", + "baseUrl": "https://www.codebuddy.cn", + "apiKey": "${CODEBUDDY_CN_API_KEY}" + } + } +} +``` + +- **Prerequisites:** Install the official CodeBuddy CLI globally: + ```bash + npm install -g @tencent-ai/codebuddy-code + ``` +- **Authentication:** Obtain your official API key from the vendor console: + - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) + - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) +- **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. +- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. +- **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. + ### A6API credit quota A custom `openai-chat` provider using `authMode: "key"` and the canonical diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index cfd5135ae9..4dec978074 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -65,7 +65,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | Field | Type | Meaning | | --- | --- | --- | -| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`). | +| `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (or alias `azure`), `codebuddy`. | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. An explicit pin requires an HTTPS target and fails locally when it cannot be honored. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts new file mode 100644 index 0000000000..1ab3a37421 --- /dev/null +++ b/src/adapters/codebuddy/adapter.ts @@ -0,0 +1,84 @@ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { mapReasoningEffort } from "../../reasoning-effort"; +import { buildSystemPrompt } from "../coding-agent/protocol"; +import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; +import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; + +export type { SpawnFn } from "../coding-agent/turn"; +export type CodeBuddyAdapterDeps = CodingAgentDeps; + +/** + * Build the scoped child-process environment for a CodeBuddy turn (§六/§十四). + * + * The region switch and credential are layered on top of the shared base env, which never inherits a + * parent `CODEBUDDY_*`. `CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS=1` matches the vendor SDK's own + * single-shot behavior (a `-p` turn stops at the first result and cannot receive cross-turn + * background push-back). + */ +export function buildChildEnv(profile: CodeBuddyProfile, apiKey: string): Record { + return { + ...baseScopedEnv(), + CODEBUDDY_API_KEY: apiKey, + CODEBUDDY_INTERNET_ENVIRONMENT: profile.internetEnvironment, + CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS: "1", + }; +} + +/** + * Build the headless CLI arguments (§七/§十一). + * + * Tool ownership stays with Codex: `--tools ""` disables every built-in tool and `--strict-mcp-config` + * (with no `--mcp-config`) blocks MCP tools, so the CLI can neither read, write, exec, nor browse the + * workspace. `-y/--dangerously-skip-permissions` is deliberately NOT passed, so any operation that + * would require authorization is blocked. The turn is a single text/reasoning pass over stream-json; + * Codex's tool catalog is not advertised in v1 (the control-protocol tool bridge is a fast-follow). + */ +export function buildArgs(profile: CodeBuddyProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { + const args: string[] = [ + "-p", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--include-partial-messages", + "--verbose", + "--no-session-persistence", + "--tools", "", + "--strict-mcp-config", + "--max-turns", "1", + "--model", parsed.modelId, + ]; + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort) args.push("--effort", effort); + const system = buildSystemPrompt(parsed); + if (system) args.push("--append-system-prompt", system); + // profile is retained for symmetry with the region-isolated design and future per-region flags. + void profile; + return args; +} + +export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBuddyAdapterDeps = {}): ProviderAdapter { + return { + name: "codebuddy", + + // runTurn owns the turn; buildRequest/parseStream are the disabled HTTP path (mirrors cursor). + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "CodeBuddy adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + + async runTurn(parsed, incoming, emit): Promise { + await runCodingAgentTurn({ + profiles: CODEBUDDY_PROFILES, + provider, + parsed, + incoming, + emit, + buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), + buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), + deps, + }); + }, + }; +} diff --git a/src/adapters/codebuddy/profiles.ts b/src/adapters/codebuddy/profiles.ts new file mode 100644 index 0000000000..f06edb8ed5 --- /dev/null +++ b/src/adapters/codebuddy/profiles.ts @@ -0,0 +1,52 @@ +import { clearCodingAgentBinaryCache, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** + * Region-isolated profiles for the official CodeBuddy Code CLI. + * + * CodeBuddy Global and CodeBuddy CN are SEPARATE credential destinations (§五/§十四/§十六). They + * share one adapter, one binary name, and the shared coding-agent stream-json parser; the region is + * fixed by the officially documented `CODEBUDDY_INTERNET_ENVIRONMENT` value (`public` for the + * overseas/global product, `internal` for the China product) — the vendor states: "使用 + * CODEBUDDY_API_KEY 时,必须根据版本正确配置 CODEBUDDY_INTERNET_ENVIRONMENT". A global key is never + * sent to the CN environment or vice versa. + * + * Evidence (verified 2026-09-03): npm `@tencent-ai/codebuddy-code` v2.143.0 (Tencent Cloud); + * keys https://www.codebuddy.ai/profile/keys (Global) / https://copilot.tencent.com/profile/keys (CN); + * headless https://www.codebuddy.ai/docs/cli/headless. + */ +export interface CodeBuddyProfile extends CodingAgentProviderProfile { + family: "codebuddy"; + /** Official `CODEBUDDY_INTERNET_ENVIRONMENT` value for this region. */ + internetEnvironment: "public" | "internal"; +} + +export const CODEBUDDY_GLOBAL_PROFILE: CodeBuddyProfile = { + providerId: "codebuddy", + family: "codebuddy", + region: "global", + label: "CodeBuddy", + internetEnvironment: "public", + canonicalBaseUrl: "https://www.codebuddy.ai", + binaryCandidates: ["codebuddy", "cbc", "codebuddy-code"], + tokenEnv: "CODEBUDDY_API_KEY", + installHint: "npm install -g @tencent-ai/codebuddy-code", + documentationUrl: "https://www.codebuddy.ai/docs/cli/headless", +}; + +export const CODEBUDDY_CN_PROFILE: CodeBuddyProfile = { + providerId: "codebuddy-cn", + family: "codebuddy", + region: "cn", + label: "CodeBuddy CN", + internetEnvironment: "internal", + canonicalBaseUrl: "https://www.codebuddy.cn", + binaryCandidates: ["codebuddy", "cbc", "codebuddy-code"], + tokenEnv: "CODEBUDDY_API_KEY", + installHint: "npm install -g @tencent-ai/codebuddy-code", + documentationUrl: "https://www.codebuddy.cn/docs/cli/headless", +}; + +export const CODEBUDDY_PROFILES: readonly CodeBuddyProfile[] = [CODEBUDDY_GLOBAL_PROFILE, CODEBUDDY_CN_PROFILE]; + +/** Binary-discovery cache is shared across coding-agent families; re-exported for test isolation. */ +export const clearCodeBuddyBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts new file mode 100644 index 0000000000..1db14bc50e --- /dev/null +++ b/src/adapters/coding-agent/profile.ts @@ -0,0 +1,100 @@ +import { existsSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +/** + * One region-isolated official coding-agent CLI target (§三十一). + * + * A profile is the ONLY place a family encodes its per-region differences (binary, credential env + * var, canonical destination, install hint). Adapters stay profile-driven so there is no scattered + * `if (provider === "codebuddy-cn")` branching, and so a family's Global and CN variants share one + * adapter and one parser (§十三). + */ +export interface CodingAgentProviderProfile { + /** Canonical OpenCodex provider id this profile serves. */ + providerId: string; + /** Vendor family; selects the arg/env builder in the family adapter. */ + family: "codebuddy"; + /** Region; drives the vendor's own region switch and keeps credentials deterministic. */ + region: "global" | "cn"; + /** Human label for diagnostics/error copy (never sent upstream). */ + label: string; + /** + * Canonical upstream destination and region identity. The CLI performs the real transport, but + * this host selects the profile and fails closed when overridden, so a region-scoped credential is + * never handed to an unexpected environment (§十六). + */ + canonicalBaseUrl: string; + /** Executable names to resolve on PATH, in preference order. */ + binaryCandidates: readonly string[]; + /** Official credential environment variable consumed by the CLI. */ + tokenEnv: string; + /** Install command surfaced when the CLI is missing (§二十六). */ + installHint: string; + /** Official documentation for the automation surface. */ + documentationUrl: string; +} + +/** Test seam: report the resolved path of a candidate executable, or undefined. */ +export type WhichFn = (candidate: string) => string | undefined; + +const binaryCache = new Map(); + +/** Reset the discovery cache (tests, or an explicit provider re-check). */ +export function clearCodingAgentBinaryCache(): void { + binaryCache.clear(); +} + +/** Default PATH scan: return the first existing executable path for a candidate name. */ +export function whichFromPath(candidate: string): string | undefined { + const pathVar = process.env.PATH ?? ""; + if (!pathVar) return undefined; + const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; + for (const dir of pathVar.split(delimiter)) { + if (!dir) continue; + for (const ext of extensions) { + const full = join(dir, `${candidate}${ext}`); + try { + if (existsSync(full)) return full; + } catch { + // An unreadable PATH entry must not abort discovery; skip it. + } + } + } + return undefined; +} + +/** + * Discover the CLI executable BEFORE a request is sent (§二十六), so a missing CLI is a clear + * pre-flight error rather than a mid-turn ENOENT. Only positive hits are cached (§三十): a CLI + * installed after startup is found on the next turn instead of being masked by a cached negative. + */ +export function resolveCodingAgentBinary( + profile: CodingAgentProviderProfile, + which: WhichFn = whichFromPath, +): string | undefined { + for (const candidate of profile.binaryCandidates) { + const cacheKey = `${profile.providerId}:${candidate}`; + const cached = binaryCache.get(cacheKey); + if (cached) return cached; + const resolved = which(candidate); + if (resolved) { + binaryCache.set(cacheKey, resolved); + return resolved; + } + } + return undefined; +} + +/** + * Resolve the profile whose canonical base URL matches the provider's configured destination. + * Returns undefined for any other host, so the adapter fails closed rather than sending a + * region-scoped credential to an unknown environment (§十六). + */ +export function resolveProfileByBaseUrl( + profiles: readonly CodingAgentProviderProfile[], + baseUrl: string | undefined, +): CodingAgentProviderProfile | undefined { + if (!baseUrl) return undefined; + const normalized = baseUrl.replace(/\/+$/, "").toLowerCase(); + return profiles.find(profile => normalized === profile.canonicalBaseUrl.toLowerCase()); +} diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts new file mode 100644 index 0000000000..a3fa06cb07 --- /dev/null +++ b/src/adapters/coding-agent/protocol.ts @@ -0,0 +1,430 @@ +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types"; + +/** + * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code). + * + * The vendor speaks the Anthropic/Claude-Code `stream-json` protocol ("the naming and protocol + * align with Anthropic Claude Code v2.1.88"). A headless turn is a newline-delimited JSON stream on stdout: + * + * {"type":"system","subtype":"init", ...} + * {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta",...}}} (with --include-partial-messages) + * {"type":"assistant","message":{"role":"assistant","content":[{"type":"text"|"thinking"|"tool_use",...}]}} + * {"type":"result","subtype":"success","is_error":false,"usage":{...},"total_cost_usd":...,"session_id":...} + * + * Diagnostics ride stderr and are NOT protocol data. This module is pure: it never spawns a process + * and never touches the network, so it is unit-testable against captured fixtures. + */ + +/** Hard ceiling on a single buffered stdout line, so a runaway frame cannot exhaust memory. */ +export const MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024; +/** Hard ceiling on the total stdout bytes consumed for one turn. */ +export const MAX_STREAM_TOTAL_BYTES = 64 * 1024 * 1024; +/** Hard ceiling on projected conversation history text (characters) to prevent runaway memory. */ +export const MAX_PROJECTED_HISTORY_CHARS = 200_000; + +export class CodingAgentStreamLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "CodingAgentStreamLimitError"; + } +} + +export class CodingAgentProtocolError extends Error { + readonly code: string = "protocol_error"; + readonly status: number = 502; + constructor(message: string) { + super(message); + this.name = "CodingAgentProtocolError"; + } +} + +/** A parsed protocol frame. */ +export type StreamMessage = Record; + +/** + * Split an async byte stream into JSONL frames. + * + * Handles the streaming hazards the task calls out (§二十五): fragmented JSON across chunks, split + * multi-byte UTF-8 (via the decoder's `stream` mode), partial trailing lines, and multiple frames in + * one chunk. A frame that does not parse to a JSON record is dropped, never thrown: an unparseable + * line is padding, and terminating on it would discard deltas that already arrived (the same + * reasoning the command-code NDJSON reader documents for #1219/#1240). + */ +export async function* readJsonLines( + chunks: AsyncIterable, + limits: { maxLineBytes?: number; maxTotalBytes?: number } = {}, +): AsyncGenerator { + const maxLineBytes = limits.maxLineBytes ?? MAX_STREAM_LINE_BYTES; + const maxTotalBytes = limits.maxTotalBytes ?? MAX_STREAM_TOTAL_BYTES; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let totalBytes = 0; + + const flushLine = function* (line: string): Generator { + const trimmed = line.trim(); + if (!trimmed) return; // Blank lines and whitespace-only lines are ignored as padding. + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); + throw new CodingAgentProtocolError( + `Malformed stream-json frame received from CodeBuddy CLI: ${snippet}`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); + throw new CodingAgentProtocolError( + `Non-object stream-json frame received from CodeBuddy CLI: ${snippet}`, + ); + } + yield parsed as StreamMessage; + }; + + for await (const chunk of chunks) { + totalBytes += chunk.byteLength; + if (totalBytes > maxTotalBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling"); + } + buffer += decoder.decode(chunk, { stream: true }); + if (encoder.encode(buffer).byteLength > maxLineBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); + } + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + yield* flushLine(line); + newline = buffer.indexOf("\n"); + } + } + // Flush the decoder's trailing bytes and any final line without a newline terminator. + buffer += decoder.decode(); + if (buffer.trim()) yield* flushLine(buffer); +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Extract OpenCodex usage from a `result` frame's Anthropic-shaped usage object. */ +export function usageFromResult(message: StreamMessage): OcxUsage | undefined { + const usage = asRecord(message.usage); + if (!usage) return undefined; + const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0; + const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0; + const cachedInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined; + const cacheCreationInputTokens = + typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined; + if (inputTokens === 0 && outputTokens === 0 && cachedInputTokens === undefined) return undefined; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(cachedInputTokens !== undefined ? { cachedInputTokens, cacheReadInputTokens: cachedInputTokens } : {}), + ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + }; +} + +/** + * Mutable per-turn parse state shared across frames of one stream (§十二). + * Thinking and text states are strictly decoupled. + */ +export interface StreamParseState { + sawPartialText: boolean; + sawPartialThinking: boolean; + sawTerminalResult: boolean; + openToolCallId?: string; +} + +/** + * Map ONE protocol frame to zero or more AdapterEvents. + * + * Token-level streaming comes from `stream_event` frames (enabled by `--include-partial-messages`); + * the complete `assistant` frame is only used as a fallback when no partial deltas were seen, so text + * and thinking are never emitted twice. + */ +export function mapStreamMessageToEvents(message: StreamMessage, state: StreamParseState): AdapterEvent[] { + const type = asString(message.type); + const events: AdapterEvent[] = []; + + if (type === "stream_event") { + const event = asRecord(message.event); + if (event) events.push(...mapRawStreamEvent(event, state)); + return events; + } + + if (type === "assistant") { + // Fallback path: a complete assistant message. Surface text and thinking independently + // only when the partial delta stream did not already carry them (§十二). + const content = asRecord(message.message)?.content; + if (Array.isArray(content)) { + for (const block of content) { + const part = asRecord(block); + if (!part) continue; + const blockType = asString(part.type); + if (blockType === "text" && !state.sawPartialText) { + const text = asString(part.text); + if (text) events.push({ type: "text_delta", text }); + } else if (blockType === "thinking" && !state.sawPartialThinking) { + const thinking = asString(part.thinking); + if (thinking) events.push({ type: "thinking_delta", thinking }); + } + } + } + return events; + } + + if (type === "result") { + const isError = message.is_error === true || asString(message.subtype) === "error_during_execution"; + const usage = usageFromResult(message); + if (isError) { + events.push({ + type: "error", + message: asString(message.result) || "CodeBuddy CLI ended the turn with an execution error", + status: 502, + errorType: "upstream_error", + code: "upstream_error", + ...(usage ? { usage } : {}), + }); + return events; + } + state.sawTerminalResult = true; + events.push({ type: "done", ...(usage ? { usage } : {}), stopReason: "stop" }); + return events; + } + + // system/init, user echoes, task_* background events: not client-visible output. + return events; +} + +/** Map a raw Anthropic SSE event (carried inside a `stream_event` frame) to AdapterEvents. */ +function mapRawStreamEvent(event: StreamMessage, state: StreamParseState): AdapterEvent[] { + const events: AdapterEvent[] = []; + const eventType = asString(event.type); + + if (eventType === "content_block_delta") { + const delta = asRecord(event.delta); + const deltaType = asString(delta?.type); + if (deltaType === "text_delta") { + const text = asString(delta?.text); + if (text) { + state.sawPartialText = true; + events.push({ type: "text_delta", text }); + } + } else if (deltaType === "thinking_delta") { + const thinking = asString(delta?.thinking); + if (thinking) { + state.sawPartialThinking = true; + events.push({ type: "thinking_delta", thinking }); + } + } else if (deltaType === "input_json_delta") { + // Tool-input streaming. Inert while tools are disabled (Codex's catalog is not advertised), + // but parsed so the seam is ready and an unexpected frame never crashes. + const partial = asString(delta?.partial_json); + if (partial && state.openToolCallId) events.push({ type: "tool_call_delta", arguments: partial }); + } + return events; + } + + if (eventType === "content_block_start") { + const block = asRecord(event.content_block); + if (asString(block?.type) === "tool_use") { + const id = asString(block?.id) ?? ""; + const name = asString(block?.name) ?? "tool"; + if (id) { + state.openToolCallId = id; + events.push({ type: "tool_call_start", id, name }); + } + } + return events; + } + + if (eventType === "content_block_stop") { + if (state.openToolCallId) { + state.openToolCallId = undefined; + events.push({ type: "tool_call_end" }); + } + return events; + } + + return events; +} + +/** One content part on the stream-json input wire (Anthropic message shape). */ +type WireContentPart = Record; + +function textPart(text: string): WireContentPart { + return { type: "text", text }; +} + +/** Encode an OpenCodex image content part as an Anthropic base64/url image block; never drop it. */ +function imagePart(imageUrl: string): WireContentPart | undefined { + const match = /^data:([^;]+);base64,(.+)$/s.exec(imageUrl); + if (match) return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }; + if (/^https?:\/\//i.test(imageUrl)) return { type: "image", source: { type: "url", url: imageUrl } }; + return undefined; +} + +function formatMessageForHistory(message: OcxMessage): string { + if (message.role === "user") { + const text = typeof message.content === "string" + ? message.content + : message.content.map(p => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n"); + return `USER:\n${text}`; + } + if (message.role === "assistant") { + const parts: string[] = []; + for (const part of message.content) { + if (part.type === "text" && part.text.trim()) { + parts.push(part.text.trim()); + } else if (part.type === "thinking" && part.thinking.trim()) { + parts.push(`[Thinking: ${part.thinking.trim()}]`); + } else if (part.type === "toolCall") { + const args = JSON.stringify(part.arguments ?? {}); + parts.push(`[Tool call: ${part.name} (call_id: ${part.id}) with args: ${args}]`); + } + } + return `ASSISTANT:\n${parts.join("\n") || "(empty response)"}`; + } + if (message.role === "toolResult") { + const text = typeof message.content === "string" + ? message.content + : message.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + const status = message.isError ? " (error)" : ""; + return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; + } + return ""; +} + +/** + * Format an isolated OpenCodex message into stream-json user message input lines. + * + * In stream-json mode, the official CLI stdin parser (`StreamJsonUtils.parseUserMessage`) only + * accepts `type: "user"` frames. Writing undocumented `type: "assistant"` frames is rejected. + * Non-user messages are therefore projected into valid user frames. + */ +export function buildInputLines(message: OcxMessage): string[] { + if (message.role === "developer") return []; + + const content: WireContentPart[] = []; + if (message.role === "user") { + if (typeof message.content === "string") { + content.push(textPart(message.content)); + } else { + for (const part of message.content) { + if (part.type === "text") content.push(textPart(part.text)); + else if (part.type === "image") { + const image = imagePart(part.imageUrl); + if (image) content.push(image); + } else { + content.push(textPart("[video]")); + } + } + } + } else { + const formatted = formatMessageForHistory(message); + if (formatted) content.push(textPart(formatted)); + } + + return content.length > 0 ? [JSON.stringify({ type: "user", message: { role: "user", content } })] : []; +} + +/** Fold the request's system + developer prompts into one system-prompt string. */ +export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined { + const parts: string[] = []; + for (const line of parsed.context.systemPrompt ?? []) { + if (line && line.trim()) parts.push(line); + } + for (const message of parsed.context.messages) { + if (message.role !== "developer") continue; + const text = typeof message.content === "string" + ? message.content + : message.content.map(part => (part.type === "text" ? part.text : "")).join(""); + if (text.trim()) parts.push(text); + } + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +/** + * Build the ordered stream-json input lines for a turn (Strategy C: Legal user-message projection). + * + * In stream-json mode, the vendor CLI stdin parser strictly accepts `type: "user"` frames + * (`{"type":"user","message":{"role":"user","content":...}}`). + * Undocumented `{"type":"assistant",...}` frames are dropped by the vendor parser. + * + * Multi-turn history (user, assistant, tool results) is projected into a legal user message: + * prior conversation turns are structured as bounded context text with tool results as text, + * clearly demarcated from the current user request. Codex retains tool control; vendor tools are never invoked. + */ +export function buildConversationInput(parsed: OcxParsedRequest): string[] { + const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); + if (nonDev.length === 0) { + return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })]; + } + + if (nonDev.length === 1 && nonDev[0]!.role === "user") { + return buildInputLines(nonDev[0]!); + } + + // Multi-turn conversation or history with tool results: + const historyMessages = nonDev.slice(0, -1); + const currentMessage = nonDev[nonDev.length - 1]!; + + const imageBlocks: WireContentPart[] = []; + let currentRequestText = ""; + + if (currentMessage.role === "user") { + if (typeof currentMessage.content === "string") { + currentRequestText = currentMessage.content; + } else { + const textParts: string[] = []; + for (const part of currentMessage.content) { + if (part.type === "text") textParts.push(part.text); + else if (part.type === "image") { + const image = imagePart(part.imageUrl); + if (image) imageBlocks.push(image); + } else { + textParts.push("[video]"); + } + } + currentRequestText = textParts.join("\n"); + } + } else if (currentMessage.role === "toolResult") { + const text = typeof currentMessage.content === "string" + ? currentMessage.content + : currentMessage.content.map(p => (p.type === "text" ? p.text : "[image]")).join(""); + const status = currentMessage.isError ? " (error)" : ""; + currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`; + } else { + currentRequestText = formatMessageForHistory(currentMessage); + } + + // Also collect any images from history messages so multimodal attachments are never dropped: + for (const msg of historyMessages) { + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "image") { + const img = imagePart(part.imageUrl); + if (img) imageBlocks.push(img); + } + } + } + } + + let historyText = historyMessages.map(formatMessageForHistory).filter(Boolean).join("\n\n"); + if (historyText.length > MAX_PROJECTED_HISTORY_CHARS) { + historyText = `[Earlier conversation history truncated for length...]\n\n` + + historyText.slice(historyText.length - MAX_PROJECTED_HISTORY_CHARS); + } + + const combinedText = `Prior conversation context:\n\n${historyText}\n\nCurrent user request:\n\n${currentRequestText}`; + + const content: WireContentPart[] = [{ type: "text", text: combinedText }, ...imageBlocks]; + return [JSON.stringify({ type: "user", message: { role: "user", content } })]; +} diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts new file mode 100644 index 0000000000..1a8201da83 --- /dev/null +++ b/src/adapters/coding-agent/turn.ts @@ -0,0 +1,285 @@ +import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { IncomingMeta } from "../base"; +import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, readJsonLines, type StreamParseState } from "./protocol"; +import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; + +/** Injectable spawn for tests; production uses node:child_process. */ +export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess; + +export interface CodingAgentDeps { + spawn?: SpawnFn; + which?: WhichFn; + /** Overall wall-clock ceiling for one turn (ms). */ + timeoutMs?: number; + /** Grace period between SIGTERM and SIGKILL (ms). */ + killGraceMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 300_000; +const DEFAULT_KILL_GRACE_MS = 2_000; +/** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */ +const MAX_STDERR_BYTES = 8 * 1024; + +/** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */ +const INHERITED_ENV_KEYS = [ + "PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP", + "SHELL", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", + "COMSPEC", "PATHEXT", "SYSTEMDRIVE", "USERNAME", "TZ", +] as const; + +/** + * Base scoped child-process environment (§六/§十四). + * + * Never mutates `process.env` (no cross-provider pollution under concurrency) and never inherits a + * parent vendor variable, so a stray region switch in the host shell cannot flip a provider's + * region: the profile is the sole authority. Family builders layer the credential + region vars on + * top of this. + */ +export function baseScopedEnv(): Record { + const env: Record = {}; + for (const key of INHERITED_ENV_KEYS) { + const value = process.env[key]; + if (typeof value === "string" && value.length > 0) env[key] = value; + } + return env; +} + +/** Redact the profile's credential env value and common secret shapes before surfacing diagnostics. */ +export function redactSecrets(text: string, tokenEnv: string): string { + const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return text + .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]") + .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]") + .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]"); +} + +export interface CodingAgentTurnInput { + /** Region profiles for this family; the turn fails closed if the base URL matches none. */ + profiles: readonly CodingAgentProviderProfile[]; + provider: OcxProviderConfig; + parsed: OcxParsedRequest; + incoming: IncomingMeta; + emit: (event: AdapterEvent) => void; + /** Family-specific headless argument builder (tools disabled, model, reasoning, system prompt). */ + buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[]; + /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */ + buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record; + deps: CodingAgentDeps; +} + +/** + * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十). + * + * Single transport for every official coding-agent CLI provider: fail closed on a non-canonical + * destination, pre-flight the credential and binary, spawn with a scoped env and tools disabled, feed + * the replayed conversation over stream-json, map the vendor's Anthropic-aligned frames to + * AdapterEvents, and always reap the process. Codex retains tool ownership: the CLI runs with its own + * tools disabled, so this turn yields text/reasoning (the control-protocol tool bridge is a + * documented fast-follow). + */ +export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { + const { profiles, provider, parsed, incoming, emit, buildArgs, buildEnv, deps } = input; + const spawnFn = deps.spawn ?? nodeSpawn; + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS; + + if (incoming.abortSignal?.aborted) { + emit({ type: "error", message: "Coding-agent turn was aborted before start." }); + return; + } + + // Fail closed on a non-canonical destination BEFORE any credential is placed in an env (§十六). + const profile = resolveProfileByBaseUrl(profiles, provider.baseUrl); + if (!profile) { + emit({ + type: "error", + message: "Provider base URL is not a canonical region destination; the credential was not sent.", + status: 400, + errorType: "invalid_request_error", + code: "non_canonical_destination", + retryable: false, + }); + return; + } + const apiKey = provider.apiKey; + if (!apiKey) { + emit({ + type: "error", + message: `${profile.label} credential missing — add an API key for this provider (${profile.tokenEnv}).`, + status: 401, + errorType: "authentication_error", + code: "missing_credential", + retryable: false, + }); + return; + } + // Pre-flight binary discovery so a missing CLI is a clear error, not a mid-turn ENOENT (§二十六). + const binary = resolveCodingAgentBinary(profile, deps.which); + if (!binary) { + emit({ + type: "error", + message: `${profile.label} CLI not found on PATH. Install it with: ${profile.installHint}`, + status: 500, + errorType: "upstream_error", + code: "cli_not_found", + retryable: false, + }); + return; + } + + const args = buildArgs(profile, parsed, provider); + const env = buildEnv(profile, apiKey); + + let child: ChildProcess; + try { + child = spawnFn(binary, args, { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + } catch (err) { + emit({ type: "error", message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv), status: 500, errorType: "upstream_error" }); + return; + } + + let terminalEmitted = false; + const emitOnce = (event: AdapterEvent): void => { + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + if (terminalEmitted) return; + terminalEmitted = true; + } + emit(event); + }; + + const stderrChunks: string[] = []; + let killed = false; + let killTimer: ReturnType | undefined; + const kill = (): void => { + if (killed || child.killed) return; + killed = true; + try { child.kill("SIGTERM"); } catch { /* already gone */ } + killTimer = setTimeout(() => { + try { child.kill("SIGKILL"); } catch { /* already gone */ } + }, killGraceMs); + }; + + const onAbort = (): void => { kill(); }; + incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); + const timeoutTimer = setTimeout(() => { + kill(); + emitOnce({ type: "error", message: `${profile.label} turn timed out.`, status: 504, errorType: "upstream_error", code: "timeout", retryable: true }); + }, timeoutMs); + + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + if (stderrChunks.join("").length < MAX_STDERR_BYTES) stderrChunks.push(chunk); + }); + + const cleanup = (): void => { + clearTimeout(timeoutTimer); + incoming.abortSignal?.removeEventListener("abort", onAbort); + try { child.stdin?.destroy(); } catch { /* ignore */ } + // Termination is owned by the reap step below, not here: killing in cleanup would set + // `child.killed` and let the wait resolve before the process is actually reaped (§三十). + }; + + let streamProtocolError: string | undefined; + const state: StreamParseState = { + sawPartialText: false, + sawPartialThinking: false, + sawTerminalResult: false, + openToolCallId: undefined, + }; + + try { + // Write the replayed conversation, then close stdin so a single-shot turn can complete. + const stdin = child.stdin; + if (stdin) { + stdin.on("error", () => { /* EPIPE if the CLI exits early; surfaced via close/stderr */ }); + for (const line of buildConversationInput(parsed)) stdin.write(`${line}\n`); + stdin.end(); + } + const stdout = child.stdout; + if (!stdout) throw new CodingAgentProtocolError(`${profile.label} CLI produced no stdout stream`); + try { + for await (const message of readJsonLines(stdout)) { + if (incoming.abortSignal?.aborted) break; + for (const event of mapStreamMessageToEvents(message, state)) emitOnce(event); + if (terminalEmitted) break; + } + } catch (err) { + kill(); + streamProtocolError = err instanceof Error ? err.message : String(err); + emitOnce({ + type: "error", + message: redactSecrets(streamProtocolError, profile.tokenEnv), + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } + } catch (err) { + kill(); + emitOnce({ type: "error", message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv), status: 502, errorType: "upstream_error" }); + } finally { + cleanup(); + } + + // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and + // force-terminate only if it lingers past the grace window after the stream ended. + await new Promise(resolve => { + if (child.exitCode !== null) { resolve(); return; } + const graceTimer = setTimeout(() => { kill(); }, killGraceMs); + child.once("close", () => { clearTimeout(graceTimer); resolve(); }); + }); + if (killTimer) clearTimeout(killTimer); + + if (!terminalEmitted) { + const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv); + if (incoming.abortSignal?.aborted) { + emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false }); + } else if (streamProtocolError) { + emitOnce({ + type: "error", + message: redactSecrets(streamProtocolError, profile.tokenEnv), + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } else if (child.exitCode !== null && child.exitCode !== 0) { + const exitMsg = stderr + ? `${profile.label} CLI exited with code ${child.exitCode}: ${stderr}` + : `${profile.label} CLI exited with non-zero exit code ${child.exitCode}`; + emitOnce({ + type: "error", + message: exitMsg, + status: 502, + errorType: "upstream_error", + code: "process_exit_error", + retryable: false, + }); + } else if (!state.sawTerminalResult) { + const msg = stderr + ? `${profile.label} CLI ended without a terminal result frame: ${stderr}` + : `${profile.label} CLI ended without a terminal result frame`; + emitOnce({ + type: "error", + message: msg, + status: 502, + errorType: "upstream_error", + code: "protocol_error", + retryable: false, + }); + } + } +} + +function boundedStderr(chunks: string[]): string { + let total = 0; + const kept: string[] = []; + for (const chunk of chunks) { + if (total >= MAX_STDERR_BYTES) break; + kept.push(chunk); + total += chunk.length; + } + return kept.join("").slice(0, MAX_STDERR_BYTES).trim(); +} diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 81fdbf99a4..999bd1cfbb 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -2,6 +2,7 @@ import { createAnthropicAdapter } from "./anthropic"; import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay"; +import { createCodeBuddyAdapter } from "./codebuddy/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; @@ -20,6 +21,7 @@ export interface AdapterFactoryContext { } export type AdapterWire = + | "codebuddy" | "command-code" | "openai-chat" | "ollama-native" @@ -53,6 +55,11 @@ type InheritedAdapterDefinition = { type AdapterDefinition = DirectAdapterDefinition | InheritedAdapterDefinition; export const ADAPTER_REGISTRY = { + codebuddy: { + wire: "codebuddy", + mutation: "codex-owned", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createCodeBuddyAdapter(provider), + }, "command-code": { wire: "command-code", mutation: "codex-owned", diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts new file mode 100644 index 0000000000..36603b0a15 --- /dev/null +++ b/src/providers/codebuddy-models.ts @@ -0,0 +1,156 @@ +/** + * Curated CodeBuddy model catalogs, transcribed from the OFFICIAL model manifest bundled with the + * vendor CLI (`@tencent-ai/codebuddy-code` v2.143.0: `product.json` for the global/`public` + * environment, `product.internal.json` for the China/`internal` environment) and cross-checked + * against the CLI's own `--model` accept-list. Verified 2026-09-03. + * + * Global and CN are deliberately NOT the same roster (§八). Context windows, output caps, vision + * and reasoning ladders are filled ONLY where the official manifest states them; a model with no + * published figure is omitted rather than guessed (§二十八/§二十九). CodeBuddy exposes no documented + * third-party live `/v1/models` endpoint, so these providers seed a static catalog + * (`liveModels: false`) exactly like the Kiro and Command Code entries. + */ + +/** Global (`public`) session models accepted by `codebuddy --model`. */ +export const CODEBUDDY_GLOBAL_MODELS = [ + "default-model", + "fast-model", + "balanced-model", + "primary-model", + "deep-model", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3-codex", + "glm-5.3", + "glm-5.2", + "kimi-k3", + "kimi-k2.6", + "minimax-m3", +]; + +/** China (`internal`) session models from the official internal manifest (text/chat models only). */ +export const CODEBUDDY_CN_MODELS = [ + "default", + "deepseek-v4-pro", + "deepseek-v4-flash", + "minimax-m3", + "minimax-m2.7", + "glm-5.2", + "glm-5.1", + "kimi-k3-1", + "kimi-k2.7", + "kimi-k2.6", + "hy3", + "hunyuan-chat", +]; + +/** + * The CLI documents a single `--effort` ladder (minimal, low, medium, high, xhigh, max). The Codex + * reasoning ladder overlaps it at low..max; `minimal`/`none` are Codex sentinels normalized by + * `mapReasoningEffort`, and `ultra` folds to `max`. Declared provider-wide, then narrowed per model + * where the official manifest publishes a smaller `supportedEfforts`. + */ +export const CODEBUDDY_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + +export const CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS: Record = { + "default-model": 176_000, + "fast-model": 200_000, + "balanced-model": 256_000, + "primary-model": 272_000, + "deep-model": 176_000, + "gpt-5.6-sol": 1_000_000, + "gpt-5.6-terra": 1_000_000, + "gpt-5.6-luna": 1_000_000, + "gpt-5.5": 1_000_000, + "gpt-5.4": 272_000, + "gpt-5.3-codex": 272_000, + "glm-5.3": 1_000_000, + "glm-5.2": 1_000_000, + "kimi-k3": 1_000_000, + "kimi-k2.6": 256_000, + "minimax-m3": 512_000, +}; + +export const CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS: Record = { + "default-model": 24_000, + "fast-model": 32_000, + "balanced-model": 32_000, + "primary-model": 72_000, + "deep-model": 24_000, + "gpt-5.6-sol": 128_000, + "gpt-5.6-terra": 128_000, + "gpt-5.6-luna": 128_000, + "gpt-5.5": 72_000, + "gpt-5.4": 128_000, + "gpt-5.3-codex": 128_000, + "glm-5.3": 48_000, + "glm-5.2": 48_000, + "kimi-k3": 32_000, + "kimi-k2.6": 32_000, + "minimax-m3": 128_000, +}; + +/** Per-model ladders narrowed from the official manifest's `reasoning.supportedEfforts`. */ +export const CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS: Record = { + "gpt-5.6-sol": ["low", "medium", "high", "xhigh"], + "gpt-5.6-terra": ["low", "medium", "high", "xhigh"], + "gpt-5.6-luna": ["low", "medium", "high", "xhigh"], + "glm-5.3": ["low", "high", "max"], + "glm-5.2": ["high", "xhigh"], +}; + +export const CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS: Record = { + "gpt-5.6-sol": "high", + "gpt-5.6-terra": "high", + "gpt-5.6-luna": "high", + "glm-5.3": "high", + "glm-5.2": "high", +}; + +export const CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS: Record = { + "default": 200_000, + "deepseek-v4-pro": 1_000_000, + "deepseek-v4-flash": 1_000_000, + "minimax-m3": 512_000, + "minimax-m2.7": 200_000, + "glm-5.2": 1_000_000, + "glm-5.1": 200_000, + "kimi-k3-1": 1_000_000, + "kimi-k2.7": 256_000, + "kimi-k2.6": 256_000, + "hy3": 192_000, + "hunyuan-chat": 200_000, +}; + +export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record = { + "default": 24_000, + "deepseek-v4-pro": 50_000, + "deepseek-v4-flash": 50_000, + "minimax-m3": 128_000, + "minimax-m2.7": 48_000, + "glm-5.2": 48_000, + "glm-5.1": 48_000, + "kimi-k3-1": 32_000, + "kimi-k2.7": 32_000, + "kimi-k2.6": 32_000, + "hy3": 64_000, + "hunyuan-chat": 8_192, +}; + +export const CODEBUDDY_CN_MODEL_REASONING_EFFORTS: Record = { + "hy3": ["low", "high"], +}; + +export const CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS: Record = { + "hy3": "high", +}; + +/** + * Text-only models (official manifest `supportsImages: false`). Images for any OTHER model are + * passed through natively; a model listed here has its images routed through the proxy's vision + * sidecar rather than being silently dropped (§二十九). + */ +export const CODEBUDDY_CN_NO_VISION_MODELS = ["default", "hunyuan-chat"]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index cc692902d3..f34a40a7e6 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -21,6 +21,20 @@ import { import { cursorFastCapableBases } from "../adapters/cursor/catalog"; import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; +import { + CODEBUDDY_CN_MODELS, + CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + CODEBUDDY_CN_NO_VISION_MODELS, + CODEBUDDY_GLOBAL_MODELS, + CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + CODEBUDDY_REASONING_EFFORTS, +} from "./codebuddy-models"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -3011,6 +3025,64 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. + // Transport is the vendor-documented headless CLI automation surface + // (`codebuddy -p --output-format stream-json --tools ""`) authenticated with the official + // `CODEBUDDY_API_KEY` (https://www.codebuddy.ai/profile/keys). It does NOT read desktop + // session files, import desktop bearer tokens, impersonate the desktop client, or call the + // private console endpoint — the approach closed in #687 and left in draft in #2244. + // baseUrl is the canonical region identity: the adapter fails closed if it is overridden, so a + // global key is never sent to the CN environment (that is the separate `codebuddy-cn` entry). + // v1 runs tools-disabled so Codex keeps tool ownership; this provider is text/reasoning only + // until the control-protocol tool bridge lands (see docs). Free/trial/promotional/subscription + // credits draw from the same official API-key pool. Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. + // GOVERNANCE: whether routing this vendor automation surface behind a proxy for a third-party + // agent satisfies CodeBuddy's AUP is an open question flagged for maintainer security review. + id: "codebuddy", + label: "CodeBuddy (Global)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.ai", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://www.codebuddy.ai/profile/keys", + defaultModel: "default-model", + models: CODEBUDDY_GLOBAL_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. v1 disables CLI tools (--tools \"\") so Codex retains tool ownership: text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, + { + // Official CodeBuddy Code CLI provider, CHINA / `internal` environment. Identical adapter and + // binary as `codebuddy`; the region is fixed by the profile's CODEBUDDY_INTERNET_ENVIRONMENT + // and this canonical baseUrl. CN key: https://copilot.tencent.com/profile/keys. The CN model + // roster differs from Global (see codebuddy-models.ts) and is seeded separately (§八). + id: "codebuddy-cn", + label: "CodeBuddy (CN)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://copilot.tencent.com/profile/keys", + defaultModel: "default", + models: CODEBUDDY_CN_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, ]; export function providerRegistryFastWireError( diff --git a/tests/adapter-buffered-tool-conformance.test.ts b/tests/adapter-buffered-tool-conformance.test.ts index 3472afdd67..d320df1a22 100644 --- a/tests/adapter-buffered-tool-conformance.test.ts +++ b/tests/adapter-buffered-tool-conformance.test.ts @@ -23,6 +23,7 @@ const WIRE_MODELS: Record = { kiro: "claude-sonnet-4.5", "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", + codebuddy: "glm-5.3", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -35,6 +36,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi kiro: "https://runtime.us-east-1.kiro.dev", "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", + codebuddy: "https://www.codebuddy.ai", }; const baseUrl = adapterId === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" diff --git a/tests/adapter-registry-authority.test.ts b/tests/adapter-registry-authority.test.ts index 1bbfc28c40..3ead9f957f 100644 --- a/tests/adapter-registry-authority.test.ts +++ b/tests/adapter-registry-authority.test.ts @@ -10,6 +10,7 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; const EXPECTED_ADAPTER_NAMES = { + codebuddy: "codebuddy", "command-code": "command-code", "openai-chat": "openai-chat", "ollama-native": "ollama-native", @@ -30,11 +31,13 @@ function provider(adapter: string): OcxProviderConfig { // adapter accepts the placeholder URL. baseUrl: adapter === "mimo-free" ? "https://api.xiaomimimo.com/api/free-ai/openai/chat" - // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than - // guessing that an arbitrary destination speaks Ollama's compatibility surface. - : adapter === "ollama-native" - ? "https://example.invalid/api" - : "https://example.invalid/v1", + : adapter === "codebuddy" + ? "https://www.codebuddy.ai" + // ollama-native refuses a bare /v1 path on a host it does not recognise, rather than + // guessing that an arbitrary destination speaks Ollama's compatibility surface. + : adapter === "ollama-native" + ? "https://example.invalid/api" + : "https://example.invalid/v1", authMode: "key", apiKey: "test-key", defaultMaxOutputTokens: 4096, diff --git a/tests/adapter-tool-conformance.test.ts b/tests/adapter-tool-conformance.test.ts index a3a6ae2800..1ad320a84c 100644 --- a/tests/adapter-tool-conformance.test.ts +++ b/tests/adapter-tool-conformance.test.ts @@ -34,6 +34,7 @@ const WIRE_MODELS: Record = { kiro: "claude-sonnet-4.5", "openai-responses": "deepseek-v4-flash", cursor: "cursor/auto", + codebuddy: "glm-5.3", }; function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfig { @@ -46,6 +47,7 @@ function providerFixture(adapterId: string, wire: AdapterWire): OcxProviderConfi kiro: "https://runtime.us-east-1.kiro.dev", "openai-responses": "https://api.deepseek.com", cursor: "https://api2.cursor.sh", + codebuddy: "https://www.codebuddy.ai", }; // Semantic wrappers with provider-specific URL shapes must override the wire-family default here. const baseUrl = adapterId === "mimo-free" @@ -417,8 +419,11 @@ describe("registry-derived routed tool conformance", () => { } }); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy"]); + test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); @@ -432,6 +437,7 @@ describe("registry-derived routed tool conformance", () => { test("tool_choice none disables every registered adapter's callable tool surface", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const enabledBody = await outbound(adapterId, toolChoiceParsed(contract.wire)); expect(advertisedToolNames(contract.wire, enabledBody).length, `${adapterId}:enabled`).toBeGreaterThan(0); @@ -442,6 +448,7 @@ describe("registry-derived routed tool conformance", () => { test("every parsed streaming wire restores hostile freeform input exactly", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall) { @@ -456,6 +463,7 @@ describe("registry-derived routed tool conformance", () => { test("every buffered adapter preserves same-name tools from different namespaces", async () => { 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 @@ -470,6 +478,7 @@ describe("registry-derived routed tool conformance", () => { test("every routed adapter fails closed for an ambiguous bare selector", async () => { 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; const parsed = namespacedCollisionParsed(contract.wire); @@ -495,6 +504,7 @@ describe("registry-derived routed tool conformance", () => { test("every streaming adapter restores namespaced custom/function collisions distinctly", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const driver = TOOL_WIRE_DRIVERS[contract.wire]; if (!driver.streamingToolCall || !driver.extractWireToolName) { @@ -537,6 +547,7 @@ describe("registry-derived routed tool conformance", () => { test("every registered adapter replays the exact apply_patch input on continuation", async () => { for (const [adapterId] of adapterDefinitions()) { + if (TOOL_LESS_ADAPTERS.has(adapterId)) continue; const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, continuationParsed(contract.wire)); expect(continuationInput(contract.wire, body), adapterId).toBe(PATCH); diff --git a/tests/codebuddy-adapter.test.ts b/tests/codebuddy-adapter.test.ts new file mode 100644 index 0000000000..1299ec614a --- /dev/null +++ b/tests/codebuddy-adapter.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { buildArgs, buildChildEnv, createCodeBuddyAdapter, type SpawnFn } from "../src/adapters/codebuddy/adapter"; +import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../src/adapters/codebuddy/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const enc = new TextEncoder(); + +// The binary-discovery cache is module-level (a production perf seam); reset it so a test that +// reports a missing CLI cannot mask a later test's injected binary. +beforeEach(() => clearCodeBuddyBinaryCache()); + +interface FakeChild extends EventEmitter { + stdout: Readable; + stderr: Readable; + stdin: Writable; + killed: boolean; + exitCode: number | null; + kill: (signal?: string) => boolean; + written: string[]; +} + +function fakeChild(stdout: Uint8Array[], opts: { stderr?: string; exitCode?: number; emitClose?: boolean } = {}): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdout = Readable.from(stdout); + child.stderr = Readable.from(opts.stderr ? [enc.encode(opts.stderr)] : []); + child.written = []; + child.stdin = new Writable({ write(chunk, _enc, cb) { child.written.push(String(chunk)); cb(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + if (opts.emitClose !== false) { + setTimeout(() => { child.exitCode = opts.exitCode ?? 0; child.emit("close", opts.exitCode ?? 0); }, 3); + } + return child; +} + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "codebuddy", + baseUrl: CODEBUDDY_GLOBAL_PROFILE.canonicalBaseUrl, + apiKey: "cb-global-key", + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + ...overrides, + } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "glm-5.3", + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + ...overrides, + } as OcxParsedRequest; +} + +function incoming(abortSignal?: AbortSignal) { + return { headers: new Headers(), translatorBudget: createTestTranslatorBudget(), ...(abortSignal ? { abortSignal } : {}) }; +} + +async function run(adapter: ReturnType, p: OcxParsedRequest, inc = incoming()): Promise { + const events: AdapterEvent[] = []; + await adapter.runTurn!(p, inc, e => events.push(e)); + return events; +} + +describe("codebuddy child environment is region-scoped and never global", () => { + test("global profile sets public environment and the global key only", () => { + const env = buildChildEnv(CODEBUDDY_GLOBAL_PROFILE, "cb-global-key"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + expect(env.CODEBUDDY_API_KEY).toBe("cb-global-key"); + expect(env.CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS).toBe("1"); + }); + + test("CN profile sets internal environment and the CN key only", () => { + const env = buildChildEnv(CODEBUDDY_CN_PROFILE, "cb-cn-key"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("internal"); + expect(env.CODEBUDDY_API_KEY).toBe("cb-cn-key"); + }); + + test("a stray parent CODEBUDDY_INTERNET_ENVIRONMENT cannot flip the region", () => { + const previous = process.env.CODEBUDDY_INTERNET_ENVIRONMENT; + process.env.CODEBUDDY_INTERNET_ENVIRONMENT = "internal"; + try { + const env = buildChildEnv(CODEBUDDY_GLOBAL_PROFILE, "k"); + expect(env.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + // The parent CODEBUDDY_* is never inherited: only the profile-set keys are present. + expect(Object.keys(env).filter(k => k.startsWith("CODEBUDDY_")).sort()).toEqual([ + "CODEBUDDY_API_KEY", "CODEBUDDY_CODE_DISABLE_BACKGROUND_TASKS", "CODEBUDDY_INTERNET_ENVIRONMENT", + ]); + } finally { + if (previous === undefined) delete process.env.CODEBUDDY_INTERNET_ENVIRONMENT; + else process.env.CODEBUDDY_INTERNET_ENVIRONMENT = previous; + } + }); +}); + +describe("codebuddy headless arguments keep tool ownership with Codex", () => { + test("disables all CLI tools and never requests permission bypass", () => { + const args = buildArgs(CODEBUDDY_GLOBAL_PROFILE, parsed(), provider()); + const toolsIndex = args.indexOf("--tools"); + expect(toolsIndex).toBeGreaterThanOrEqual(0); + expect(args[toolsIndex + 1]).toBe(""); // "" = disable all built-in tools + expect(args).toContain("--strict-mcp-config"); // no MCP tools either + expect(args).not.toContain("-y"); + expect(args).not.toContain("--dangerously-skip-permissions"); + expect(args).toContain("--output-format"); + expect(args[args.indexOf("--output-format") + 1]).toBe("stream-json"); + expect(args[args.indexOf("--model") + 1]).toBe("glm-5.3"); + }); + + test("maps Codex reasoning effort onto --effort and folds the system prompt", () => { + const args = buildArgs( + CODEBUDDY_GLOBAL_PROFILE, + parsed({ options: { reasoning: "high" }, context: { systemPrompt: ["Be terse."], messages: [] } }), + provider(), + ); + expect(args[args.indexOf("--effort") + 1]).toBe("high"); + expect(args[args.indexOf("--append-system-prompt") + 1]).toBe("Be terse."); + }); +}); + +describe("codebuddy runTurn fails closed before any spawn", () => { + test("a non-canonical base URL is refused and the credential is never placed in a child env", async () => { + let spawned = 0; + const spawn: SpawnFn = () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }; + const adapter = createCodeBuddyAdapter(provider({ baseUrl: "https://evil.example.test" }), { spawn, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination", retryable: false }); + }); + + test("a missing credential is refused before spawn", async () => { + let spawned = 0; + const adapter = createCodeBuddyAdapter(provider({ apiKey: undefined }), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "missing_credential" }); + }); + + test("a missing CLI is a clear pre-flight error, not a mid-turn ENOENT", async () => { + let spawned = 0; + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => undefined }); + const events = await run(adapter, parsed()); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "cli_not_found" }); + expect(String((events[0] as { message: string }).message)).toContain("npm install -g @tencent-ai/codebuddy-code"); + }); +}); + +describe("codebuddy runTurn streams a headless turn", () => { + test("emits text deltas then done with usage, and feeds the conversation to stdin", async () => { + const stdout = [ + enc.encode('{"type":"system","subtype":"init"}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hel"}}}\n'), + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}}\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":7,"output_tokens":2}}\n'), + ]; + const child = fakeChild(stdout); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe("Hello"); + expect(events.at(-1)).toMatchObject({ type: "done", usage: { inputTokens: 7, outputTokens: 2, totalTokens: 9 } }); + expect(child.written.join("")).toContain('"text":"hello"'); + }); + + test("region isolation: the global adapter never spawns with the CN environment", async () => { + let seenEnv: NodeJS.ProcessEnv | undefined; + const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; + const adapter = createCodeBuddyAdapter(provider(), { spawn, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + await run(adapter, parsed()); + expect(seenEnv?.CODEBUDDY_INTERNET_ENVIRONMENT).toBe("public"); + expect(seenEnv?.CODEBUDDY_API_KEY).toBe("cb-global-key"); + }); + + test("an upstream error result surfaces as an error event", async () => { + const stdout = [enc.encode('{"type":"result","subtype":"error_during_execution","is_error":true,"result":"insufficient credits"}\n')]; + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => fakeChild(stdout) as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.at(-1)).toMatchObject({ type: "error", message: "insufficient credits", status: 502 }); + }); + + test("a pre-aborted signal ends the turn without spawning", async () => { + let spawned = 0; + const controller = new AbortController(); + controller.abort(); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => { spawned++; return fakeChild([]) as unknown as ChildProcess; }, which: () => "/usr/bin/codebuddy" }); + const events = await run(adapter, parsed(), incoming(controller.signal)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error" }); + }); + + test("a CLI that exits without a result reports stderr (redacted) as an upstream error", async () => { + const child = fakeChild([], { stderr: "fatal: CODEBUDDY_API_KEY=sk-secretvalue rejected", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + const last = events.at(-1) as { type: string; message: string; code: string }; + expect(last.type).toBe("error"); + expect(last.code).toBe("process_exit_error"); + expect(last.message).not.toContain("sk-secretvalue"); + }); + + test("a CLI that exits with non-zero exit code and empty stderr reports process_exit_error and never done", async () => { + const child = fakeChild([], { stderr: "", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + code: "process_exit_error", + errorType: "upstream_error", + }); + expect((events[0] as { message: string }).message).toContain("exited with non-zero exit code 1"); + // Under no circumstance should a synthetic done be emitted! + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("a CLI that exits with code 0 but emitted no terminal result frame fails closed with protocol_error", async () => { + // Upstream closed stdout without emitting a result frame + const child = fakeChild([ + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Partial"}}}\n'), + ], { exitCode: 0 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(events.some(e => e.type === "done")).toBe(false); + const last = events.at(-1) as { type: string; message: string; code: string; status: number }; + expect(last.type).toBe("error"); + expect(last.code).toBe("protocol_error"); + expect(last.status).toBe(502); + expect(last.message).toContain("ended without a terminal result frame"); + }); + + test("a stream with malformed JSON terminates child and fails closed with protocol_error", async () => { + const child = fakeChild([ + enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n'), + enc.encode('CORRUPTED_NOT_JSON\n'), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ], { exitCode: 0 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + expect(child.killed).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + const last = events.at(-1) as { type: string; message: string; code: string; status: number }; + expect(last.type).toBe("error"); + expect(last.code).toBe("protocol_error"); + expect(last.status).toBe(502); + expect(last.message).toContain("Malformed stream-json frame"); + }); + + test("an in-flight abort kills the child process gracefully with SIGTERM", async () => { + const controller = new AbortController(); + const stdoutStream = new Readable({ + read() { + // Feed one partial delta then abort before result + this.push(enc.encode('{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"start"}}}\n')); + setTimeout(() => controller.abort(), 5); + }, + }); + const child = new EventEmitter() as FakeChild; + child.stdout = stdoutStream; + child.stderr = Readable.from([]); + child.written = []; + child.stdin = new Writable({ write(_c, _e, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + let killSignal: string | undefined; + child.kill = (sig?: string) => { + child.killed = true; + killSignal = sig; + setTimeout(() => { child.exitCode = 143; child.emit("close", 143); }, 5); + return true; + }; + + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed(), incoming(controller.signal)); + expect(child.killed).toBe(true); + expect(killSignal).toBe("SIGTERM"); + expect(events.some(e => e.type === "error")).toBe(true); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); diff --git a/tests/codebuddy-protocol.test.ts b/tests/codebuddy-protocol.test.ts new file mode 100644 index 0000000000..0d48fc33dc --- /dev/null +++ b/tests/codebuddy-protocol.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, test } from "bun:test"; +import { + buildConversationInput, + buildInputLines, + buildSystemPrompt, + mapStreamMessageToEvents, + readJsonLines, + usageFromResult, +} from "../src/adapters/coding-agent/protocol"; +import type { OcxParsedRequest } from "../src/types"; + +// The stream-json protocol for coding-agent CLIs +// (src/adapters/coding-agent/protocol.ts); these fixtures exercise it via CodeBuddy frames. + +const enc = new TextEncoder(); + +async function* chunks(...parts: Uint8Array[]): AsyncGenerator { + for (const part of parts) yield part; +} + +async function collect(gen: AsyncGenerator>): Promise[]> { + const out: Record[] = []; + for await (const item of gen) out.push(item); + return out; +} + +function parsedRequest(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "glm-5.3", + stream: true, + options: {}, + context: { messages: [] }, + ...overrides, + } as OcxParsedRequest; +} + +describe("codebuddy stream-json line reader", () => { + test("parses multiple frames delivered in a single chunk", async () => { + const line = enc.encode('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out.map(m => m.type)).toEqual(["a", "b", "c"]); + }); + + test("reassembles a JSON frame fragmented across chunk boundaries", async () => { + const full = enc.encode('{"type":"result","subtype":"success"}\n'); + const out = await collect(readJsonLines(chunks(full.slice(0, 12), full.slice(12, 25), full.slice(25)))); + expect(out).toEqual([{ type: "result", subtype: "success" }]); + }); + + test("reassembles a multi-byte UTF-8 character split across chunks", async () => { + const full = enc.encode('{"type":"stream_event","text":"世界"}\n'); + // "世" is a 3-byte sequence; split inside it so the decoder must buffer the partial char. + const marker = enc.encode('"text":"').length; + const splitAt = full.indexOf(enc.encode("世")[0]!, marker) + 1; + const out = await collect(readJsonLines(chunks(full.slice(0, splitAt), full.slice(splitAt)))); + expect(out[0]?.text).toBe("世界"); + }); + + test("handles CRLF line endings transparently", async () => { + const line = enc.encode('{"type":"a"}\r\n{"type":"b"}\r\n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out.map(m => m.type)).toEqual(["a", "b"]); + }); + + test("emits a final frame that has no trailing newline (upstream EOF)", async () => { + const out = await collect(readJsonLines(chunks(enc.encode('{"type":"result"}')))); + expect(out).toEqual([{ type: "result" }]); + }); + + test("fails closed on malformed stream-json line with CodingAgentProtocolError", async () => { + const line = enc.encode('{"type":"ok"}\nnot-json\n'); + const gen = readJsonLines(chunks(line)); + await expect(collect(gen)).rejects.toThrow("Malformed stream-json frame received from CodeBuddy CLI"); + }); + + test("fails closed on non-object JSON frame (array or primitive)", async () => { + const line = enc.encode('[1,2]\n'); + const gen = readJsonLines(chunks(line)); + await expect(collect(gen)).rejects.toThrow("Non-object stream-json frame received from CodeBuddy CLI"); + }); + + test("ignores blank and whitespace padding lines between valid frames", async () => { + const line = enc.encode(' \n\n{"type":"ok"}\n \n'); + const out = await collect(readJsonLines(chunks(line))); + expect(out).toEqual([{ type: "ok" }]); + }); + + test("enforces the total byte ceiling", async () => { + const gen = readJsonLines(chunks(enc.encode("x".repeat(100))), { maxTotalBytes: 10 }); + await expect(collect(gen)).rejects.toThrow(/total byte ceiling/); + }); +}); + +describe("codebuddy stream-json event mapping", () => { + test("maps partial text and thinking deltas and decouples their state", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const text = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hi" } } }, + state, + ); + expect(text).toEqual([{ type: "text_delta", text: "Hi" }]); + expect(state.sawPartialText).toBe(true); + expect(state.sawPartialThinking).toBe(false); + + const thinking = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "let me see" } } }, + state, + ); + expect(thinking).toEqual([{ type: "thinking_delta", thinking: "let me see" }]); + expect(state.sawPartialThinking).toBe(true); + }); + + test("assistant fallback matrix: independently decouples partial text and partial thinking", () => { + // Case 1: Partial text seen, partial thinking NOT seen -> assistant emits thinking only, no duplicate text + const state1 = { sawPartialText: true, sawPartialThinking: false, sawTerminalResult: false }; + const events1 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state1, + ); + expect(events1).toEqual([{ type: "thinking_delta", thinking: "reasoning..." }]); + + // Case 2: Partial thinking seen, partial text NOT seen -> assistant emits text only, no duplicate thinking + const state2 = { sawPartialText: false, sawPartialThinking: true, sawTerminalResult: false }; + const events2 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state2, + ); + expect(events2).toEqual([{ type: "text_delta", text: "final answer" }]); + + // Case 3: Both partials seen -> assistant emits nothing + const state3 = { sawPartialText: true, sawPartialThinking: true, sawTerminalResult: false }; + const events3 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state3, + ); + expect(events3).toEqual([]); + + // Case 4: Neither partial seen -> assistant emits both thinking and text + const state4 = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const events4 = mapStreamMessageToEvents( + { + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning..." }, + { type: "text", text: "final answer" }, + ], + }, + }, + state4, + ); + expect(events4).toEqual([ + { type: "thinking_delta", thinking: "reasoning..." }, + { type: "text_delta", text: "final answer" }, + ]); + }); + + test("maps a successful result frame to done with usage and marks sawTerminalResult", () => { + const state = { sawPartialText: true, sawPartialThinking: false, sawTerminalResult: false }; + const events = mapStreamMessageToEvents( + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 } }, + state, + ); + expect(state.sawTerminalResult).toBe(true); + expect(events).toEqual([{ + type: "done", + stopReason: "stop", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15, cachedInputTokens: 2, cacheReadInputTokens: 2 }, + }]); + }); + + test("maps an errored result frame to an upstream error, keeping usage without marking success", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + const events = mapStreamMessageToEvents( + { type: "result", subtype: "error_during_execution", is_error: true, result: "boom", usage: { input_tokens: 3, output_tokens: 0 } }, + state, + ); + expect(state.sawTerminalResult).toBe(false); + expect(events[0]).toMatchObject({ type: "error", status: 502, errorType: "upstream_error", message: "boom" }); + }); + + test("ignores system/init and background task frames", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; + expect(mapStreamMessageToEvents({ type: "system", subtype: "init" }, state)).toEqual([]); + expect(mapStreamMessageToEvents({ type: "system", subtype: "task_started" }, state)).toEqual([]); + }); + + test("parses tool_use blocks defensively even though v1 disables tools", () => { + const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false, openToolCallId: undefined as string | undefined }; + const start = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_start", content_block: { type: "tool_use", id: "t1", name: "exec" } } }, + state, + ); + expect(start).toEqual([{ type: "tool_call_start", id: "t1", name: "exec" }]); + const delta = mapStreamMessageToEvents( + { type: "stream_event", event: { type: "content_block_delta", delta: { type: "input_json_delta", partial_json: "{\"a\":1}" } } }, + state, + ); + expect(delta).toEqual([{ type: "tool_call_delta", arguments: "{\"a\":1}" }]); + const stop = mapStreamMessageToEvents({ type: "stream_event", event: { type: "content_block_stop" } }, state); + expect(stop).toEqual([{ type: "tool_call_end" }]); + expect(state.openToolCallId).toBeUndefined(); + }); + + test("usageFromResult returns undefined when no usage is present", () => { + expect(usageFromResult({ type: "result" })).toBeUndefined(); + }); +}); + +describe("codebuddy conversation input builder (Strategy C projection)", () => { + test("folds system + developer prompts and skips developer messages in the input stream", () => { + const parsed = parsedRequest({ + context: { + systemPrompt: ["You are Codex."], + messages: [ + { role: "developer", content: "Policy: be brief.", timestamp: 0 }, + { role: "user", content: "hello", timestamp: 1 }, + ], + }, + }); + expect(buildSystemPrompt(parsed)).toBe("You are Codex.\n\nPolicy: be brief."); + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ type: "user", message: { role: "user", content: [{ type: "text", text: "hello" }] } }); + }); + + test("projects multi-turn conversation into legal user-message frames with clear context separation", () => { + const parsed = parsedRequest({ + context: { + messages: [ + { role: "user", content: "Check the files.", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I will call exec" }, + { type: "toolCall", id: "c1", name: "exec", arguments: { cmd: "ls" } }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "exec", + content: "file1.txt\nfile2.txt", + isError: false, + timestamp: 2, + }, + { role: "user", content: "Now read file1.txt", timestamp: 3 }, + ], + }, + }); + + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + // Must ONLY emit legal user message frames; zero assistant replay frames! + expect(lines).toHaveLength(1); + expect(lines[0].type).toBe("user"); + expect(lines[0].message.role).toBe("user"); + + const text = lines[0].message.content[0].text as string; + expect(text).toContain("Prior conversation context:"); + expect(text).toContain("USER:\nCheck the files."); + expect(text).toContain("ASSISTANT:\n[Thinking: I will call exec]\n[Tool call: exec (call_id: c1)"); + expect(text).toContain("TOOL RESULT (call_id: c1):\nfile1.txt\nfile2.txt"); + expect(text).toContain("Current user request:\n\nNow read file1.txt"); + + // Must NOT contain raw assistant frames + for (const raw of buildConversationInput(parsed)) { + expect(raw).not.toContain('"type":"assistant"'); + } + }); + + test("encodes a base64 image part and never silently drops a remote image", () => { + const dataUrl = buildInputLines({ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,QUJD" }], timestamp: 0 } as never) + .map(line => JSON.parse(line)); + expect(dataUrl[0].message.content[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "QUJD" } }); + const remote = buildInputLines({ role: "user", content: [{ type: "image", imageUrl: "https://x.test/a.png" }], timestamp: 0 } as never) + .map(line => JSON.parse(line)); + expect(remote[0].message.content[0]).toEqual({ type: "image", source: { type: "url", url: "https://x.test/a.png" } }); + }); + + test("preserves images attached during multi-turn conversation projection", () => { + const parsed = parsedRequest({ + context: { + messages: [ + { role: "user", content: "Here is the layout", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "Show me the screenshot" }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "Look at this screenshot" }, + { type: "image", imageUrl: "data:image/png;base64,QUJD" }, + ], + timestamp: 2, + }, + ], + }, + }); + + const lines = buildConversationInput(parsed).map(line => JSON.parse(line)); + expect(lines).toHaveLength(1); + expect(lines[0].type).toBe("user"); + const content = lines[0].message.content as Array>; + expect(content[0].type).toBe("text"); + expect(content[0].text).toContain("Current user request:\n\nLook at this screenshot"); + expect(content[1]).toEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "QUJD" }, + }); + }); +}); diff --git a/tests/helpers/adapter-conformance/wire-drivers.ts b/tests/helpers/adapter-conformance/wire-drivers.ts index 979d1d4780..d99ea81a6d 100644 --- a/tests/helpers/adapter-conformance/wire-drivers.ts +++ b/tests/helpers/adapter-conformance/wire-drivers.ts @@ -318,4 +318,13 @@ export const TOOL_WIRE_DRIVERS = { } }, }, + codebuddy: { + // CodeBuddy v1 runs the vendor CLI with `--tools ""` so Codex keeps tool ownership; it forwards + // no client tool catalog and is exempt from routed-tool conformance, so this driver is never + // invoked. It fails loudly if a future change routes it here before the control-protocol tool + // bridge (sdk_mcp / can_use_tool) lands. + async observeOutbound(): Promise { + throw new Error("codebuddy forwards no client tool catalog in v1; excluded from tool conformance"); + }, + }, } satisfies Record; diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index b52a57583a..f2d66a46a4 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -37,6 +37,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", + "codebuddy", "codebuddy-cn", ]; describe("provider registry parity", () => { @@ -1137,6 +1138,31 @@ describe("free-provider directory isolation", () => { expect(routed.modelId).toBe("custom-model"); }); + test("a custom provider named codebuddy keeps its own destination (preserveCustomDestination)", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "codebuddy", + providers: { + codebuddy: { + adapter: "openai-chat", + baseUrl: "https://custom.codebuddy.example.test/v1", + apiKey: "test-key", + liveModels: true, + }, + }, + }; + + const routed = routeModel(config, "codebuddy/custom-model"); + expect(routed.provider).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://custom.codebuddy.example.test/v1", + liveModels: true, + }); + expect(routed.provider.adapter).not.toBe("codebuddy"); + expect(routed.provider.baseUrl).not.toBe("https://www.codebuddy.ai"); + expect(routed.modelId).toBe("custom-model"); + }); + test("only rows with checked provenance claim a verification date", () => { for (const entry of FREE_PROVIDER_DIRECTORY) { if (entry.verification === "unverified") { From 4f74dd12524e1b19385e9fdeea652b40f9cb8c7a Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:46:18 +0800 Subject: [PATCH 2/5] fix(provider): harden CodeBuddy CLI lifecycle --- src/adapters/coding-agent/turn.ts | 73 +++++++++++++++++++------- src/providers/codebuddy-models.ts | 30 ++++++++++- tests/codebuddy-adapter.test.ts | 26 +++++++++ tests/provider-registry-parity.test.ts | 9 ++++ 4 files changed, 117 insertions(+), 21 deletions(-) diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 1a8201da83..7feacc8db3 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -45,10 +45,12 @@ export function baseScopedEnv(): Record { return env; } -/** Redact the profile's credential env value and common secret shapes before surfacing diagnostics. */ -export function redactSecrets(text: string, tokenEnv: string): string { +/** Redact the profile's credential and common secret shapes before surfacing diagnostics. */ +export function redactSecrets(text: string, tokenEnv: string, credential?: string): string { const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return text + let redacted = text; + if (credential) redacted = redacted.split(credential).join("[redacted]"); + return redacted .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]") .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]") .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]"); @@ -139,6 +141,26 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise(resolve => { + let settled = false; + const settle = (): void => { + if (settled) return; + settled = true; + resolve(); + }; + child.once("error", err => { + childProcessError = err; + // A launch failure has no process to reap and is not guaranteed to emit `close` on every runtime. + if (child.pid === undefined) settle(); + }); + child.once("close", settle); + if (child.exitCode !== null) settle(); + }); + let terminalEmitted = false; const emitOnce = (event: AdapterEvent): void => { if (event.type === "done" || event.type === "error" || event.type === "incomplete") { @@ -181,6 +203,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise(resolve => { - if (child.exitCode !== null) { resolve(); return; } - const graceTimer = setTimeout(() => { kill(); }, killGraceMs); - child.once("close", () => { clearTimeout(graceTimer); resolve(); }); - }); + const graceTimer = setTimeout(() => { kill(); }, killGraceMs); + await processLifecycle; + clearTimeout(graceTimer); if (killTimer) clearTimeout(killTimer); if (!terminalEmitted) { - const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv); + const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv, apiKey); if (incoming.abortSignal?.aborted) { emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false }); + } else if (childProcessError) { + emitOnce({ + type: "error", + message: `${profile.label} CLI failed to start: ${redactSecrets(childProcessError.message, profile.tokenEnv, apiKey)}`, + status: 500, + errorType: "upstream_error", + code: "cli_spawn_failed", + retryable: false, + }); + } else if (turnError) { + emitOnce({ + type: "error", + message: redactSecrets(turnError, profile.tokenEnv, apiKey), + status: 502, + errorType: "upstream_error", + }); } else if (streamProtocolError) { emitOnce({ type: "error", - message: redactSecrets(streamProtocolError, profile.tokenEnv), + message: redactSecrets(streamProtocolError, profile.tokenEnv, apiKey), status: 502, errorType: "upstream_error", code: "protocol_error", diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts index 36603b0a15..edd6a30415 100644 --- a/src/providers/codebuddy-models.ts +++ b/src/providers/codebuddy-models.ts @@ -24,6 +24,7 @@ export const CODEBUDDY_GLOBAL_MODELS = [ "gpt-5.5", "gpt-5.4", "gpt-5.3-codex", + "gemini-3.5-flash", "glm-5.3", "glm-5.2", "kimi-k3", @@ -40,9 +41,15 @@ export const CODEBUDDY_CN_MODELS = [ "minimax-m2.7", "glm-5.2", "glm-5.1", + "glm-5.0", + "glm-5.0-turbo", + "glm-5v-turbo", + "glm-4.7", "kimi-k3-1", "kimi-k2.7", "kimi-k2.6", + "kimi-k2.5", + "deepseek-v3-2-volc", "hy3", "hunyuan-chat", ]; @@ -67,6 +74,7 @@ export const CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS: Record = { "gpt-5.5": 1_000_000, "gpt-5.4": 272_000, "gpt-5.3-codex": 272_000, + "gemini-3.5-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.2": 1_000_000, "kimi-k3": 1_000_000, @@ -86,6 +94,7 @@ export const CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS: Record = "gpt-5.5": 72_000, "gpt-5.4": 128_000, "gpt-5.3-codex": 128_000, + "gemini-3.5-flash": 65_536, "glm-5.3": 48_000, "glm-5.2": 48_000, "kimi-k3": 32_000, @@ -118,9 +127,15 @@ export const CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS: Record = { "minimax-m2.7": 200_000, "glm-5.2": 1_000_000, "glm-5.1": 200_000, + "glm-5.0": 200_000, + "glm-5.0-turbo": 200_000, + "glm-5v-turbo": 200_000, + "glm-4.7": 200_000, "kimi-k3-1": 1_000_000, "kimi-k2.7": 256_000, "kimi-k2.6": 256_000, + "kimi-k2.5": 164_000, + "deepseek-v3-2-volc": 96_000, "hy3": 192_000, "hunyuan-chat": 200_000, }; @@ -133,9 +148,15 @@ export const CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS: Record = { "minimax-m2.7": 48_000, "glm-5.2": 48_000, "glm-5.1": 48_000, + "glm-5.0": 48_000, + "glm-5.0-turbo": 48_000, + "glm-5v-turbo": 64_000, + "glm-4.7": 48_000, "kimi-k3-1": 32_000, "kimi-k2.7": 32_000, "kimi-k2.6": 32_000, + "kimi-k2.5": 32_000, + "deepseek-v3-2-volc": 32_000, "hy3": 64_000, "hunyuan-chat": 8_192, }; @@ -153,4 +174,11 @@ export const CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS: Record { expect(events[0]).toMatchObject({ type: "error", code: "cli_not_found" }); expect(String((events[0] as { message: string }).message)).toContain("npm install -g @tencent-ai/codebuddy-code"); }); + + test("an asynchronous spawn failure settles as cli_spawn_failed without waiting for close", async () => { + const child = fakeChild([], { emitClose: false }); + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => { + setTimeout(() => child.emit("error", Object.assign(new Error("spawn ENOENT cb-global-key"), { code: "ENOENT" })), 0); + return child as unknown as ChildProcess; + }, + which: () => "/stale/path/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed", retryable: false }); + expect((events[0] as { message: string }).message).not.toContain("cb-global-key"); + }); }); describe("codebuddy runTurn streams a headless turn", () => { @@ -204,6 +221,15 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(last.message).not.toContain("sk-secretvalue"); }); + test("redacts the exact configured credential even when stderr uses no known secret prefix", async () => { + const child = fakeChild([], { stderr: "authentication failed: token cb-global-key rejected", exitCode: 1 }); + const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); + const events = await run(adapter, parsed()); + const last = events.at(-1) as { message: string }; + expect(last.message).toContain("token [redacted] rejected"); + expect(last.message).not.toContain("cb-global-key"); + }); + test("a CLI that exits with non-zero exit code and empty stderr reports process_exit_error and never done", async () => { const child = fakeChild([], { stderr: "", exitCode: 1 }); const adapter = createCodeBuddyAdapter(provider(), { spawn: () => child as unknown as ChildProcess, which: () => "/usr/bin/codebuddy", killGraceMs: 20 }); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index f2d66a46a4..a43e7ff87f 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -41,6 +41,15 @@ const EXPECTED_KEY_PROVIDER_IDS = [ ]; describe("provider registry parity", () => { + test("CodeBuddy static catalogs cover every official CLI-agent model in the bundled 2.143.0 manifest", () => { + const global = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "codebuddy")!); + const cn = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "codebuddy-cn")!); + expect(global.models).toContain("gemini-3.5-flash"); + expect(cn.models).toEqual(expect.arrayContaining([ + "glm-5.0", "glm-5.0-turbo", "glm-5v-turbo", "glm-4.7", "kimi-k2.5", "deepseek-v3-2-volc", + ])); + }); + test("registry ids are unique", () => { const ids = PROVIDER_REGISTRY.map(entry => entry.id); expect(new Set(ids).size).toBe(ids.length); From bc199c54ac62f98ffad1a684600dd3ab52916563 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:34:44 +0800 Subject: [PATCH 3/5] fix(provider): address CodeBuddy runtime review --- src/adapters/coding-agent/protocol.ts | 14 +++--- src/adapters/coding-agent/turn.ts | 41 ++++++++++++++-- tests/codebuddy-adapter.test.ts | 69 +++++++++++++++++++++++++++ tests/codebuddy-protocol.test.ts | 6 +++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index a3fa06cb07..2326b52ad3 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -46,9 +46,8 @@ export type StreamMessage = Record; * * Handles the streaming hazards the task calls out (§二十五): fragmented JSON across chunks, split * multi-byte UTF-8 (via the decoder's `stream` mode), partial trailing lines, and multiple frames in - * one chunk. A frame that does not parse to a JSON record is dropped, never thrown: an unparseable - * line is padding, and terminating on it would discard deltas that already arrived (the same - * reasoning the command-code NDJSON reader documents for #1219/#1240). + * one chunk. A non-empty frame that does not parse to a JSON record fails closed so corrupted + * protocol output cannot be mistaken for a successful response. */ export async function* readJsonLines( chunks: AsyncIterable, @@ -62,6 +61,9 @@ export async function* readJsonLines( let totalBytes = 0; const flushLine = function* (line: string): Generator { + if (encoder.encode(line).byteLength > maxLineBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); + } const trimmed = line.trim(); if (!trimmed) return; // Blank lines and whitespace-only lines are ignored as padding. let parsed: unknown; @@ -88,9 +90,6 @@ export async function* readJsonLines( throw new CodingAgentStreamLimitError("Coding-agent stream exceeded the total byte ceiling"); } buffer += decoder.decode(chunk, { stream: true }); - if (encoder.encode(buffer).byteLength > maxLineBytes) { - throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); - } let newline = buffer.indexOf("\n"); while (newline >= 0) { const line = buffer.slice(0, newline); @@ -98,6 +97,9 @@ export async function* readJsonLines( yield* flushLine(line); newline = buffer.indexOf("\n"); } + if (encoder.encode(buffer).byteLength > maxLineBytes) { + throw new CodingAgentStreamLimitError("Coding-agent stream line exceeded the byte ceiling"); + } } // Flush the decoder's trailing bytes and any final line without a newline terminator. buffer += decoder.decode(); diff --git a/src/adapters/coding-agent/turn.ts b/src/adapters/coding-agent/turn.ts index 7feacc8db3..e8ef20fe45 100644 --- a/src/adapters/coding-agent/turn.ts +++ b/src/adapters/coding-agent/turn.ts @@ -1,5 +1,6 @@ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { commandInvocation } from "../../lib/win-exec"; import type { IncomingMeta } from "../base"; import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, readJsonLines, type StreamParseState } from "./protocol"; import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile"; @@ -14,6 +15,10 @@ export interface CodingAgentDeps { timeoutMs?: number; /** Grace period between SIGTERM and SIGKILL (ms). */ killGraceMs?: number; + /** Maximum time to wait for a child that never reports close after termination (ms). */ + reapTimeoutMs?: number; + /** Test seam for Windows command-shim invocation. */ + platform?: NodeJS.Platform; } const DEFAULT_TIMEOUT_MS = 300_000; @@ -85,6 +90,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { kill(); }; + const stopStream = (): void => { + try { child.stdout?.destroy(); } catch { /* already closed */ } + }; + const onAbort = (): void => { + kill(); + stopStream(); + }; incoming.abortSignal?.addEventListener("abort", onAbort, { once: true }); const timeoutTimer = setTimeout(() => { kill(); + stopStream(); emitOnce({ type: "error", message: `${profile.label} turn timed out.`, status: 504, errorType: "upstream_error", code: "timeout", retryable: true }); }, timeoutMs); @@ -245,8 +271,15 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise { kill(); }, killGraceMs); - await processLifecycle; + let reapTimer: ReturnType | undefined; + await Promise.race([ + processLifecycle, + new Promise(resolve => { + reapTimer = setTimeout(resolve, reapTimeoutMs); + }), + ]); clearTimeout(graceTimer); + if (reapTimer) clearTimeout(reapTimer); if (killTimer) clearTimeout(killTimer); if (!terminalEmitted) { diff --git a/tests/codebuddy-adapter.test.ts b/tests/codebuddy-adapter.test.ts index b2e360909b..7d05072148 100644 --- a/tests/codebuddy-adapter.test.ts +++ b/tests/codebuddy-adapter.test.ts @@ -167,6 +167,42 @@ describe("codebuddy runTurn fails closed before any spawn", () => { expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed", retryable: false }); expect((events[0] as { message: string }).message).not.toContain("cb-global-key"); }); + + test("a synchronous spawn failure redacts the exact configured credential", async () => { + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => { throw new Error("launch rejected credential cb-global-key"); }, + which: () => "/stale/path/codebuddy", + }); + + const events = await run(adapter, parsed()); + expect(events[0]).toMatchObject({ type: "error", code: "cli_spawn_failed", retryable: false }); + expect((events[0] as { message: string }).message).toContain("credential [redacted]"); + expect((events[0] as { message: string }).message).not.toContain("cb-global-key"); + }); + + test("a Windows cmd shim is launched through commandInvocation with escaped arguments", async () => { + let command = ""; + let args: readonly string[] = []; + let options: import("node:child_process").SpawnOptions | undefined; + const adapter = createCodeBuddyAdapter(provider(), { + platform: "win32", + which: () => "C:\\npm\\codebuddy.cmd", + spawn: (seenCommand, seenArgs, seenOptions) => { + command = seenCommand; + args = seenArgs; + options = seenOptions; + return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; + }, + killGraceMs: 20, + }); + + await run(adapter, parsed({ context: { systemPrompt: ['Say "hello" & stop'], messages: [] } })); + expect(command.toLowerCase()).toContain("cmd.exe"); + expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(args[3]).toContain("codebuddy.cmd"); + expect(args[3]).toContain("Say"); + expect(options?.windowsVerbatimArguments).toBe(true); + }); }); describe("codebuddy runTurn streams a headless turn", () => { @@ -309,4 +345,37 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(events.some(e => e.type === "error")).toBe(true); expect(events.some(e => e.type === "done")).toBe(false); }); + + test("a timeout destroys a stalled stdout stream and returns even when close never arrives", async () => { + const stdoutStream = new Readable({ read() { /* stays open until timeout destroys it */ } }); + const child = new EventEmitter() as FakeChild; + child.stdout = stdoutStream; + child.stderr = Readable.from([]); + child.written = []; + child.stdin = new Writable({ write(_c, _e, cb) { cb(); } }); + child.killed = false; + child.exitCode = null; + const signals: string[] = []; + child.kill = (sig?: string) => { + child.killed = true; + signals.push(sig ?? "SIGTERM"); + return true; + }; + + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => child as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + timeoutMs: 10, + killGraceMs: 10, + reapTimeoutMs: 35, + }); + const startedAt = Date.now(); + const events = await run(adapter, parsed()); + + expect(Date.now() - startedAt).toBeLessThan(250); + expect(stdoutStream.destroyed).toBe(true); + expect(signals).toContain("SIGTERM"); + expect(events).toContainEqual(expect.objectContaining({ type: "error", status: 504, code: "timeout" })); + expect(events.some(e => e.type === "done")).toBe(false); + }); }); diff --git a/tests/codebuddy-protocol.test.ts b/tests/codebuddy-protocol.test.ts index 0d48fc33dc..a769b9d8e5 100644 --- a/tests/codebuddy-protocol.test.ts +++ b/tests/codebuddy-protocol.test.ts @@ -41,6 +41,12 @@ describe("codebuddy stream-json line reader", () => { expect(out.map(m => m.type)).toEqual(["a", "b", "c"]); }); + test("applies the line limit to each frame instead of the combined chunk", async () => { + const line = enc.encode('{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n'); + const out = await collect(readJsonLines(chunks(line), { maxLineBytes: 12 })); + expect(out.map(m => m.type)).toEqual(["a", "b", "c"]); + }); + test("reassembles a JSON frame fragmented across chunk boundaries", async () => { const full = enc.encode('{"type":"result","subtype":"success"}\n'); const out = await collect(readJsonLines(chunks(full.slice(0, 12), full.slice(12, 25), full.slice(25)))); From 4ac98bd4de0a7ed4c490d24b34c54d9f161e9657 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:56:22 +0800 Subject: [PATCH 4/5] feat(provider): add Qoder Global PAT provider --- README.md | 2 +- docs/qoder-cli-provider.md | 43 ++++++++++++ src/adapters/coding-agent/profile.ts | 2 +- src/adapters/coding-agent/protocol.ts | 43 ++++++++++-- src/adapters/qoder/adapter.ts | 70 +++++++++++++++++++ src/adapters/qoder/live-models.ts | 89 ++++++++++++++++++++++++ src/adapters/qoder/profiles.ts | 24 +++++++ src/adapters/registry.ts | 5 ++ src/codex/catalog/provider-fetch.ts | 46 ++++++++++++ src/codex/model-cache.ts | 15 ++-- src/providers/free-directory.ts | 12 ++++ src/providers/qoder-models.ts | 16 +++++ src/providers/registry.ts | 20 ++++++ src/server/management/provider-routes.ts | 24 +++++++ tests/adapter-registry-authority.test.ts | 3 + tests/adapter-tool-conformance.test.ts | 2 +- tests/codebuddy-protocol.test.ts | 14 +++- tests/provider-connection-test.test.ts | 18 +++++ tests/provider-registry-parity.test.ts | 9 +-- tests/qoder-adapter.test.ts | 79 +++++++++++++++++++++ tests/qoder-live-models.test.ts | 74 ++++++++++++++++++++ 21 files changed, 590 insertions(+), 20 deletions(-) create mode 100644 docs/qoder-cli-provider.md create mode 100644 src/adapters/qoder/adapter.ts create mode 100644 src/adapters/qoder/live-models.ts create mode 100644 src/adapters/qoder/profiles.ts create mode 100644 src/providers/qoder-models.ts create mode 100644 tests/qoder-adapter.test.ts create mode 100644 tests/qoder-live-models.test.ts diff --git a/README.md b/README.md index f995366a49..69265f71e0 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ full-slash form keeps working too. Details: [model routing docs](https://opencod OpenAI (ChatGPT login or API key), Anthropic, Google Gemini, xAI, Kimi, Azure OpenAI, Ollama (local + Cloud), Cursor (experimental), and every OpenAI-compatible endpoint — plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, -Qwen Cloud, SiliconFlow, and more. Full list: `ocx init` or the +Qwen Cloud, Qoder Global (official PAT + CLI), SiliconFlow, and more. Full list: `ocx init` or the [provider docs](https://opencodex.me/guides/providers/). ## CLI diff --git a/docs/qoder-cli-provider.md b/docs/qoder-cli-provider.md new file mode 100644 index 0000000000..2ca9b6df95 --- /dev/null +++ b/docs/qoder-cli-provider.md @@ -0,0 +1,43 @@ +# Qoder CLI providers + +OpenCodex supports Qoder Global through Qoder's official Personal Access Token and headless CLI. +It does not read Qoder Desktop sessions, browser cookies, refresh tokens, or private console APIs. + +## Qoder Global + +1. Install the official CLI: `npm install -g @qoder-ai/qodercli`. +2. Create a PAT from `https://qoder.com/account/integrations`. +3. Add the `qoder` provider in `ocx init` or the Providers workspace and paste that PAT as the API key. +4. Run `ocx provider test qoder` to verify CLI authentication and account-specific model discovery. + +OpenCodex passes the stored key only as `QODER_PERSONAL_ACCESS_TOKEN` in a scoped child environment. +The adapter accepts only the canonical `https://qoder.com` destination. A legacy custom provider +named `qoder` with another destination keeps its existing adapter and URL. + +The CLI is invoked in one-turn `stream-json` mode with built-in tools disabled (`--tools ""`), MCP +restricted with an empty strict configuration, setting sources disabled, and session persistence +disabled. Codex remains the only tool owner. The first version is text/reasoning only; image input +fails explicitly until the provider route has verified multimodal evidence. + +`qoder --list-models` is the authoritative entitlement roster for the current PAT. OpenCodex uses +its normal model cache and credential-generation invalidation. If discovery fails, it degrades to a +stale cache and then the documented static seed. Quota totals and reset times remain unavailable +because no public quota API is used; insufficient-credit errors are still surfaced as HTTP 429. + +Free, trial, promotional, and subscription credits are expected to use the account attached to the +official PAT/CLI, but the exact product eligibility is account-controlled and is not inferred by +OpenCodex. There is no automatic regional failover or credential exchange. The companion Qoder CN +integration is intentionally delivered as a separate provider/PR with its own PAT, CLI profile, +model entitlement, cache, usage, and health state. + +Primary sources (verified 2026-09-03): + +- Installation: +- PAT authentication: +- Headless scripts and CI: +- Account model discovery: +- SDK/tool configuration: +- Terms: + +The service terms identify BRIGHT ZENITH PRIVATE LIMITED as the operator. This integration uses the +documented CLI automation surface; maintainers should still make the final routing/AUP determination. diff --git a/src/adapters/coding-agent/profile.ts b/src/adapters/coding-agent/profile.ts index 1db14bc50e..7298469767 100644 --- a/src/adapters/coding-agent/profile.ts +++ b/src/adapters/coding-agent/profile.ts @@ -13,7 +13,7 @@ export interface CodingAgentProviderProfile { /** Canonical OpenCodex provider id this profile serves. */ providerId: string; /** Vendor family; selects the arg/env builder in the family adapter. */ - family: "codebuddy"; + family: "codebuddy" | "qoder"; /** Region; drives the vendor's own region switch and keeps credentials deterministic. */ region: "global" | "cn"; /** Human label for diagnostics/error copy (never sent upstream). */ diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 2326b52ad3..f68406b268 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -1,7 +1,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxUsage } from "../../types"; /** - * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code). + * Shared stream-json protocol for official coding-agent CLIs (CodeBuddy Code and Qoder CLI). * * The vendor speaks the Anthropic/Claude-Code `stream-json` protocol ("the naming and protocol * align with Anthropic Claude Code v2.1.88"). A headless turn is a newline-delimited JSON stream on stdout: @@ -72,13 +72,13 @@ export async function* readJsonLines( } catch { const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); throw new CodingAgentProtocolError( - `Malformed stream-json frame received from CodeBuddy CLI: ${snippet}`, + `Malformed stream-json frame received from coding-agent CLI: ${snippet}`, ); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { const snippet = trimmed.slice(0, 64).replace(/[\r\n]+/g, " "); throw new CodingAgentProtocolError( - `Non-object stream-json frame received from CodeBuddy CLI: ${snippet}`, + `Non-object stream-json frame received from coding-agent CLI: ${snippet}`, ); } yield parsed as StreamMessage; @@ -186,12 +186,41 @@ export function mapStreamMessageToEvents(message: StreamMessage, state: StreamPa const isError = message.is_error === true || asString(message.subtype) === "error_during_execution"; const usage = usageFromResult(message); if (isError) { + const errors = Array.isArray(message.errors) + ? message.errors.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const detail = asString(message.result) || errors[0] || "Coding-agent CLI ended the turn with an execution error"; + const vendorCode = typeof message.error_code === "number" ? message.error_code : undefined; + // Qoder documents code 118 and emits the "credit usage limit" wording. Keep the + // match deliberately narrow so other coding-agent CLIs retain their established + // generic-upstream handling for ambiguous text such as "insufficient credits". + const insufficientQuota = vendorCode === 118 || /credit usage limit/i.test(detail); + const authentication = /not logged in|invalid (?:personal access )?token|authentication/i.test(detail); + const rateLimited = !insufficientQuota && /rate limit|too many requests/i.test(detail); + const modelUnavailable = /model (?:is )?(?:not found|unavailable|unsupported)|invalid model/i.test(detail); events.push({ type: "error", - message: asString(message.result) || "CodeBuddy CLI ended the turn with an execution error", - status: 502, - errorType: "upstream_error", - code: "upstream_error", + message: detail, + status: insufficientQuota || rateLimited ? 429 : authentication ? 401 : modelUnavailable ? 400 : 502, + errorType: insufficientQuota + ? "insufficient_quota" + : rateLimited + ? "rate_limit_error" + : authentication + ? "authentication_error" + : modelUnavailable + ? "invalid_request_error" + : "upstream_error", + code: insufficientQuota + ? "insufficient_quota" + : rateLimited + ? "rate_limit_exceeded" + : authentication + ? "invalid_api_key" + : modelUnavailable + ? "model_not_found" + : "upstream_error", + retryable: rateLimited, ...(usage ? { usage } : {}), }); return events; diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts new file mode 100644 index 0000000000..20c0b5581b --- /dev/null +++ b/src/adapters/qoder/adapter.ts @@ -0,0 +1,70 @@ +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import type { AdapterRequest, ProviderAdapter } from "../base"; +import { mapReasoningEffort } from "../../reasoning-effort"; +import { buildSystemPrompt } from "../coding-agent/protocol"; +import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn"; +import { QODER_PROFILES, type QoderProfile } from "./profiles"; + +export type QoderAdapterDeps = CodingAgentDeps; + +export function buildQoderChildEnv(profile: QoderProfile, apiKey: string): Record { + return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey }; +} + +/** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */ +export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] { + const args = [ + "-p", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--tools", "", + "--strict-mcp-config", + "--setting-sources", "", + "--max-turns", "1", + "--no-session-persistence", + "--model", parsed.modelId, + ]; + const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); + if (effort) args.push("--reasoning-effort", effort); + const system = buildSystemPrompt(parsed); + if (system) args.push("--append-system-prompt", system); + return args; +} + +export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter { + return { + name: "qoder", + buildRequest(): AdapterRequest { + return { url: provider.baseUrl, method: "POST", headers: {}, body: "" }; + }, + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." }; + }, + async runTurn(parsed, incoming, emit): Promise { + const hasImage = parsed.context.messages.some(message => + Array.isArray(message.content) && message.content.some(part => part.type === "image"), + ); + if (hasImage) { + emit({ + type: "error", + message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.", + status: 400, + errorType: "invalid_request_error", + code: "unsupported_input_modality", + retryable: false, + }); + return; + } + await runCodingAgentTurn({ + profiles: QODER_PROFILES, + provider, + parsed, + incoming, + emit, + buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov), + buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey), + deps, + }); + }, + }; +} diff --git a/src/adapters/qoder/live-models.ts b/src/adapters/qoder/live-models.ts new file mode 100644 index 0000000000..06d10408bf --- /dev/null +++ b/src/adapters/qoder/live-models.ts @@ -0,0 +1,89 @@ +import { execFile } from "node:child_process"; +import { commandInvocation } from "../../lib/win-exec"; +import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; +import { baseScopedEnv, redactSecrets } from "../coding-agent/turn"; +import { resolveCodingAgentBinary, type WhichFn } from "../coding-agent/profile"; +import type { QoderProfile } from "./profiles"; + +const MAX_OUTPUT_BYTES = 256 * 1024; +const MAX_MODELS = 256; + +export type QoderModelsResult = + | { ok: true; models: string[] } + | { ok: false; error: "auth" | "cli_not_found" | "timeout" | "process" | "invalid_output" | "empty" | "too_large"; detail?: string }; + +export interface QoderExecResult { stdout: string; stderr: string } +export type QoderExecFn = ( + command: string, + args: readonly string[], + options: { env: Record; timeout: number; maxBuffer: number; windowsHide: boolean; windowsVerbatimArguments?: boolean }, +) => Promise; + +export interface QoderModelsDeps { + which?: WhichFn; + platform?: NodeJS.Platform; + timeoutMs?: number; + exec?: QoderExecFn; +} + +type QoderModelsFetcher = (profile: QoderProfile, apiKey: string) => QoderModelsResult | Promise; +let qoderModelsFetcherForTests: QoderModelsFetcher | null = null; + +export function setFetchQoderModelsForTests(next: QoderModelsFetcher | null): void { + qoderModelsFetcherForTests = next; +} + +export function parseQoderModelList(stdout: string): QoderModelsResult { + if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES) return { ok: false, error: "too_large" }; + const lines = stdout.split(/\r?\n/); + const header = lines.findIndex(raw => /^model$/i.test(raw.trim())); + if (header < 0) return { ok: false, error: "invalid_output", detail: "Qoder model list header is missing" }; + const models: string[] = []; + const seen = new Set(); + for (const raw of lines.slice(header + 1)) { + const id = raw.trim(); + if (!id || seen.has(id) || !isValidModelDiscoveryModelId(id)) continue; + seen.add(id); + models.push(id); + if (models.length >= MAX_MODELS) break; + } + return models.length > 0 ? { ok: true, models } : { ok: false, error: "empty" }; +} + +function execQoder(command: string, args: readonly string[], options: Parameters[2]): Promise { + return new Promise((resolve, reject) => { + execFile(command, [...args], { ...options, encoding: "utf8" }, (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +/** Discover the roster exposed to this exact PAT via the documented `--list-models` command. */ +export async function fetchQoderModels(profile: QoderProfile, apiKey: string, deps: QoderModelsDeps = {}): Promise { + if (qoderModelsFetcherForTests) return qoderModelsFetcherForTests(profile, apiKey); + const binary = resolveCodingAgentBinary(profile, deps.which); + if (!binary) return { ok: false, error: "cli_not_found", detail: profile.installHint }; + const env = { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey }; + const invocation = commandInvocation(binary, ["--list-models"], deps.platform ?? process.platform, { env }); + try { + const result = await (deps.exec ?? execQoder)(invocation.file, invocation.args, { + ...invocation.options, + env, + timeout: deps.timeoutMs ?? 8_000, + maxBuffer: MAX_OUTPUT_BYTES, + windowsHide: true, + }); + return parseQoderModelList(result.stdout); + } catch (error) { + const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string }; + const stderr = redactSecrets(failure.stderr ?? failure.message ?? String(error), profile.tokenEnv, apiKey).trim().slice(0, 512); + if (failure.killed || failure.code === "ETIMEDOUT") return { ok: false, error: "timeout", detail: "Qoder model discovery timed out" }; + if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return { ok: false, error: "too_large" }; + const auth = /not logged in|invalid (?:personal access )?token|authentication/i.test(stderr); + return { ok: false, error: auth ? "auth" : "process", ...(stderr ? { detail: stderr } : {}) }; + } +} diff --git a/src/adapters/qoder/profiles.ts b/src/adapters/qoder/profiles.ts new file mode 100644 index 0000000000..6d8e2a1f68 --- /dev/null +++ b/src/adapters/qoder/profiles.ts @@ -0,0 +1,24 @@ +import { clearCodingAgentBinaryCache, resolveProfileByBaseUrl, type CodingAgentProviderProfile } from "../coding-agent/profile"; + +/** Official Qoder CLI profile. Region variants are separate profiles and credentials. */ +export interface QoderProfile extends CodingAgentProviderProfile { + family: "qoder"; +} + +export const QODER_GLOBAL_PROFILE: QoderProfile = { + providerId: "qoder", + family: "qoder", + region: "global", + label: "Qoder", + canonicalBaseUrl: "https://qoder.com", + binaryCandidates: ["qoder", "qodercli"], + tokenEnv: "QODER_PERSONAL_ACCESS_TOKEN", + installHint: "npm install -g @qoder-ai/qodercli", + documentationUrl: "https://docs.qoder.com/cli/authentication", +}; + +export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE]; +export function resolveQoderProfile(baseUrl: string | undefined): QoderProfile | undefined { + return resolveProfileByBaseUrl(QODER_PROFILES, baseUrl) as QoderProfile | undefined; +} +export const clearQoderBinaryCache = clearCodingAgentBinaryCache; diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts index 999bd1cfbb..d8edbead92 100644 --- a/src/adapters/registry.ts +++ b/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { createAzureAdapter } from "./azure"; import type { ProviderAdapter } from "./base"; import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay"; import { createCodeBuddyAdapter } from "./codebuddy/adapter"; +import { createQoderAdapter } from "./qoder/adapter"; import { createCommandCodeAdapter } from "./command-code"; import { createCursorAdapter } from "./cursor"; import { createGoogleAdapter } from "./google"; @@ -115,6 +116,10 @@ export const ADAPTER_REGISTRY = { contractParent: "openai-chat", create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createMimoFreeAdapter(provider), }, + qoder: { + contractParent: "codebuddy", + create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderAdapter(provider), + }, } as const satisfies Record; export type AdapterId = keyof typeof ADAPTER_REGISTRY; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f48edcd03c..784e1193b6 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -50,6 +50,8 @@ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; 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 { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1472,6 +1474,50 @@ async function fetchProviderModelsWithAuth( ? [...models, vertexDefaultSeed] : models ); + if (prov.adapter === "qoder") { + if (!apiKey) return observed(configured, "degraded"); + const profile = resolveQoderProfile(prov.baseUrl); + if (!profile) return observed(configured, "degraded"); + // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT + // fingerprint so an account switch cannot observe another account's roster, even if a caller + // bypasses the normal config mutation path that clears provider caches. + const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); + const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); + if (fresh) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold), + ), "authoritative"); + } + const scopedStale = getStaleCached(name, authorityIdentity); + if (isModelsFetchCoolingDown(name) && scopedStale) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold), + ), "degraded"); + } + const live = await fetchQoderModels(profile, apiKey); + if (live.ok) { + const discovered = live.models.map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold), + })); + const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, live.models.length); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`); + } + const stale = getStaleCached(name, authorityIdentity); + return observed(withConfiguredRetention( + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold) : 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/codex/model-cache.ts b/src/codex/model-cache.ts index 067c4195ca..fe790715fe 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -18,6 +18,8 @@ interface CacheEntry { models: CatalogModel[]; fetchedAt: number; sizeBytes: number; + /** Irreversible credential/account identity for entitlement-sensitive catalogs. */ + authorityIdentity?: string; } export type ProviderModelDiscoveryFailureReason = @@ -149,15 +151,19 @@ export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_F } /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */ -export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null { +export function getFreshCached(provider: string, ttlMs: number, now = Date.now(), authorityIdentity?: string): CatalogModel[] | null { const entry = cache.get(provider); if (!entry) return null; + if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null; return now - entry.fetchedAt < ttlMs ? entry.models : null; } /** Last-known-good models regardless of age — the fallback when a live fetch fails. */ -export function getStaleCached(provider: string): CatalogModel[] | null { - return cache.get(provider)?.models ?? null; +export function getStaleCached(provider: string, authorityIdentity?: string): CatalogModel[] | null { + const entry = cache.get(provider); + if (!entry) return null; + if (authorityIdentity !== undefined && entry.authorityIdentity !== authorityIdentity) return null; + return entry.models; } /** Capture the cache generation before an asynchronous provider discovery starts. */ @@ -181,12 +187,13 @@ export function setCached( models: CatalogModel[], now = Date.now(), generation?: string, + authorityIdentity?: string, ): boolean { if (generation !== undefined && !isModelCacheGenerationCurrent(provider, generation)) return false; deleteCachedProvider(provider); const sizeBytes = modelCacheEncoder.encode(provider).byteLength + modelCacheEncoder.encode(JSON.stringify(models)).byteLength; - cache.set(provider, { models, fetchedAt: now, sizeBytes }); + cache.set(provider, { models, fetchedAt: now, sizeBytes, ...(authorityIdentity ? { authorityIdentity } : {}) }); cacheBytes += sizeBytes; if (oldestCachedAt === null || now < oldestCachedAt) { oldestCachedProvider = provider; diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index ab6e9b2389..e0ca0e455b 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -140,6 +140,18 @@ const CONNECTABLE: Record = { nscale: openAi("https://inference.api.nscale.com/v1", "https://console.nscale.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.nscale.com/docs/use-cases/chat", modelsUrl: "https://inference.api.nscale.com/v1/models", lastVerified: "2026-08-03" }), nvidia: openAi("https://integrate.api.nvidia.com/v1", "https://build.nvidia.com", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.api.nvidia.com/nim/reference/llm-apis" }), publicai: openAi("https://api.publicai.co/v1", "https://publicai.co"), + qoder: { + baseUrl: "https://qoder.com", + dashboardUrl: "https://qoder.com/account/integrations", + adapter: "qoder", + authKind: "key", + supportLevel: "supported", + verification: "official", + documentationUrl: "https://docs.qoder.com/cli/authentication", + discovery: "live", + liveModels: true, + lastVerified: "2026-09-03", + }, scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { supportLevel: "supported", verification: "official", documentationUrl: "https://www.scaleway.com/en/docs/generative-apis/api-cli/using-generative-apis/", modelsUrl: "https://api.scaleway.ai/v1/models", lastVerified: "2026-08-01" }), sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), diff --git a/src/providers/qoder-models.ts b/src/providers/qoder-models.ts new file mode 100644 index 0000000000..d97483a015 --- /dev/null +++ b/src/providers/qoder-models.ts @@ -0,0 +1,16 @@ +/** + * Cold-start fallback from the official Qoder Global model documentation, verified 2026-09-03. + * The account-specific `qoder --list-models` result is authoritative whenever discovery succeeds. + */ +export const QODER_GLOBAL_MODELS = [ + "Qwen3.8-Max", + "Qwen3.7-Max", + "Qwen3.7-Plus", + "Kimi-K3", + "Kimi-K2.7-Code", + "GLM-5.3", + "GLM-5.2", + "DeepSeek-V4-Pro", +] as const; + +export const QODER_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f34a40a7e6..8ae35d2ff1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -35,6 +35,7 @@ import { CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, CODEBUDDY_REASONING_EFFORTS, } from "./codebuddy-models"; +import { QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -3025,6 +3026,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // Official Qoder Global CLI automation surface. The canonical URL is an identity boundary; + // inference and model discovery are performed only by the installed vendor CLI. Authentication + // uses the documented PAT environment variable and never imports desktop/session credentials. + id: "qoder", + label: "Qoder (Global)", + adapter: "qoder", + baseUrl: "https://qoder.com", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.com/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_GLOBAL_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_GLOBAL_MODELS], + note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.", + }, { // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. // Transport is the vendor-documented headless CLI automation surface diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8b9f8d0dd4..f08464937c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -38,6 +38,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; @@ -1228,6 +1230,28 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); - const TOOL_LESS_ADAPTERS = new Set(["codebuddy"]); + const TOOL_LESS_ADAPTERS = new Set(["codebuddy", "qoder"]); test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { diff --git a/tests/codebuddy-protocol.test.ts b/tests/codebuddy-protocol.test.ts index a769b9d8e5..3e82f3eb37 100644 --- a/tests/codebuddy-protocol.test.ts +++ b/tests/codebuddy-protocol.test.ts @@ -76,13 +76,13 @@ describe("codebuddy stream-json line reader", () => { test("fails closed on malformed stream-json line with CodingAgentProtocolError", async () => { const line = enc.encode('{"type":"ok"}\nnot-json\n'); const gen = readJsonLines(chunks(line)); - await expect(collect(gen)).rejects.toThrow("Malformed stream-json frame received from CodeBuddy CLI"); + await expect(collect(gen)).rejects.toThrow("Malformed stream-json frame received from coding-agent CLI"); }); test("fails closed on non-object JSON frame (array or primitive)", async () => { const line = enc.encode('[1,2]\n'); const gen = readJsonLines(chunks(line)); - await expect(collect(gen)).rejects.toThrow("Non-object stream-json frame received from CodeBuddy CLI"); + await expect(collect(gen)).rejects.toThrow("Non-object stream-json frame received from coding-agent CLI"); }); test("ignores blank and whitespace padding lines between valid frames", async () => { @@ -98,6 +98,16 @@ describe("codebuddy stream-json line reader", () => { }); describe("codebuddy stream-json event mapping", () => { + test("classifies coding-agent auth, rate-limit, and unavailable-model results", () => { + const frame = (detail: string) => mapStreamMessageToEvents( + { type: "result", subtype: "error_during_execution", is_error: true, errors: [detail] }, + { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }, + )[0]; + expect(frame("Not logged in; invalid token")).toMatchObject({ status: 401, code: "invalid_api_key", retryable: false }); + expect(frame("Too many requests: rate limit reached")).toMatchObject({ status: 429, code: "rate_limit_exceeded", retryable: true }); + expect(frame("Model is unavailable")).toMatchObject({ status: 400, code: "model_not_found", retryable: false }); + }); + test("maps partial text and thinking deltas and decouples their state", () => { const state = { sawPartialText: false, sawPartialThinking: false, sawTerminalResult: false }; const text = mapStreamMessageToEvents( diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 892b45bef4..3ff01c83d8 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setFetchCursorUsableModelsForTests } from "../src/adapters/cursor/live-models"; +import { setFetchQoderModelsForTests } from "../src/adapters/qoder/live-models"; import { handleManagementAPI } from "../src/server/management-api"; import { saveConfig } from "../src/config"; import { OAUTH_PROVIDERS } from "../src/oauth"; @@ -24,6 +25,7 @@ beforeEach(() => { afterEach(() => { setFetchCursorUsableModelsForTests(null); + setFetchQoderModelsForTests(null); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -54,6 +56,22 @@ async function probe(config: OcxConfig, name: string): Promise<{ status: number; } describe("POST /api/providers/test (WP040 connectivity probe)", () => { + test("Qoder probes the official CLI model list for the configured PAT", async () => { + const calls: Array<{ providerId: string; token: string }> = []; + setFetchQoderModelsForTests((profile, token) => { + calls.push({ providerId: profile.providerId, token }); + return { ok: true, models: ["Qwen3.8-Max", "GLM-5.3"] }; + }); + const config = baseConfig({ + qoder: { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", authMode: "key", liveModels: true }, + }); + + const { body } = await probe(config, "qoder"); + + expect(body).toMatchObject({ ok: true, models: 2, message: "Connected. 2 models." }); + expect(calls).toEqual([{ providerId: "qoder", token: "qoder-pat" }]); + }); + test("Cursor probes GetUsableModels and reports the live model count", async () => { const calls: { apiKey: string; baseUrl?: string }[] = []; setFetchCursorUsableModelsForTests(async options => { diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index a43e7ff87f..36945d839c 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -37,7 +37,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "codebuddy", "codebuddy-cn", + "qoder", "codebuddy", "codebuddy-cn", ]; describe("provider registry parity", () => { @@ -1093,13 +1093,12 @@ describe("free-provider directory isolation", () => { test("directory metadata never becomes a canonical runtime provider", () => { // The directory is a catalog of endpoints we have not adopted. If its ids reached // PROVIDER_REGISTRY, routedProviderConfig() would canonicalize a user's same-named provider - // onto the directory's adapter and baseUrl — for `qoder` that baseUrl is the empty string, - // so the request would lose its destination entirely. + // onto the directory's adapter and baseUrl, so the request could lose its destination. const directoryOnlyIds = FREE_PROVIDER_DIRECTORY .filter(entry => entry.supportLevel === "reference") .map(entry => entry.id); expect(directoryOnlyIds.length).toBeGreaterThan(0); - expect(directoryOnlyIds).toContain("qoder"); + expect(directoryOnlyIds).not.toContain("qoder"); const registryIds = new Set(PROVIDER_REGISTRY.map(entry => entry.id)); for (const id of directoryOnlyIds) { @@ -1144,6 +1143,8 @@ describe("free-provider directory isolation", () => { baseUrl: "https://custom.example.test/v1", liveModels: true, }); + expect(routed.provider.adapter).not.toBe("qoder"); + expect(routed.provider.baseUrl).not.toBe("https://qoder.com"); expect(routed.modelId).toBe("custom-model"); }); diff --git a/tests/qoder-adapter.test.ts b/tests/qoder-adapter.test.ts new file mode 100644 index 0000000000..123c02571c --- /dev/null +++ b/tests/qoder-adapter.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../src/adapters/qoder/adapter"; +import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const enc = new TextEncoder(); +beforeEach(() => clearQoderBinaryCache()); + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], ...overrides } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { modelId: "Qwen3.8-Max", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, ...overrides } as OcxParsedRequest; +} + +function fakeChild(frames: string[]): ChildProcess { + const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; + child.stdout = Readable.from(frames.map(frame => enc.encode(frame))); + child.stderr = Readable.from([]); + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { child.killed = true; return true; }; + setTimeout(() => { child.exitCode = 0; child.emit("close", 0); }, 2); + return child; +} + +describe("qoder adapter", () => { + test("uses only the Global PAT and disables tools, MCP, settings hooks, and persistence", () => { + const env = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "qoder-pat"); + expect(env.QODER_PERSONAL_ACCESS_TOKEN).toBe("qoder-pat"); + expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual(["QODER_PERSONAL_ACCESS_TOKEN"]); + const args = buildQoderArgs(parsed({ options: { reasoning: "high" } }), provider()); + expect(args[args.indexOf("--tools") + 1]).toBe(""); + expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); + expect(args).toContain("--strict-mcp-config"); + expect(args).toContain("--no-session-persistence"); + expect(args[args.indexOf("--reasoning-effort") + 1]).toBe("high"); + expect(args).not.toContain("--dangerously-skip-permissions"); + }); + + test("fails closed before spawn for a non-canonical destination", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider({ baseUrl: "https://evil.example.test" }), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination" }); + }); + + test("rejects unverified image input instead of silently dropping or forwarding it", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } }); + const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events[0]).toMatchObject({ type: "error", code: "unsupported_input_modality" }); + }); + + test("maps Qoder credit exhaustion to a non-retryable 429", async () => { + const adapter = createQoderAdapter(provider(), { + which: () => "/bin/qoder", + spawn: () => fakeChild([ + '{"type":"assistant","message":{"content":[{"type":"text","text":"limit"}]} }\n', + '{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["You reached your credit usage limit"],"error_code":118}\n', + ]), + killGraceMs: 10, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events.at(-1)).toMatchObject({ type: "error", status: 429, errorType: "insufficient_quota", code: "insufficient_quota", retryable: false }); + }); +}); diff --git a/tests/qoder-live-models.test.ts b/tests/qoder-live-models.test.ts new file mode 100644 index 0000000000..858d730c92 --- /dev/null +++ b/tests/qoder-live-models.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../src/adapters/qoder/live-models"; +import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles"; +import { fetchProviderModels } from "../src/codex/catalog/provider-fetch"; +import { clearModelCache, providerCacheGenerations } from "../src/codex/model-cache"; +import type { OcxProviderConfig } from "../src/types"; + +beforeEach(() => clearQoderBinaryCache()); +afterEach(() => { + setFetchQoderModelsForTests(null); + clearModelCache("qoder-test"); + providerCacheGenerations.delete("qoder-test"); +}); + +describe("qoder live model discovery", () => { + test("parses the documented plaintext table with validation and dedupe", () => { + expect(parseQoderModelList("MODEL\nQwen3.8-Max\nQwen3.8-Max\nGLM-5.3\n")).toEqual({ ok: true, models: ["Qwen3.8-Max", "GLM-5.3"] }); + expect(parseQoderModelList("warning that is not a roster\n")).toMatchObject({ ok: false, error: "invalid_output" }); + }); + + test("passes PAT only in scoped env and supports Windows cmd shims", async () => { + let seen: { command: string; args: readonly string[]; env?: NodeJS.ProcessEnv } | undefined; + const exec = async (command: string, args: readonly string[], options: { env: Record }) => { + seen = { command, args, env: options.env }; + return { stdout: "MODEL\nQwen3.8-Max\n", stderr: "" }; + }; + const result = await fetchQoderModels(QODER_GLOBAL_PROFILE, "secret-pat", { platform: "win32", which: () => "C:\\npm\\qoder.cmd", exec }); + expect(result).toEqual({ ok: true, models: ["Qwen3.8-Max"] }); + expect(seen?.command.toLowerCase()).toContain("cmd.exe"); + expect(seen?.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(seen?.env?.QODER_PERSONAL_ACCESS_TOKEN).toBe("secret-pat"); + }); + + test("live account roster is authoritative and static models are only fallback", async () => { + setFetchQoderModelsForTests((_profile, token) => token === "pat" ? { ok: true, models: ["Account-Model"] } : { ok: false, error: "auth" }); + const provider = { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "pat", authMode: "key", liveModels: true, models: ["Static-Model"] } as OcxProviderConfig; + const models = await fetchProviderModels("qoder-test", provider, 60_000); + expect(models.map(model => model.id)).toEqual(["Account-Model"]); + }); + + test("a PAT change cannot reuse the previous account's entitlement cache", async () => { + const calls: string[] = []; + setFetchQoderModelsForTests((_profile, token) => { + calls.push(token); + return { ok: true, models: [`${token}-model`] }; + }); + const base = { adapter: "qoder", baseUrl: "https://qoder.com", authMode: "key", liveModels: true } as OcxProviderConfig; + const accountA = await fetchProviderModels("qoder-test", { ...base, apiKey: "account-a" }, 60_000); + const accountB = await fetchProviderModels("qoder-test", { ...base, apiKey: "account-b" }, 60_000); + expect(accountA.map(model => model.id)).toEqual(["account-a-model"]); + expect(accountB.map(model => model.id)).toEqual(["account-b-model"]); + expect(calls).toEqual(["account-a", "account-b"]); + }); + + test("sequential accounts receive only their own PAT and authentication failures are redacted", async () => { + const credentials: string[] = []; + const exec = async (_command: string, _args: readonly string[], options: { env: Record }) => { + const token = options.env.QODER_PERSONAL_ACCESS_TOKEN ?? ""; + credentials.push(token); + if (token === "bad-secret") { + throw Object.assign(new Error("auth failed"), { stderr: `Not logged in: QODER_PERSONAL_ACCESS_TOKEN=${token}` }); + } + return { stdout: `MODEL\n${token}-model\n`, stderr: "" }; + }; + const first = await fetchQoderModels(QODER_GLOBAL_PROFILE, "account-a", { which: () => "/bin/qoder", exec }); + const second = await fetchQoderModels(QODER_GLOBAL_PROFILE, "account-b", { which: () => "/bin/qoder", exec }); + const failed = await fetchQoderModels(QODER_GLOBAL_PROFILE, "bad-secret", { which: () => "/bin/qoder", exec }); + expect(credentials).toEqual(["account-a", "account-b", "bad-secret"]); + expect(first).toEqual({ ok: true, models: ["account-a-model"] }); + expect(second).toEqual({ ok: true, models: ["account-b-model"] }); + expect(failed).toMatchObject({ ok: false, error: "auth" }); + expect(JSON.stringify(failed)).not.toContain("bad-secret"); + }); +}); From a4e805084de48a5121c5c4d0e3973a3491d6d302 Mon Sep 17 00:00:00 2001 From: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:06:25 +0800 Subject: [PATCH 5/5] feat(provider): add Qoder CN PAT provider --- README.md | 2 +- docs/qoder-cli-provider.md | 32 +++++++++++++++++++++- src/adapters/qoder/profiles.ts | 14 +++++++++- src/providers/free-directory.ts | 16 +++++++++-- src/providers/qoder-models.ts | 9 ++++++ src/providers/registry.ts | 20 +++++++++++++- tests/provider-connection-test.test.ts | 16 +++++++++++ tests/provider-registry-parity.test.ts | 3 +- tests/qoder-adapter.test.ts | 38 +++++++++++++++++++++++++- tests/qoder-live-models.test.ts | 17 +++++++++++- 10 files changed, 158 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 69265f71e0..9b3cd8b6c3 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ full-slash form keeps working too. Details: [model routing docs](https://opencod OpenAI (ChatGPT login or API key), Anthropic, Google Gemini, xAI, Kimi, Azure OpenAI, Ollama (local + Cloud), Cursor (experimental), and every OpenAI-compatible endpoint — plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, -Qwen Cloud, Qoder Global (official PAT + CLI), SiliconFlow, and more. Full list: `ocx init` or the +Qwen Cloud, Qoder Global and CN (official PAT + CLI), SiliconFlow, and more. Full list: `ocx init` or the [provider docs](https://opencodex.me/guides/providers/). ## CLI diff --git a/docs/qoder-cli-provider.md b/docs/qoder-cli-provider.md index 2ca9b6df95..8148506626 100644 --- a/docs/qoder-cli-provider.md +++ b/docs/qoder-cli-provider.md @@ -1,6 +1,6 @@ # Qoder CLI providers -OpenCodex supports Qoder Global through Qoder's official Personal Access Token and headless CLI. +OpenCodex supports Qoder Global and Qoder CN through their official Personal Access Tokens and headless CLIs. It does not read Qoder Desktop sessions, browser cookies, refresh tokens, or private console APIs. ## Qoder Global @@ -30,6 +30,36 @@ OpenCodex. There is no automatic regional failover or credential exchange. The c integration is intentionally delivered as a separate provider/PR with its own PAT, CLI profile, model entitlement, cache, usage, and health state. +## Qoder CN + +1. Install the official CLI: `npm install -g @qodercn-ai/qoderclicn` (the vendor install script is also supported). +2. Create a PAT at `https://qoder.cn/account/integrations`. +3. Add the `qoder-cn` provider and paste the PAT as its API key. +4. Run `ocx provider test qoder-cn` to verify the exact account's authentication and live roster. + +The CN profile accepts only `https://qoder.cn`, resolves `qodercn`/`qoderclicn`, and passes the +credential only as `QODERCN_PERSONAL_ACCESS_TOKEN`. It never reads the local interactive login or +OpenCodex OAuth state. Global and CN credentials, executable resolution, model cache identity, +usage, and health are independent; neither region falls back to the other. + +The static CN roster is only a degraded seed captured from authenticated `qoderclicn --list-models` +on 2026-09-03. Live discovery remains authoritative. A real headless turn reached Qoder CN and +returned vendor error code 118 because that test account had zero credits. This proves the local +authentication/transport/model route, not successful inference; no successful CN response is claimed. + +Qoder CN primary sources (verified 2026-09-03): + +- Installation: +- PAT authentication: +- Headless scripts: +- SDK authentication: +- SDK quick start: + +This implementation credits Liang Xu (`Liang-Psych`) for the earlier Qoder CN exploration in +OpenCodex PR #3010. It retains the useful high-level direction—official CLI, headless stream JSON, +and tools disabled—but deliberately replaces that PR's OAuth/private-protocol and ambient-session +design with the documented PAT environment contract and the shared audited coding-agent adapter. + Primary sources (verified 2026-09-03): - Installation: diff --git a/src/adapters/qoder/profiles.ts b/src/adapters/qoder/profiles.ts index 6d8e2a1f68..a90a274f43 100644 --- a/src/adapters/qoder/profiles.ts +++ b/src/adapters/qoder/profiles.ts @@ -17,7 +17,19 @@ export const QODER_GLOBAL_PROFILE: QoderProfile = { documentationUrl: "https://docs.qoder.com/cli/authentication", }; -export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE]; +export const QODER_CN_PROFILE: QoderProfile = { + providerId: "qoder-cn", + family: "qoder", + region: "cn", + label: "Qoder CN", + canonicalBaseUrl: "https://qoder.cn", + binaryCandidates: ["qodercn", "qoderclicn"], + tokenEnv: "QODERCN_PERSONAL_ACCESS_TOKEN", + installHint: "npm install -g @qodercn-ai/qoderclicn", + documentationUrl: "https://docs.qoder.cn/en/cli/authentication", +}; + +export const QODER_PROFILES: readonly QoderProfile[] = [QODER_GLOBAL_PROFILE, QODER_CN_PROFILE]; export function resolveQoderProfile(baseUrl: string | undefined): QoderProfile | undefined { return resolveProfileByBaseUrl(QODER_PROFILES, baseUrl) as QoderProfile | undefined; } diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index e0ca0e455b..c378f16146 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -19,7 +19,7 @@ export const FREE_PROVIDER_ACCESS_GROUPS = { "recurring-credit": ["bytez", "nous-research"], "signup-credit": [ "agentrouter", "ai21", "baichuan", "baseten", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn", - "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", + "hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", "qoder-cn", "scaleway", "sensenova", "stepfun", "together", "vertex", ], } as const satisfies Record; @@ -152,6 +152,18 @@ const CONNECTABLE: Record = { liveModels: true, lastVerified: "2026-09-03", }, + "qoder-cn": { + baseUrl: "https://qoder.cn", + dashboardUrl: "https://qoder.cn/account/integrations", + adapter: "qoder", + authKind: "key", + supportLevel: "supported", + verification: "official", + documentationUrl: "https://docs.qoder.cn/en/cli/authentication", + discovery: "live", + liveModels: true, + lastVerified: "2026-09-03", + }, scaleway: openAi("https://api.scaleway.ai/v1", "https://console.scaleway.com/generative-api", { supportLevel: "supported", verification: "official", documentationUrl: "https://www.scaleway.com/en/docs/generative-apis/api-cli/using-generative-apis/", modelsUrl: "https://api.scaleway.ai/v1/models", lastVerified: "2026-08-01" }), sensenova: openAi("https://token.sensenova.cn/v1", "https://console.sensenova.cn", { verification: "official" }), stepfun: openAi("https://api.stepfun.com/v1", "https://platform.stepfun.com", { verification: "official" }), @@ -172,7 +184,7 @@ const LABELS: Record = { ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao", "freemodel-dev": "FreeModel.dev", sambanova: "SambaNova Cloud", nebius: "Nebius Token Factory", novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM", - publicai: "PublicAI", qoder: "Qoder", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", + publicai: "PublicAI", qoder: "Qoder", "qoder-cn": "Qoder CN", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI", }; const referenceNote = "Reference entry only: no safe documented API integration is enabled. Configure it manually only with provider documentation; consumer-web cookies and anti-bot bypasses are intentionally unsupported."; diff --git a/src/providers/qoder-models.ts b/src/providers/qoder-models.ts index d97483a015..0f8d8c4350 100644 --- a/src/providers/qoder-models.ts +++ b/src/providers/qoder-models.ts @@ -13,4 +13,13 @@ export const QODER_GLOBAL_MODELS = [ "DeepSeek-V4-Pro", ] as const; +/** Live Qoder CN roster captured from the official CLI on 2026-09-03. */ +export const QODER_CN_MODELS = [ + "Qwen3.8-Max", + "Qwen3.8-Flash", + "Qwen3.7-Max", + "Qwen3.7-Plus", + "Qwen3.7-Flash", +] as const; + export const QODER_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8ae35d2ff1..54ebace1d8 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -35,7 +35,7 @@ import { CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, CODEBUDDY_REASONING_EFFORTS, } from "./codebuddy-models"; -import { QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models"; +import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; @@ -3045,6 +3045,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ noVisionModels: [...QODER_GLOBAL_MODELS], note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.", }, + { + // Qoder CN is a separate credential, executable, destination, entitlement cache, and health + // domain. It deliberately does not reuse the OAuth/private-protocol design from #3010. + id: "qoder-cn", + label: "Qoder CN", + adapter: "qoder", + baseUrl: "https://qoder.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.cn/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_CN_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_CN_MODELS], + note: "Official Qoder CN CLI using QODERCN_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qodercn --list-models`; the verified roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qodercn-ai/qoderclicn`.", + }, { // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. // Transport is the vendor-documented headless CLI automation surface diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index 3ff01c83d8..c2d506da42 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -72,6 +72,22 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(calls).toEqual([{ providerId: "qoder", token: "qoder-pat" }]); }); + test("Qoder CN probes its own CLI profile and PAT", async () => { + const calls: Array<{ providerId: string; token: string }> = []; + setFetchQoderModelsForTests((profile, token) => { + calls.push({ providerId: profile.providerId, token }); + return { ok: true, models: ["Qwen3.8-Flash"] }; + }); + const config = baseConfig({ + "qoder-cn": { adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "cn-pat", authMode: "key", liveModels: true }, + }); + + const { body } = await probe(config, "qoder-cn"); + + expect(body).toMatchObject({ ok: true, models: 1, message: "Connected. 1 models." }); + expect(calls).toEqual([{ providerId: "qoder-cn", token: "cn-pat" }]); + }); + test("Cursor probes GetUsableModels and reports the live model count", async () => { const calls: { apiKey: string; baseUrl?: string }[] = []; setFetchCursorUsableModelsForTests(async options => { diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 36945d839c..2584d632ba 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -37,7 +37,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", - "qoder", "codebuddy", "codebuddy-cn", + "qoder", "qoder-cn", "codebuddy", "codebuddy-cn", ]; describe("provider registry parity", () => { @@ -1099,6 +1099,7 @@ describe("free-provider directory isolation", () => { .map(entry => entry.id); expect(directoryOnlyIds.length).toBeGreaterThan(0); expect(directoryOnlyIds).not.toContain("qoder"); + expect(directoryOnlyIds).not.toContain("qoder-cn"); const registryIds = new Set(PROVIDER_REGISTRY.map(entry => entry.id)); for (const id of directoryOnlyIds) { diff --git a/tests/qoder-adapter.test.ts b/tests/qoder-adapter.test.ts index 123c02571c..2f95fcb890 100644 --- a/tests/qoder-adapter.test.ts +++ b/tests/qoder-adapter.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildQoderArgs, buildQoderChildEnv, createQoderAdapter } from "../src/adapters/qoder/adapter"; -import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles"; +import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../src/adapters/qoder/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -44,6 +44,42 @@ describe("qoder adapter", () => { expect(args).not.toContain("--dangerously-skip-permissions"); }); + test("keeps Global and CN profiles, executables, destinations, and PAT variables isolated", async () => { + expect(resolveQoderProfile("https://qoder.com/")).toBe(QODER_GLOBAL_PROFILE); + expect(resolveQoderProfile("https://qoder.cn/")).toBe(QODER_CN_PROFILE); + expect(QODER_CN_PROFILE.binaryCandidates).toEqual(["qodercn", "qoderclicn"]); + + const globalEnv = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "global-pat"); + const cnEnv = buildQoderChildEnv(QODER_CN_PROFILE, "cn-pat"); + expect(globalEnv.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); + expect(globalEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cnEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); + expect(cnEnv.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + + const spawned: Array<{ executable: string; env: NodeJS.ProcessEnv }> = []; + const runRegion = async (configured: OcxProviderConfig, executable: string) => { + const adapter = createQoderAdapter(configured, { + which: candidate => candidate === executable ? `/bin/${candidate}` : undefined, + spawn: (command, _args, options) => { + spawned.push({ executable: command, env: options.env ?? {} }); + return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + }, + }); + await adapter.runTurn!(parsed(), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, () => {}); + }; + await Promise.all([ + runRegion(provider({ baseUrl: "https://qoder.com", apiKey: "global-pat" }), "qoder"), + runRegion(provider({ baseUrl: "https://qoder.cn", apiKey: "cn-pat" }), "qodercn"), + ]); + expect(spawned).toHaveLength(2); + const global = spawned.find(item => item.executable.endsWith("/qoder"))!; + const cn = spawned.find(item => item.executable.endsWith("/qodercn"))!; + expect(global.env.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); + expect(global.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cn.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); + expect(cn.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + }); + test("fails closed before spawn for a non-canonical destination", async () => { let spawned = 0; const adapter = createQoderAdapter(provider({ baseUrl: "https://evil.example.test" }), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } }); diff --git a/tests/qoder-live-models.test.ts b/tests/qoder-live-models.test.ts index 858d730c92..8b199ca445 100644 --- a/tests/qoder-live-models.test.ts +++ b/tests/qoder-live-models.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { fetchQoderModels, parseQoderModelList, setFetchQoderModelsForTests } from "../src/adapters/qoder/live-models"; -import { clearQoderBinaryCache, QODER_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles"; +import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE } from "../src/adapters/qoder/profiles"; import { fetchProviderModels } from "../src/codex/catalog/provider-fetch"; import { clearModelCache, providerCacheGenerations } from "../src/codex/model-cache"; import type { OcxProviderConfig } from "../src/types"; @@ -31,6 +31,21 @@ describe("qoder live model discovery", () => { expect(seen?.env?.QODER_PERSONAL_ACCESS_TOKEN).toBe("secret-pat"); }); + test("CN discovery selects qodercn and passes only the CN PAT variable", async () => { + let seen: { command: string; env: Record } | undefined; + const result = await fetchQoderModels(QODER_CN_PROFILE, "cn-secret", { + which: candidate => candidate === "qodercn" ? "/bin/qodercn" : undefined, + exec: async (command, _args, options) => { + seen = { command, env: options.env }; + return { stdout: "MODEL\nQwen3.8-Max\nQwen3.8-Flash\n", stderr: "" }; + }, + }); + expect(result).toEqual({ ok: true, models: ["Qwen3.8-Max", "Qwen3.8-Flash"] }); + expect(seen?.command).toBe("/bin/qodercn"); + expect(seen?.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-secret"); + expect(seen?.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + }); + test("live account roster is authoritative and static models are only fallback", async () => { setFetchQoderModelsForTests((_profile, token) => token === "pat" ? { ok: true, models: ["Account-Model"] } : { ok: false, error: "auth" }); const provider = { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "pat", authMode: "key", liveModels: true, models: ["Static-Model"] } as OcxProviderConfig;