diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 11be146d1c3..ba9db69d594 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -878,10 +878,11 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - 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. +- **Model Discovery:** the proxy requests the CodeBuddy product configuration (`GET {baseUrl}/v3/config`) with the configured key as the `X-API-Key` header, and the roster in that answer is the authoritative roster of discovered models: it is the key's own account configuration, so it is proven to belong to the key — a different or wrong key answers the anonymous envelope with no roster instead of another account's models. The authenticated roster is the same list the CLI prints for `--model` (the "Currently supported" line of a signed-in CLI), can differ from the static manifest bundled with the CLI, and the vendor default selectors (`default` for CN, `default-model` for Global) never appear in it but remain callable: the catalog retains them during live discovery and on every fallback path. On start/sync the proxy binds the cached roster to an irreversible fingerprint of the configured key, so a key switch never observes a roster cached for the previous key, and degrades to the stale provider/key-fingerprint-scoped cache, then to the static seed in `src/providers/codebuddy-models.ts`, when the key does not authenticate or the request fails. - **Tool Ownership and the Tool Bridge:** The CLI is always spawned with `--tools ""` and `--strict-mcp-config`, so it has no built-in or user-configured tools of its own. When a request carries a Codex tool catalog, the provider arms a capture-only MCP bridge: the validated catalog and MCP config are written to a private temp dir, the CLI is launched with `--mcp-config` and an exact `--allowedTools` list, and the `system/init` frame must report exactly that bridge server as connected or the turn fails closed. The bridge advertises the Codex tools and captures proposed calls but never executes anything: a completed tool-call batch is returned as `function_call` items (names mapped back to the request's wire names, at most 16 calls per assistant message), the process tree is terminated at `message_stop`, and the external Codex client alone performs approval, sandboxing, and execution. Tool results come back as the next request's input, and the conversation continues. Requests without tools keep the plain text-and-reasoning shape. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. -- **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. - **Tool Choice Enforcement:** When a request specifies `tool_choice: "required"` or selects a specific named tool, the bridge expects a tool call from the model. If the CLI completes the turn with plain text instead of capturing a tool call, OpenCodex fails closed with a 502 `tool_call_required` error rather than returning an invalid text completion. - **Governance Status:** Whether routing this vendor automation surface behind a proxy for a third-party agent satisfies CodeBuddy's acceptable-use terms is an open question flagged for maintainer security review (see the governance note in the provider registry entry). Treat this provider as pending that review, and keep the tool bridge's ownership boundary in mind: the nested CLI advertises tools but never executes them, and approval, sandboxing, and execution remain with the external Codex client. +- **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. ### Official Qoder CLI (Global & CN) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d9cb5a84ad5..94bb93ce04c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -525,6 +525,7 @@ "codebuddy-adapter.test.ts": "providers", "codebuddy-live-acceptance.test.ts": "providers", "codebuddy-mcp-server.test.ts": "providers", + "codebuddy-live-models.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", diff --git a/src/adapters/codebuddy/live-models.ts b/src/adapters/codebuddy/live-models.ts new file mode 100644 index 00000000000..e646f3694eb --- /dev/null +++ b/src/adapters/codebuddy/live-models.ts @@ -0,0 +1,191 @@ +import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; +import type { CodeBuddyProfile } from "./profiles"; + +const MAX_CONFIG_BYTES = 512 * 1024; +const MAX_MODELS = 128; +const MAX_ERROR_BODY_BYTES = 4 * 1024; + +export type CodeBuddyModelsResult = + | { ok: true; models: string[] } + | { ok: false; error: "http" | "timeout" | "invalid_output" | "empty" | "too_large"; detail?: string }; + +export interface CodeBuddyConfigFetchDeps { + /** Test seam for the outbound request; defaults to global fetch. */ + fetch?: typeof fetch; + timeoutMs?: number; +} + +type CodeBuddyModelsFetcher = (profile: CodeBuddyProfile, apiKey: string) => CodeBuddyModelsResult | Promise; +let codeBuddyModelsFetcherForTests: CodeBuddyModelsFetcher | null = null; + +export function setFetchCodeBuddyModelsForTests(next: CodeBuddyModelsFetcher | null): void { + codeBuddyModelsFetcherForTests = next; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// The product gateway authenticates the X-API-Key and then requires a User-Agent it can parse a +// client version from: a bare default fetch/axios UA answers 400 {"code":12403,"msg":"check ua, +// get coding copilot version error"} (measured 260923 on www.codebuddy.cn and +// copilot.tencent.com). The CLI's own UA shape is `CLI/ CodeBuddy/`; the +// version VALUE is not validated (CLI/0.0.1 measures fine), so a fixed recent shape is stable +// until the vendor tightens it — and a rejection then degrades through the same failure path as +// any other discovery failure. +const CLI_USER_AGENT = "CLI/2.126.0 CodeBuddy/2.126.0"; + +/** + * Read at most `cap` bytes of an untrusted upstream body, then stop reading. A body that + * would cross the cap is cancelled at the reader the moment the crossing chunk arrives, so a + * malformed or compromised upstream cannot make discovery buffer an unbounded response (the + * same contract as the vision sidecar's bounded error-body read). + */ +async function readBoundedBodyText(res: Response, cap: number): Promise< + | { ok: true; text: string } + | { ok: false; reason: "exceeded" | "read"; detail?: string } +> { + if (!res.body) return { ok: true, text: "" }; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let out = ""; + let seen = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (seen + value.byteLength > cap) { + try { void reader.cancel("CodeBuddy config body byte limit reached").catch(() => undefined); } + catch { /* best-effort body teardown */ } + return { ok: false, reason: "exceeded" }; + } + seen += value.byteLength; + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return { ok: true, text: out }; + } catch (error) { + return { ok: false, reason: "read", detail: String((error as Error)?.message ?? error).slice(0, 200) }; + } +} + +/** A declared Content-Length above the cap is refused before a single byte is read. */ +function declaredLengthExceeds(response: Response, cap: number): boolean { + const declared = Number(response.headers.get("content-length")); + return Number.isSafeInteger(declared) && declared > cap; +} + +/** + * Parse the key-scoped roster out of the product configuration envelope. + * + * `GET {canonicalBaseUrl}/v3/config` with the configured key answers the KEY's own account + * configuration: `data.agents[].models` is exactly the roster the CLI's `--help` prints for a + * signed-in account of that key (measured 260923: 17 ids, byte-identical), and `data.models` + * carries per-model metadata for the wider account catalog. An absent or invalid key answers + * the anonymous envelope instead — no `agents` array and an empty `models` list — so the roster + * is proven to belong to the key by construction: it only exists when the key authenticated. + * `custom:*` selectors are per-user CLI configuration pointing at operator-defined upstreams, + * not shared catalog rows, and are excluded. + */ +export function parseCodeBuddyConfigRoster(body: unknown): CodeBuddyModelsResult { + if (!isPlainObject(body)) return { ok: false, error: "invalid_output", detail: "CodeBuddy config response is not an object" }; + const data = body.data; + if (!isPlainObject(data)) return { ok: false, error: "invalid_output", detail: "CodeBuddy config envelope is missing its data object" }; + const agents = data.agents; + if (!Array.isArray(agents)) { + // The authenticated envelope always carries an agents array; the anonymous one (absent or + // invalid key) does not. Both fail closed here, but the distinction names the cause. + return { ok: false, error: "empty", detail: "CodeBuddy answered the anonymous config: the key did not authenticate" }; + } + // The catalog mirrors what the CLI itself accepts for --model: the default agent's models. + // agents has carried exactly one entry named "cli" so far; prefer it by name and fall back to + // the first agent that declares a models array, so a future second agent cannot silently + // widen the roster beyond what the chat path can actually run. + const agent = agents.find(entry => isPlainObject(entry) && entry.name === "cli" && Array.isArray(entry.models)) + ?? agents.find(entry => isPlainObject(entry) && Array.isArray(entry.models)); + const declared = isPlainObject(agent) && Array.isArray(agent.models) ? agent.models : []; + const models: string[] = []; + const seen = new Set(); + for (const raw of declared) { + const id = typeof raw === "string" ? raw : isPlainObject(raw) && typeof raw.id === "string" ? raw.id : undefined; + if (!id || id.startsWith("custom:") || 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", detail: "CodeBuddy config roster is empty" }; +} + +/** + * Discover the roster that belongs to this exact key from the product configuration endpoint. + * + * The previous design parsed `codebuddy --help`, whose roster reflects the CLI's signed-in + * account under the caller's home — a key of a different account (or a wrong key) still + * observed the signed-in account's roster, so caching it under the key's fingerprint could + * advertise another account's models for that key (review on #5147). The configuration request + * authenticates with the key itself, so the roster it returns is the key's own: the CLI binary, + * its login state, and the caller's home are all irrelevant to the answer. Measured 260923 + * against www.codebuddy.cn (CN): a valid key answers `data.agents[0].models` with the same 17 + * ids the CLI prints when signed in to that account; an invalid or absent key answers the + * anonymous envelope with no agents and no models. + */ +export async function fetchCodeBuddyModels( + profile: CodeBuddyProfile, + apiKey: string, + deps: CodeBuddyConfigFetchDeps = {}, +): Promise { + if (codeBuddyModelsFetcherForTests) return codeBuddyModelsFetcherForTests(profile, apiKey); + const url = `${profile.canonicalBaseUrl}/v3/config`; + const headers: Record = { + "Accept": "application/json", + "User-Agent": CLI_USER_AGENT, + "X-API-Key": apiKey, + "X-Requested-With": "XMLHttpRequest", + }; + let response: Response; + try { + response = await (deps.fetch ?? fetch)(url, { + headers, + signal: AbortSignal.timeout(deps.timeoutMs ?? 8_000), + }); + } catch (error) { + const name = (error as { name?: string } | null)?.name ?? ""; + if (name === "TimeoutError" || name === "AbortError") { + return { ok: false, error: "timeout", detail: "CodeBuddy model discovery timed out" }; + } + return { ok: false, error: "http", detail: `CodeBuddy config request failed: ${String((error as Error)?.message ?? error).slice(0, 200)}` }; + } + if (response.status !== 200) { + let detail = `HTTP ${response.status}`; + // The error envelope is untrusted upstream output too; read it bounded and skip the + // message entirely when it does not fit a small error-body cap. + const errorBody = await readBoundedBodyText(response, MAX_ERROR_BODY_BYTES); + if (errorBody.ok) { + try { + const envelope = JSON.parse(errorBody.text) as unknown; + if (isPlainObject(envelope) && typeof envelope.msg === "string") { + detail = `HTTP ${response.status} (${String(envelope.msg).slice(0, 120)})`; + } + } catch { + // The status line alone is enough when the body is not a JSON envelope. + } + } + return { ok: false, error: "http", detail }; + } + if (declaredLengthExceeds(response, MAX_CONFIG_BYTES)) { + try { void response.body?.cancel("CodeBuddy config body byte limit reached").catch(() => undefined); } + catch { /* best-effort body teardown */ } + return { ok: false, error: "too_large" }; + } + const body = await readBoundedBodyText(response, MAX_CONFIG_BYTES); + if (!body.ok) { + return body.reason === "exceeded" + ? { ok: false, error: "too_large" } + : { ok: false, error: "http", detail: `CodeBuddy config body read failed${body.detail ? ": " + body.detail : ""}` }; + } + try { + return parseCodeBuddyConfigRoster(JSON.parse(body.text) as unknown); + } catch { + return { ok: false, error: "invalid_output", detail: "CodeBuddy config response is not valid JSON" }; + } +} diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index 8fd19f0054f..4e345578c9d 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -426,6 +426,17 @@ export function suppressedSyntheticMaxCatalogSlugs( export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]); export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly>> = { + // CodeBuddy's vendor defaults are real callable selectors — both the bundled manifests + // (`product.json` / `product.internal.json`) and `--model` accept them — but the key-scoped + // configuration roster omits them. Without this entry, a successful live roster + // would drop the configured default ("default" for CN, "default-model" for Global) from + // the catalog even though the client can still call it (maintainer review, #5147). + codebuddy: new Set([ + "default-model", + ]), + "codebuddy-cn": new Set([ + "default", + ]), kimi: new Set([ "k3[1m]", "kimi-k2.7-code", diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 23a9696850c..617a3737263 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -55,6 +55,9 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { cursorLiveRosterScope, recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "../../adapters/codebuddy/profiles"; +import { fetchCodeBuddyModels } from "../../adapters/codebuddy/live-models"; +import { resolveProfileByBaseUrl } from "../../adapters/coding-agent/profile"; import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; @@ -245,6 +248,53 @@ export async function fetchProviderModelsWithAuth( ? [...models, vertexDefaultSeed] : models ); + if (prov.adapter === "codebuddy") { + if (!apiKey) return observed(configured, "degraded"); + const resolvedProfile = resolveProfileByBaseUrl(CODEBUDDY_PROFILES, prov.baseUrl); + if (!resolvedProfile) return observed(configured, "degraded"); + const profile = resolvedProfile as CodeBuddyProfile; + // Cache reads/writes are provider/key-fingerprint-scoped: an irreversible fingerprint of + // the configured key means a key switch never reuses the roster cached for the previous + // key. The roster comes from the product configuration endpoint authenticated with that + // same key, so the fingerprint scope and the roster's authority are the same identity: the + // roster is the key's own account answer, never the CLI login's. + 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, captured.effectiveAlias), + ), "authoritative"); + } + const scopedStale = getStaleCached(name, authorityIdentity); + if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && scopedStale) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + ), "degraded"); + } + const live = await fetchCodeBuddyModels(profile, apiKey); + if (live.ok) { + const discovered = live.models.map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + })); + 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, undefined, authorityIdentity); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn(`[opencodex] CodeBuddy 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, captured.effectiveAlias) : configured, + ), "degraded"); + } if (prov.adapter === "qoder") { if (!apiKey) return observed(configured, "degraded"); const profile = resolveQoderProfile(prov.baseUrl); diff --git a/src/providers/codebuddy-models.ts b/src/providers/codebuddy-models.ts index 0cdcaa1a8c0..59e1509267d 100644 --- a/src/providers/codebuddy-models.ts +++ b/src/providers/codebuddy-models.ts @@ -7,8 +7,12 @@ * 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. + * third-party live `/v1/models` endpoint, so live discovery instead reads the key-authenticated + * product configuration roster (src/adapters/codebuddy/live-models.ts) and this static catalog is + * only the degraded seed for keys that fail to authenticate or requests that fail. The + * server-side roster can list models the manifest does not know — an account's entitlement can + * be newer than the bundled manifest — so a mismatch between this file and a live roster is + * expected, not a catalog bug. */ /** Global (`public`) session models accepted by `codebuddy --model`. */ diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index c4327df4f08..7e61738f92f 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1387,7 +1387,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://www.codebuddy.ai/profile/keys", defaultModel: "default-model", models: CODEBUDDY_GLOBAL_MODELS, - liveModels: false, + liveModels: true, modelContextWindows: CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, modelMaxOutputTokens: CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, defaultMaxOutputTokens: 32_000, @@ -1400,7 +1400,9 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // 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 (§八). + // roster differs from Global and is discovered live from the key-authenticated product + // configuration roster; the seeded list in codebuddy-models.ts is only the degraded + // fallback (§八). id: "codebuddy-cn", label: "CodeBuddy (CN)", adapter: "codebuddy", @@ -1411,7 +1413,7 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://copilot.tencent.com/profile/keys", defaultModel: "default", models: CODEBUDDY_CN_MODELS, - liveModels: false, + liveModels: true, modelContextWindows: CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, modelMaxOutputTokens: CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, defaultMaxOutputTokens: 32_000, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0fc0dcbfb27..f763d749d15 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -351,6 +351,7 @@ "codebuddy-adapter.test.ts": "providers", "codebuddy-live-acceptance.test.ts": "providers", "codebuddy-mcp-server.test.ts": "providers", + "codebuddy-live-models.test.ts": "providers", "codebuddy-protocol.test.ts": "providers", "codebuddy-tool-bridge-turn.test.ts": "providers", "codebuddy-tool-bridge.test.ts": "providers", diff --git a/tests/providers/codebuddy-live-models.test.ts b/tests/providers/codebuddy-live-models.test.ts new file mode 100644 index 00000000000..49fee3f02ae --- /dev/null +++ b/tests/providers/codebuddy-live-models.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + fetchCodeBuddyModels, + parseCodeBuddyConfigRoster, + setFetchCodeBuddyModelsForTests, +} from "../../src/adapters/codebuddy/live-models"; +import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE } from "../../src/adapters/codebuddy/profiles"; +import { gatherRoutedModels, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; +import { clearModelCache, setCached } from "../../src/codex/model-cache"; +import type { OcxConfig } from "../../src/types"; + +// Envelope captured 260923 from GET https://www.codebuddy.cn/v3/config with a valid CN key: +// data.agents[0].models is the same 17-id roster the CLI prints for --model on a signed-in +// account of that key, and data.models carries the wider per-account metadata catalog. +function authenticatedEnvelope(models: string[] = ["hy4-preview-f", "hy3", "hy3-x", "deepseek-v4.1-flash", "glm-5.3", "glm-5.3-flash", "glm-5.3-flashx", "glm-5.2", "glm-5.1", "glm-5v-turbo", "minimax-m3-pay", "minimax-m2.7", "kimi-k3-2", "kimi-k2.8-preview", "kimi-k2.7", "kimi-k2.6", "deepseek-v4-pro"]): unknown { + return { code: 0, msg: "ok", requestId: "req-test", data: { agents: [{ name: "cli", models, tools: [] }], enterpriseId: "ent", models: models.map(id => ({ id, name: id })), productFeatures: {} } }; +} + +// Envelope measured 260923 for an absent or invalid key: the anonymous config answers no +// agents array at all and an empty models list, so no roster exists to misattribute. +const ANONYMOUS_ENVELOPE: unknown = { code: 0, msg: "ok", requestId: "req-test", data: { agent: {}, models: [], mcp: {}, codebase: {}, features: {} } }; + +describe("CodeBuddy configuration-roster parser", () => { + test("parses the authenticated key's roster in order", () => { + const result = parseCodeBuddyConfigRoster(authenticatedEnvelope()); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.models).toHaveLength(17); + expect(result.models[0]).toBe("hy4-preview-f"); + expect(result.models).toContain("kimi-k3-2"); + expect(result.models).toContain("deepseek-v4.1-flash"); + } + }); + + test("filters custom selectors, blanks, and duplicates", () => { + const result = parseCodeBuddyConfigRoster(authenticatedEnvelope(["kimi-k3-2", "custom:mine", "kimi-k3-2", ""])); + expect(result.ok).toBe(true); + if (result.ok) expect(result.models).toEqual(["kimi-k3-2"]); + }); + + test("prefers the cli agent when several agents are declared", () => { + const body = { data: { agents: [ + { name: "other", models: ["other-model"] }, + { name: "cli", models: ["cli-model"] }, + ] } }; + const result = parseCodeBuddyConfigRoster(body); + expect(result.ok).toBe(true); + if (result.ok) expect(result.models).toEqual(["cli-model"]); + }); + + test("the anonymous envelope an invalid key receives fails closed as empty", () => { + const result = parseCodeBuddyConfigRoster(ANONYMOUS_ENVELOPE); + expect(result).toMatchObject({ ok: false, error: "empty" }); + if (!result.ok) expect(result.detail).toContain("anonymous"); + }); + + test("a missing data object fails closed", () => { + expect(parseCodeBuddyConfigRoster({ code: 0, msg: "ok" })).toMatchObject({ ok: false, error: "invalid_output" }); + expect(parseCodeBuddyConfigRoster(null)).toMatchObject({ ok: false, error: "invalid_output" }); + }); + + test("an authenticated envelope with an empty agent roster fails closed", () => { + expect(parseCodeBuddyConfigRoster(authenticatedEnvelope([]))).toMatchObject({ ok: false, error: "empty" }); + }); +}); + +describe("CodeBuddy live model fetch", () => { + function recordingFetch(status: number, body: unknown) { + const seen: { url: string; headers: Record }[] = []; + const fetchLike = (async (url: RequestInfo | URL, init?: RequestInit) => { + const headers: Record = {}; + for (const [key, value] of Object.entries(init?.headers ?? {})) headers[key.toLowerCase()] = String(value); + seen.push({ url: String(url), headers }); + return new Response(status === 200 ? JSON.stringify(body) : JSON.stringify(body), { status }); + }) as typeof fetch; + return { fetchLike, seen }; + } + + test("requests the region's configuration endpoint with the key and returns the roster", async () => { + const { fetchLike, seen } = recordingFetch(200, authenticatedEnvelope()); + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.models).toContain("kimi-k3-2"); + expect(seen).toHaveLength(1); + expect(seen[0]!.url).toBe("https://www.codebuddy.cn/v3/config"); + // The roster's authority is the key on the request: the header must carry it, and the + // gateway requires a CLI-shaped User-Agent before it authenticates the key at all. + expect(seen[0]!.headers["x-api-key"]).toBe("cb-cn-key"); + expect(seen[0]!.headers["user-agent"]).toMatch(/^CLI\/\d+\.\d+\.\d+ CodeBuddy\/\d+\.\d+\.\d+$/); + }); + + test("the global profile addresses the global configuration endpoint", async () => { + const { fetchLike, seen } = recordingFetch(200, authenticatedEnvelope(["glm-5.3"])); + const result = await fetchCodeBuddyModels(CODEBUDDY_GLOBAL_PROFILE, "cb-global-key", { fetch: fetchLike }); + expect(result.ok).toBe(true); + expect(seen[0]!.url).toBe("https://www.codebuddy.ai/v3/config"); + }); + + test("a non-200 answer is a clear error carrying the gateway's message", async () => { + const { fetchLike } = recordingFetch(400, { code: 12403, msg: "check ua, get coding copilot version error" }); + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result).toMatchObject({ ok: false, error: "http" }); + if (!result.ok) expect(result.detail).toContain("check ua"); + }); + + test("a timed-out request is a timeout, never a crash", async () => { + const fetchLike = (async () => { + throw Object.assign(new Error("timed out"), { name: "TimeoutError" }); + }) as typeof fetch; + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result).toMatchObject({ ok: false, error: "timeout" }); + }); + + test("a body that is not JSON fails closed as invalid output", async () => { + const fetchLike = (async () => new Response("gateway error page", { status: 200 })) as typeof fetch; + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result).toMatchObject({ ok: false, error: "invalid_output" }); + }); + + test("a chunked body that crosses the byte limit fails as too_large and cancels the stream", async () => { + // 256 KiB chunks: the third crossing chunk must cancel the reader, so a compromised + // upstream cannot keep discovery reading (or buffering) past the advertised cap. + const chunk = new Uint8Array(256 * 1024).fill(0x61); + let cancelled = false; + let pulls = 0; + const stream = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel() { cancelled = true; }, + }); + const fetchLike = (async () => new Response(stream, { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch; + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result).toMatchObject({ ok: false, error: "too_large" }); + expect(cancelled).toBe(true); + // Two chunks fit under the cap; the third is the crossing one. The stream machinery + // may prefetch one chunk ahead, so the bound is "a handful", never stream-sized. + expect(pulls).toBeLessThanOrEqual(4); + }); + + test("a declared Content-Length above the cap is refused without reading the body", async () => { + const chunk = new Uint8Array(16).fill(0x61); + let pulls = 0; + let cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel() { cancelled = true; }, + }); + const fetchLike = (async () => new Response(stream, { + status: 200, + headers: { "content-type": "application/json", "content-length": String(600 * 1024) }, + })) as typeof fetch; + const result = await fetchCodeBuddyModels(CODEBUDDY_CN_PROFILE, "cb-cn-key", { fetch: fetchLike }); + expect(result).toMatchObject({ ok: false, error: "too_large" }); + // The declared length is refused before the body is read; the wrapper's teardown may + // still cost one prefetch chunk, never the declared 600 KiB. + expect(pulls).toBeLessThanOrEqual(1); + expect(cancelled).toBe(true); + }); +}); + +describe("CodeBuddy catalog cache isolation", () => { + afterEach(() => { + setFetchCodeBuddyModelsForTests(null); + clearModelCache(); + resetCatalogRuntimeStateForTests(); + }); + + function codeBuddyConfig(apiKey: string): OcxConfig { + return { + providers: { + "codebuddy-cn": { + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.cn", + authMode: "key", + apiKey, + liveModels: true, + defaultModel: "default", + // Mirrors the registry seed: the static list ships the vendor default even though + // the key-scoped configuration roster does not list it. + models: ["default"], + }, + }, + } as unknown as OcxConfig; + } + + test("a fetch-failure cooldown for one key does not suppress another key's discovery", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + let keyBFetches = 0; + setFetchCodeBuddyModelsForTests((_profile, apiKey) => { + if (apiKey === "cb-key-a") return { ok: false, error: "http", detail: "denied" }; + keyBFetches += 1; + return { ok: true, models: ["roster-b-model"] }; + }); + + // Seed a stale (TTL-expired) roster for key B so the cooldown branch is reachable. + const identityB = createHash("sha256").update("cb-key-b").digest("hex"); + setCached("codebuddy-cn", [{ id: "roster-b-old", provider: "codebuddy-cn" }], Date.now() - 3_600_000, undefined, identityB); + + // Key A fails discovery: the cooldown must be recorded against A's fingerprint only. + const withA = await gatherRoutedModels(codeBuddyConfig("cb-key-a")); + expect(withA.filter(m => m.provider === "codebuddy-cn").map(m => m.id)).not.toContain("roster-b-model"); + + // Key B still has its own stale roster, but A's cooldown is not B's: discovery must run. + const withB = await gatherRoutedModels(codeBuddyConfig("cb-key-b")); + expect(keyBFetches).toBe(1); + expect(withB.filter(m => m.provider === "codebuddy-cn").map(m => m.id)).toContain("roster-b-model"); + } finally { + warn.mockRestore(); + } + }); + + test("a second key never receives the first key's fresh or stale roster", async () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + setFetchCodeBuddyModelsForTests((_profile, apiKey) => ( + apiKey === "cb-key-a" + ? { ok: true, models: ["roster-a-model"] } + : { ok: false, error: "http", detail: "denied" } + )); + + const first = await gatherRoutedModels(codeBuddyConfig("cb-key-a")); + const firstIds = first.filter(model => model.provider === "codebuddy-cn").map(model => model.id); + expect(firstIds).toContain("roster-a-model"); + // The vendor default is callable even though the live roster omits it. + expect(firstIds).toContain("default"); + + // Key B's fetch fails: neither the fresh nor the stale cache entry recorded for key A + // may leak into key B's catalog. + const second = await gatherRoutedModels(codeBuddyConfig("cb-key-b")); + const secondIds = second.filter(model => model.provider === "codebuddy-cn").map(model => model.id); + expect(secondIds).not.toContain("roster-a-model"); + expect(secondIds).toContain("default"); + } finally { + warn.mockRestore(); + } + }); +}); + +// The roster authority is the key on the request, so the cached roster can only ever be the +// key's own answer. The remaining cross-key guard is the cooldown/fingerprint isolation above. +test("an invalid key answers the anonymous envelope and never caches a roster", async () => { + setFetchCodeBuddyModelsForTests(() => ({ ok: false, error: "empty", detail: "anonymous" })); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + clearModelCache(); + resetCatalogRuntimeStateForTests(); + const config = { + providers: { + "codebuddy-cn": { + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.cn", + authMode: "key", + apiKey: "cb-wrong-key", + liveModels: true, + defaultModel: "default", + models: ["default"], + }, + }, + } as unknown as OcxConfig; + const models = await gatherRoutedModels(config); + const ids = models.filter(m => m.provider === "codebuddy-cn").map(m => m.id); + // Degraded to the configured selector only — no roster from any other account. + expect(ids).toEqual(["default"]); + } finally { + warn.mockRestore(); + setFetchCodeBuddyModelsForTests(null); + clearModelCache(); + resetCatalogRuntimeStateForTests(); + } +});