Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion docs/qoder-cli-provider.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: <https://docs.qoder.cn/cli/installation>
- PAT authentication: <https://docs.qoder.cn/en/cli/authentication>
- Headless scripts: <https://docs.qoder.cn/en/cli/run-in-scripts>
- SDK authentication: <https://docs.qoder.cn/en/cli/sdk/authentication>
- SDK quick start: <https://docs.qoder.cn/cli/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: <https://docs.qoder.com/cli/installation>
Expand Down
14 changes: 13 additions & 1 deletion src/adapters/qoder/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
16 changes: 14 additions & 2 deletions src/providers/free-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderAccessGroup, readonly string[]>;
Expand Down Expand Up @@ -152,6 +152,18 @@ const CONNECTABLE: Record<string, ConnectableOverride> = {
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" }),
Expand All @@ -172,7 +184,7 @@ const LABELS: Record<string, string> = {
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.";
Expand Down
9 changes: 9 additions & 0 deletions src/providers/qoder-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
20 changes: 19 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Qoder CN inert until provider evidence is complete

When this canonical registry row is enabled, users can send a PAT to a new credential destination, but the evidence added in docs/qoder-cli-provider.md only records a model-list capture, a zero-credit turn, and CLI/authentication documentation; it provides neither Qoder CN terms and operating legal entity nor a named maintenance owner. MAINTAINERS.md:70-78 requires both before promoting a new provider preset and directs incomplete entries to remain inert in free-directory.ts, so record that evidence and the required security review before exposing this registry entry, or keep it reference-only.

AGENTS.md reference: AGENTS.md:L342-L344

Useful? React with 👍 / 👎.

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
Expand Down
16 changes: 16 additions & 0 deletions tests/providers/provider-connection-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
3 changes: 2 additions & 1 deletion tests/providers/provider-registry-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 37 additions & 1 deletion tests/providers/qoder-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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([]); } });
Expand Down
17 changes: 16 additions & 1 deletion tests/providers/qoder-live-models.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string> } | 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;
Expand Down
Loading