diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..75a5dd1cbf4 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1130,6 +1130,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa ([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment. - **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL. - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. +- **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. - **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index dc9a9f44475..e0254fbb7bd 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -52,8 +52,10 @@ const QODER_MCP_SERVER_NAME = "opencodex"; const QODER_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url)); export function buildQoderChildEnv(profile: QoderProfile, apiKey: string, systemPrompt?: string): Record { - const promptEnv = profile.region === "cn" ? "QODERCN_APPEND_SYSTEM_PROMPT" : "QODER_APPEND_SYSTEM_PROMPT"; - return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey, + // Each build reads its own brand prefix with no fallback: qodercn ignores QODER_EXPOSE_TOKEN_USAGE. + const envPrefix = profile.region === "cn" ? "QODERCN_" : "QODER_"; + const promptEnv = `${envPrefix}APPEND_SYSTEM_PROMPT`; + return { ...baseScopedEnv(), NO_COLOR: "1", [`${envPrefix}EXPOSE_TOKEN_USAGE`]: "true", [profile.tokenEnv]: apiKey, ...(systemPrompt ? { [promptEnv]: systemPrompt } : {}) }; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c522806f375..532cea1a510 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -85,7 +85,7 @@ name/namespace components and wire names over 512 UTF-8 bytes before staging. It 256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the -folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 7791824ef80..bba85c4ffff 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -458,8 +458,12 @@ 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_EXPOSE_TOKEN_USAGE).toBe("true"); expect(env.QODER_PERSONAL_ACCESS_TOKEN).toBe("qoder-pat"); - expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual(["QODER_PERSONAL_ACCESS_TOKEN"]); + expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual([ + "QODER_EXPOSE_TOKEN_USAGE", + "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(""); @@ -496,6 +500,7 @@ describe("qoder adapter", () => { expect(args.join(" ")).not.toContain(secretDeveloper); expect(args).not.toContain("--append-system-prompt-file"); expect(childEnv.QODER_APPEND_SYSTEM_PROMPT).toBe(`${secretSystem}\n\n${secretDeveloper}`); + expect(childEnv.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); }); test("never inherits an ambient Qoder prompt for either region", () => { @@ -525,8 +530,17 @@ describe("qoder adapter", () => { const globalEnv = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "global-pat"); const cnEnv = buildQoderChildEnv(QODER_CN_PROFILE, "cn-pat"); + expect(globalEnv.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(globalEnv.QODERCN_EXPOSE_TOKEN_USAGE).toBeUndefined(); expect(globalEnv.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); expect(globalEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + // The CN build derives every variable from a QODERCN_ prefix and has no QODER_ fallback for this switch. + expect(cnEnv.QODERCN_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(cnEnv.QODER_EXPOSE_TOKEN_USAGE).toBeUndefined(); + expect(Object.keys(cnEnv).filter(key => key.startsWith("QODER"))).toEqual([ + "QODERCN_EXPOSE_TOKEN_USAGE", + "QODERCN_PERSONAL_ACCESS_TOKEN", + ]); expect(cnEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); expect(cnEnv.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); @@ -548,8 +562,11 @@ describe("qoder adapter", () => { 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_EXPOSE_TOKEN_USAGE).toBe("true"); expect(global.env.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); expect(global.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cn.env.QODERCN_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(cn.env.QODER_EXPOSE_TOKEN_USAGE).toBeUndefined(); expect(cn.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); expect(cn.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); });