Skip to content

Commit 023b316

Browse files
committed
feat(provider): address maintainer review - tighten WASM provenance, origin security, and protocol fidelity
- Document official WASM binary provenance (package, SHA256 checksum) in src/adapters/qodercn.ts - Enforce strict HTTPS gateway origin allowlist guard (https://gateway.qoder.com.cn) and add negative security tests - Support structured message multimodal images and tool definition/call formatting - Parse authentic stream token usage and finish reasons from SSE response payload - Thread credential from OpenCodex auth context directly instead of reading disk stores - Handle relative expires_in alongside absolute expires_at in token refresh
1 parent df8b388 commit 023b316

10 files changed

Lines changed: 908 additions & 1 deletion

File tree

src/adapters/qodercn.ts

Lines changed: 541 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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
function getMachineId(): string {
43+
const cliPath = join(homedir(), ".qoder-cn", ".auth", "machine_id");
44+
try {
45+
if (existsSync(cliPath)) {
46+
const id = readFileSync(cliPath, "utf-8").trim();
47+
if (id) return id;
48+
}
49+
} catch (_err) {
50+
// Fall back to generating a machine id if unreadable.
51+
}
52+
const p = join(getConfigDir(), MACHINE_ID_FILENAME);
53+
try {
54+
if (existsSync(p)) {
55+
const id = readFileSync(p, "utf-8").trim();
56+
if (id) return id;
57+
}
58+
} catch (e) {
59+
if ((e as { code?: string })?.code !== "ENOENT") throw e;
60+
}
61+
const id = randomUUID();
62+
recordOwnedConfigPath(getConfigDir(), p);
63+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
64+
writeFileSync(p, id + "
65+
", { mode: 0o600 });
66+
return id;
67+
}
68+
69+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
70+
return new Promise((resolve, reject) => {
71+
if (signal?.aborted) return reject(new Error("Login cancelled"));
72+
const t = setTimeout(resolve, ms);
73+
signal?.addEventListener("abort", () => {
74+
clearTimeout(t);
75+
reject(new Error("Login cancelled"));
76+
}, { once: true });
77+
});
78+
}
79+
80+
async function pollForToken(nonce: string, verifier: string, signal?: AbortSignal): Promise<OAuthCredentials> {
81+
const deadline = Date.now() + POLL_TIMEOUT_MS;
82+
const search = new URLSearchParams({
83+
nonce,
84+
verifier,
85+
challenge_method: "S256",
86+
});
87+
const url = `${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/poll?${search}`;
88+
89+
while (Date.now() < deadline) {
90+
if (signal?.aborted) throw new Error("Login cancelled");
91+
const res = await fetch(url, {
92+
method: "GET",
93+
headers: { Accept: "application/json" },
94+
signal,
95+
});
96+
if (res.status === 404) {
97+
await sleep(POLL_INTERVAL_MS, signal);
98+
continue;
99+
}
100+
if (!res.ok) {
101+
throw new Error(`Qoder device token poll failed: HTTP ${res.status}`);
102+
}
103+
const data = (await res.json()) as QoderDevicePollResponse;
104+
const token = data.token || data.device_token;
105+
if (!token) throw new Error("Qoder poll response missing token");
106+
107+
let expires = Date.now() + 24 * 3600 * 1000;
108+
if (typeof data.expires_at === "string") {
109+
const parsed = new Date(data.expires_at).getTime();
110+
if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS;
111+
} else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) {
112+
expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS;
113+
}
114+
115+
const accountId = data.user_id;
116+
const email = data.user_name || data.email;
117+
118+
return {
119+
access: token,
120+
refresh: data.refresh_token || token,
121+
expires,
122+
...(accountId ? { accountId } : {}),
123+
...(email ? { email } : {}),
124+
source: "oauth",
125+
};
126+
}
127+
throw new Error("Qoder CN device authorization timed out");
128+
}
129+
130+
export async function loginQoderCn(ctrl: OAuthController): Promise<OAuthCredentials> {
131+
const { verifier, challenge } = generatePKCE();
132+
const nonce = randomUUID();
133+
const machineId = getMachineId();
134+
const authUrl = `${DEFAULT_AUTH_HOST}/device/selectAccounts?challenge=${challenge}&challenge_method=S256&nonce=${nonce}&machine_id=${machineId}&client_id=${CLIENT_ID}`;
135+
136+
ctrl.onAuth?.({
137+
url: authUrl,
138+
instructions: "Please complete the login in your browser",
139+
});
140+
141+
return pollForToken(nonce, verifier, ctrl.signal);
142+
}
143+
144+
export async function refreshQoderCnToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
145+
const res = await fetch(`${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/refresh`, {
146+
method: "POST",
147+
headers: {
148+
"Content-Type": "application/json",
149+
Accept: "application/json",
150+
},
151+
body: JSON.stringify({ refresh_token: refreshToken }),
152+
signal,
153+
});
154+
if (!res.ok) {
155+
throw new Error(`Qoder token refresh failed: HTTP ${res.status}`);
156+
}
157+
const data = (await res.json()) as QoderTokenRefreshResponse;
158+
const token = data.device_token || data.token;
159+
if (!token) throw new Error("Qoder refresh response missing token");
160+
let expires = Date.now() + 24 * 3600 * 1000;
161+
if (typeof data.expires_at === "string") {
162+
const parsed = new Date(data.expires_at).getTime();
163+
if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS;
164+
} else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) {
165+
expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS;
166+
}
167+
return { access: token, refresh: data.refresh_token || refreshToken, expires, source: "oauth" };
168+
}

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: 89 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,94 @@ 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://api2-v2.qoder.sh/model/v1",
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: [
1324+
"GLM-5.3-Flash",
1325+
"GLM-5.3",
1326+
"GLM-5.2",
1327+
"Qwen3.8-Flash",
1328+
"Qwen3.8-Max",
1329+
"Qwen3.7-Max",
1330+
"Qwen3.7-Plus",
1331+
"Qwen3.5-Plus",
1332+
"DeepSeek-V4-Flash",
1333+
"DeepSeek-V4-Pro",
1334+
"Kimi-K3",
1335+
"Kimi-K2.7-Code",
1336+
"MiniMax-M3",
1337+
"Cantus",
1338+
"Auto",
1339+
],
1340+
modelMap: {
1341+
"GLM-5.3-Flash": "gfmodel",
1342+
"GLM-5.3": "gmodel",
1343+
"GLM-5.2": "gm51model",
1344+
"Qwen3.8-Flash": "qfmodel",
1345+
"Qwen3.8-Max": "qmodel_38max",
1346+
"Qwen3.7-Max": "qmodel_latest",
1347+
"Qwen3.7-Plus": "qmodel",
1348+
"Qwen3.5-Plus": "q35model",
1349+
"DeepSeek-V4-Flash": "dfmodel",
1350+
"DeepSeek-V4-Pro": "dmodel",
1351+
"Kimi-K3": "kmodel_latest",
1352+
"Kimi-K2.7-Code": "kmodel",
1353+
"MiniMax-M3": "mmodel",
1354+
"Cantus": "cmodel",
1355+
"Auto": "auto",
1356+
},
1357+
modelContextWindows: {
1358+
"GLM-5.3-Flash": 1_000_000,
1359+
"GLM-5.3": 1_000_000,
1360+
"GLM-5.2": 1_000_000,
1361+
"Qwen3.8-Flash": 1_000_000,
1362+
"Qwen3.8-Max": 1_000_000,
1363+
"Qwen3.7-Max": 1_000_000,
1364+
"Qwen3.7-Plus": 1_000_000,
1365+
"Qwen3.5-Plus": 1_000_000,
1366+
"DeepSeek-V4-Flash": 1_000_000,
1367+
"DeepSeek-V4-Pro": 1_000_000,
1368+
"Kimi-K3": 262_144,
1369+
"Kimi-K2.7-Code": 262_144,
1370+
"MiniMax-M3": 262_144,
1371+
"Cantus": 128_000,
1372+
"Auto": 1_000_000,
1373+
},
1374+
preserveReasoningContentModels: [
1375+
"GLM-5.3-Flash",
1376+
"GLM-5.3",
1377+
"GLM-5.2",
1378+
"Qwen3.8-Flash",
1379+
"Qwen3.8-Max",
1380+
"Qwen3.7-Max",
1381+
"Qwen3.7-Plus",
1382+
"DeepSeek-V4-Flash",
1383+
"DeepSeek-V4-Pro",
1384+
"Kimi-K3",
1385+
],
1386+
modelReasoningEfforts: {
1387+
"GLM-5.3-Flash": ["low", "high", "max"],
1388+
"GLM-5.3": ["low", "high", "max"],
1389+
"GLM-5.2": ["low", "medium", "high", "xhigh", "max"],
1390+
"Qwen3.8-Flash": ["low", "medium", "high", "xhigh", "max"],
1391+
"Qwen3.8-Max": ["low", "medium", "xhigh"],
1392+
"Qwen3.7-Max": ["low", "medium", "high", "xhigh", "max"],
1393+
"Qwen3.7-Plus": ["low", "medium", "high", "xhigh", "max"],
1394+
"DeepSeek-V4-Flash": ["low", "high", "max"],
1395+
"DeepSeek-V4-Pro": ["low", "high", "max"],
1396+
"Kimi-K3": ["low", "high", "max"],
1397+
},
1398+
defaultMaxOutputTokens: 64_000,
1399+
},
13111400
{
13121401
id: "kimi",
13131402
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)