From 615c5c62c59af28a19e948977bd12d47bd21ec01 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] feat(provider): add Qoder CN PAT provider (cherry picked from commit a4e805084de48a5121c5c4d0e3973a3491d6d302) --- 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 +++++++++- .../provider-connection-test.test.ts | 16 ++++++++ .../provider-registry-parity.test.ts | 3 +- tests/providers/qoder-adapter.test.ts | 38 ++++++++++++++++++- tests/providers/qoder-live-models.test.ts | 17 ++++++++- 10 files changed, 158 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7b2fc0b7f5..ce0e6daef2 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,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 d2a38de446..6d84efe529 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"; @@ -3174,6 +3174,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/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts index 2ce2df35e7..bbadd8c442 100644 --- a/tests/providers/provider-connection-test.test.ts +++ b/tests/providers/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/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index d4802df497..009aec37d9 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -38,7 +38,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", () => { @@ -1259,6 +1259,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/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 3b3271fd69..5411504354 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/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/providers/qoder-live-models.test.ts b/tests/providers/qoder-live-models.test.ts index 7f63cd28f7..ba00e378e7 100644 --- a/tests/providers/qoder-live-models.test.ts +++ b/tests/providers/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;