Skip to content

Commit 34ea3b5

Browse files
committed
feat(provider): add Qoder CN OAuth provider and pure in-memory streaming adapter
- Support PKCE S256 OAuth device authorization grant and automated token refresh in src/oauth/qodercn.ts - Provide Qoder CN official model catalogue and modelMap translation in src/providers/registry.ts - Implement pure in-memory WASM signing and direct HTTPS streaming adapter in src/adapters/qodercn.ts with intelligent tool argument normalization and pseudo-XML tool call parsing - Add comprehensive test coverage in tests/qodercn-adapter.test.ts and tests/qodercn-oauth.test.ts
1 parent 293a2b8 commit 34ea3b5

10 files changed

Lines changed: 1051 additions & 1 deletion

File tree

src/adapters/qodercn.ts

Lines changed: 650 additions & 0 deletions
Large diffs are not rendered by default.

src/adapters/registry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createQoderCnAdapter } from "./qodercn";
12
import { createAnthropicAdapter } from "./anthropic";
23
import { createAzureAdapter } from "./azure";
34
import type { ProviderAdapter } from "./base";
@@ -27,7 +28,8 @@ export type AdapterWire =
2728
| "openai-responses"
2829
| "google"
2930
| "kiro"
30-
| "cursor";
31+
| "cursor"
32+
| "qodercn";
3133

3234
export type AdapterMutationContract =
3335
| "codex-owned"
@@ -99,6 +101,11 @@ export const ADAPTER_REGISTRY = {
99101
contractParent: "openai-responses",
100102
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createAzureAdapter(provider),
101103
},
104+
qodercn: {
105+
wire: "qodercn",
106+
mutation: "codex-owned",
107+
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderCnAdapter(provider),
108+
},
102109
cursor: {
103110
wire: "cursor",
104111
mutation: "codex-owned-with-gated-native-fallback",

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ const providerConfigSchema = z.object({
502502
baseUrl: z.string().min(1),
503503
alias: z.string().optional(),
504504
modelAliases: z.record(z.string(), z.string()).optional(),
505+
modelMap: z.record(z.string(), z.string()).optional(),
505506
defaultAliases: z.boolean().optional(),
506507
requestPacing: requestPacingSchema.optional().catch(undefined),
507508
mcpMaxTools: z.number().int().positive().optional(),

src/oauth/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { loginQoderCn, refreshQoderCnToken } from "./qodercn";
12
import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
23
import { parseCallbackInput } from "./callback-server";
34
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
@@ -245,6 +246,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
245246
// Unofficial Copilot bridge — keep proactive traffic lazy-only (no background guardian spam).
246247
defaultRefreshPolicy: "lazy-only",
247248
},
249+
qodercn: {
250+
login: (ctrl) => loginQoderCn(ctrl),
251+
refresh: (rt, signal) => refreshQoderCnToken(rt, signal),
252+
providerConfig: oauthConfig("qodercn"),
253+
defaultModel: oauthDefaultModel("qodercn"),
254+
},
248255
chatgpt: {
249256
login: loginChatGPT,
250257
refresh: (rt) => refreshChatGPTToken(rt),

src/oauth/qodercn.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Qoder CN OAuth flow (device authorization grant with PKCE).
3+
*/
4+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5+
import { homedir } from "node:os";
6+
import { join } from "node:path";
7+
import { randomUUID } from "node:crypto";
8+
import { getConfigDir } from "../config";
9+
import { recordOwnedConfigPath } from "../lib/config-ownership";
10+
import { generatePKCE } from "./pkce";
11+
import type { OAuthController, OAuthCredentials } from "./types";
12+
13+
const CLIENT_ID = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb";
14+
const DEFAULT_OPENAPI_HOST = "https://openapi.qoder.com.cn";
15+
const DEFAULT_AUTH_HOST = "https://qoder.cn";
16+
const MACHINE_ID_FILENAME = "qodercn-machine-id";
17+
const POLL_INTERVAL_MS = 1500;
18+
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
19+
const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000;
20+
21+
interface QoderDevicePollResponse {
22+
token?: string;
23+
device_token?: string;
24+
refresh_token?: string;
25+
expires_at?: string;
26+
expires_in?: number;
27+
refresh_token_expires_at?: string;
28+
refresh_token_expires_in?: number;
29+
user_id?: string;
30+
user_name?: string;
31+
email?: string;
32+
}
33+
34+
interface QoderTokenRefreshResponse {
35+
device_token?: string;
36+
token?: string;
37+
refresh_token?: string;
38+
expires_at?: string;
39+
expires_in?: number;
40+
}
41+
42+
export function getMachineId(): string {
43+
const p = join(getConfigDir(), MACHINE_ID_FILENAME);
44+
try {
45+
if (existsSync(p)) {
46+
const id = readFileSync(p, "utf-8").trim();
47+
if (id) return id;
48+
}
49+
} catch (e) {
50+
if ((e as { code?: string })?.code !== "ENOENT") throw e;
51+
}
52+
const id = randomUUID();
53+
recordOwnedConfigPath(getConfigDir(), p);
54+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
55+
writeFileSync(p, id + "\n", { mode: 0o600 });
56+
return id;
57+
}
58+
59+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
60+
return new Promise((resolve, reject) => {
61+
if (signal?.aborted) return reject(new Error("Login cancelled"));
62+
const t = setTimeout(resolve, ms);
63+
signal?.addEventListener("abort", () => {
64+
clearTimeout(t);
65+
reject(new Error("Login cancelled"));
66+
}, { once: true });
67+
});
68+
}
69+
70+
async function pollForToken(nonce: string, verifier: string, signal?: AbortSignal): Promise<OAuthCredentials> {
71+
const deadline = Date.now() + POLL_TIMEOUT_MS;
72+
const search = new URLSearchParams({
73+
nonce,
74+
verifier,
75+
challenge_method: "S256",
76+
});
77+
const url = `${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/poll?${search}`;
78+
79+
while (Date.now() < deadline) {
80+
if (signal?.aborted) throw new Error("Login cancelled");
81+
const res = await fetch(url, {
82+
method: "GET",
83+
headers: { Accept: "application/json" },
84+
signal,
85+
});
86+
if (res.status === 404) {
87+
await sleep(POLL_INTERVAL_MS, signal);
88+
continue;
89+
}
90+
if (!res.ok) {
91+
throw new Error(`Qoder device token poll failed: HTTP ${res.status}`);
92+
}
93+
const data = (await res.json()) as QoderDevicePollResponse;
94+
const token = data.token || data.device_token;
95+
if (!token) throw new Error("Qoder poll response missing token");
96+
97+
let expires = Date.now() + 24 * 3600 * 1000;
98+
if (typeof data.expires_at === "string") {
99+
const parsed = new Date(data.expires_at).getTime();
100+
if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS;
101+
} else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) {
102+
expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS;
103+
}
104+
105+
const accountId = data.user_id;
106+
const email = data.user_name || data.email;
107+
108+
return {
109+
access: token,
110+
refresh: data.refresh_token || token,
111+
expires,
112+
...(accountId ? { accountId } : {}),
113+
...(email ? { email } : {}),
114+
source: "oauth",
115+
};
116+
}
117+
throw new Error("Qoder CN device authorization timed out");
118+
}
119+
120+
export async function loginQoderCn(ctrl: OAuthController): Promise<OAuthCredentials> {
121+
const { verifier, challenge } = generatePKCE();
122+
const nonce = randomUUID();
123+
const machineId = getMachineId();
124+
const authUrl = `${DEFAULT_AUTH_HOST}/device/selectAccounts?challenge=${challenge}&challenge_method=S256&nonce=${nonce}&machine_id=${machineId}&client_id=${CLIENT_ID}`;
125+
126+
ctrl.onAuth?.({
127+
url: authUrl,
128+
instructions: "Please complete the login in your browser",
129+
});
130+
131+
return pollForToken(nonce, verifier, ctrl.signal);
132+
}
133+
134+
export async function refreshQoderCnToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
135+
const res = await fetch(`${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/refresh`, {
136+
method: "POST",
137+
headers: {
138+
"Content-Type": "application/json",
139+
Accept: "application/json",
140+
},
141+
body: JSON.stringify({ refresh_token: refreshToken }),
142+
signal,
143+
});
144+
if (!res.ok) {
145+
throw new Error(`Qoder token refresh failed: HTTP ${res.status}`);
146+
}
147+
const data = (await res.json()) as QoderTokenRefreshResponse;
148+
const token = data.device_token || data.token;
149+
if (!token) throw new Error("Qoder refresh response missing token");
150+
let expires = Date.now() + 24 * 3600 * 1000;
151+
if (typeof data.expires_at === "string") {
152+
const parsed = new Date(data.expires_at).getTime();
153+
if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS;
154+
} else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) {
155+
expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS;
156+
}
157+
return { access: token, refresh: data.refresh_token || refreshToken, expires, source: "oauth" };
158+
}
159+
160+
export function resolveQoderAccountContext(token: string): { machineId: string; accountId: string } {
161+
const machineId = getMachineId();
162+
let accountId = "";
163+
try {
164+
const authPath = join(getConfigDir(), "auth.json");
165+
if (existsSync(authPath)) {
166+
const auth = JSON.parse(readFileSync(authPath, "utf-8"));
167+
const accounts = auth.qodercn?.accounts || [];
168+
const match = accounts.find((a: any) => a.credential?.access === token);
169+
if (match?.credential?.accountId) {
170+
accountId = match.credential.accountId;
171+
} else if (accounts[0]?.credential?.accountId) {
172+
accountId = accounts[0].credential.accountId;
173+
}
174+
}
175+
} catch (_e) {
176+
void _e;
177+
}
178+
return { machineId, accountId: accountId || "default-user" };
179+
}

src/providers/derive.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
224224
...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}),
225225
...(entry.freeTier !== undefined ? { freeTier: entry.freeTier } : {}),
226226
...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}),
227+
...(entry.modelMap ? { modelMap: { ...entry.modelMap } } : {}),
227228
...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}),
228229
...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}),
229230
...(entry.models ? { models: [...entry.models] } : {}),

src/providers/registry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export interface ProviderRegistryEntry {
156156
/** Static headers merged into every upstream request for this provider. */
157157
staticHeaders?: Record<string, string>;
158158
modelSuffixBracketStrip?: boolean;
159+
modelMap?: Record<string, string>;
159160
featured?: boolean;
160161
dashboardPreset?: boolean;
161162
note?: string;
@@ -1308,6 +1309,30 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
13081309
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
13091310
defaultModel: "claude-sonnet-5",
13101311
},
1312+
{
1313+
id: "qodercn",
1314+
label: "Qoder CN",
1315+
adapter: "qodercn",
1316+
baseUrl: "https://gateway.qoder.com.cn",
1317+
authKind: "oauth",
1318+
oauthId: "qodercn",
1319+
featured: false,
1320+
liveModels: false,
1321+
note: "Log in with your Qoder CN account",
1322+
defaultModel: "GLM-5.3-Flash",
1323+
models: ["GLM-5.3-Flash", "GLM-5.3", "GLM-5.2", "Qwen3.8-Flash", "Qwen3.8-Max", "Qwen3.7-Max", "Qwen3.7-Plus", "Qwen3.5-Plus", "DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "Kimi-K3", "Kimi-K2.7-Code", "MiniMax-M3", "Cantus", "Auto"],
1324+
modelMap: {
1325+
"GLM-5.3-Flash": "gfmodel", "GLM-5.3": "gmodel", "GLM-5.2": "gm51model",
1326+
"Qwen3.8-Flash": "qfmodel", "Qwen3.8-Max": "qmodel_38max", "Qwen3.7-Max": "qmodel_latest", "Qwen3.7-Plus": "qmodel", "Qwen3.5-Plus": "q35model",
1327+
"DeepSeek-V4-Flash": "dfmodel", "DeepSeek-V4-Pro": "dmodel",
1328+
"Kimi-K3": "kmodel_latest", "Kimi-K2.7-Code": "kmodel",
1329+
"MiniMax-M3": "mmodel", "Cantus": "cmodel", "Auto": "auto"
1330+
},
1331+
modelContextWindows: { "GLM-5.3-Flash": 1000000, "GLM-5.3": 1000000, "GLM-5.2": 1000000, "Qwen3.8-Flash": 1000000, "Qwen3.8-Max": 1000000, "Qwen3.7-Max": 1000000, "Qwen3.7-Plus": 1000000, "Qwen3.5-Plus": 1000000, "DeepSeek-V4-Flash": 1000000, "DeepSeek-V4-Pro": 1000000, "Kimi-K3": 262144, "Kimi-K2.7-Code": 262144, "MiniMax-M3": 262144, "Cantus": 128000, "Auto": 1000000 },
1332+
preserveReasoningContentModels: ["GLM-5.3-Flash", "GLM-5.3", "GLM-5.2", "Qwen3.8-Flash", "Qwen3.8-Max", "Qwen3.7-Max", "Qwen3.7-Plus", "DeepSeek-V4-Flash", "DeepSeek-V4-Pro", "Kimi-K3"],
1333+
modelReasoningEfforts: { "GLM-5.3-Flash": ["low", "high", "max"], "GLM-5.3": ["low", "high", "max"], "GLM-5.2": ["low", "medium", "high", "xhigh", "max"], "Qwen3.8-Flash": ["low", "medium", "high", "xhigh", "max"], "Qwen3.8-Max": ["low", "medium", "xhigh"], "Qwen3.7-Max": ["low", "medium", "high", "xhigh", "max"], "Qwen3.7-Plus": ["low", "medium", "high", "xhigh", "max"], "DeepSeek-V4-Flash": ["low", "high", "max"], "DeepSeek-V4-Pro": ["low", "high", "max"], "Kimi-K3": ["low", "high", "max"] },
1334+
defaultMaxOutputTokens: 64000
1335+
},
13111336
{
13121337
id: "kimi",
13131338
label: "Kimi",

src/types/provider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ export interface OcxProviderConfig {
168168
alias?: string;
169169
/** Native model id -> short, slash-free request alias. */
170170
modelAliases?: Record<string, string>;
171+
/** Native model id -> upstream wire model id mapping. */
172+
modelMap?: Record<string, string>;
171173
/** Override the global built-in model-alias switch for this provider. */
172174
defaultAliases?: boolean;
173175
adapter: string;

0 commit comments

Comments
 (0)