cartethyia 2.7.1 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-XaUmiBrC.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-CVe6RaBH.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-Dd8ZSamp.js → CliToolsPage-0XUo4UMn.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-CXGS_Ct-.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-DGnGNYnM.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-CKUFTcW4.js → CustomizationPage-DAG5J9EY.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-CzSLiDzr.js → HealthEventsModal-BKtAPYVF.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-BdmRLnq2.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-DuWzAQFL.js → OverviewPage-CqMxibJB.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-Bwotu8UJ.js → ProvidersPage-B9uWu4Nz.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js → ProxyPage-BoWOhpDO.js} +1 -1
  24. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js.gz → ProxyPage-BoWOhpDO.js.gz} +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-7P2n--Fv.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  27. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CxZ1fTkN.js} +1 -1
  28. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-BdgECjqq.js → SetupPage-Co12Zzaw.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-Dqf4zpS-.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-Byj2lAEm.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-CTkLAuRc.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-ClCMCWWQ.js} +3 -3
  38. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  39. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-CWWZ-uCZ.js} +1 -1
  40. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  41. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-DFVFrnWW.js} +2 -2
  42. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  43. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-DBNVLdUu.js} +1 -1
  44. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  45. package/dist/dashboard/index.html +1 -1
  46. package/dist/dashboard/index.html.gz +0 -0
  47. package/dist/dashboard/precompressed-assets.json +22 -22
  48. package/package.json +1 -1
  49. package/src/console/cli-tools/contracts.ts +10 -4
  50. package/src/network/outbound-fetch.ts +281 -226
  51. package/src/network/pool/agent.ts +7 -0
  52. package/src/providers/default-registry.ts +1 -0
  53. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  54. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  55. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  56. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  57. package/src/providers/integrations/qoder/qoder.ts +1373 -1641
  58. package/src/providers/operations/account-error-classification.ts +1684 -1660
  59. package/src/providers/operations/account-health-service.ts +621 -621
  60. package/src/providers/operations/provider-catalog-service.ts +11 -1
  61. package/src/providers/provider-contracts.ts +7 -2
  62. package/src/security/cli-client-fingerprint.ts +9 -2
  63. package/src/transport/dispatch/empty-stream.ts +224 -224
  64. package/src/transport/failure-policy.ts +33 -0
  65. package/src/transport/routing/route-catalog.ts +34 -12
  66. package/src/transport/routing/router.ts +28 -0
  67. package/src/transport/surface/responses/encode.ts +2 -3
  68. package/src/transport/surface/responses/parse.ts +49 -7
  69. package/src/types/undici.d.ts +33 -0
  70. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  71. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  72. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  73. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  74. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  75. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  76. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  77. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  78. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  79. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  80. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  81. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  82. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  83. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  84. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  85. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  86. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  87. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  88. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  89. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  90. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  91. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
@@ -1,1641 +1,1373 @@
1
- // Factory-blocked (Phase C4): PAT-to-jobToken session handshake, COSY
2
- // AES/RSA request signing, alphabet-scrambled binary body, enveloped SSE
3
- // frames — session/crypto protocol outside factory reach; stays bespoke.
4
- import { constants, createCipheriv, createHash, publicEncrypt, randomUUID } from "node:crypto";
5
- import {
6
- classifyUpstreamError,
7
- extractUpstreamMessage,
8
- isUpstreamQueued,
9
- mapUpstreamHttpError,
10
- queueRetryAfterMs,
11
- upstreamErrorIdentifier,
12
- } from "../../../transport/failure-policy";
13
- import { GatewayError } from "../../../protocol/gateway-error";
14
- import { resolveImageSource } from "../../../protocol/codecs";
15
- import type {
16
- CanonicalEvent,
17
- CanonicalRequest,
18
- CanonicalStopReason,
19
- ContentPart,
20
- ToolChoice,
21
- ToolDefinition,
22
- } from "../../../protocol/canonical-model";
23
- import { toolResultParts } from "../../../protocol/canonical-model";
24
- import { decodeSseEvents } from "../../../protocol/sse-decoder";
25
- import { usageFromProvider, readReasoningText } from "../../provider-usage";
26
- import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
27
- import type { ModelDefinition } from "../../provider-model-definition";
28
- import { parseQoderOAuthState } from "./qoder-oauth";
29
- import { getQoderVersion } from "../../operations/client-versions";
30
- import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
31
- import { qoderMachineOs } from "../../operations/cli-platform";
32
- import { qoderGatewayCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
33
- import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
34
- import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
35
-
36
- interface QoderModeProfile {
37
- readonly chatUrl: string;
38
- readonly businessProduct: string;
39
- readonly businessType: string;
40
- readonly businessVersion: string;
41
- readonly cosyScene: string;
42
- readonly mirrorTopLevelSystem: boolean;
43
- readonly sendBusinessHeaders: boolean;
44
- readonly sendModelSourceHeaders: boolean;
45
- readonly emptyAliyunUserType: boolean;
46
- }
47
-
48
- export const MODERN_PROFILE: QoderModeProfile = {
49
- chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
50
- businessProduct: "cli",
51
- businessType: "agent",
52
- businessVersion: "1.0.22",
53
- cosyScene: "assistant",
54
- mirrorTopLevelSystem: true,
55
- sendBusinessHeaders: true,
56
- sendModelSourceHeaders: true,
57
- emptyAliyunUserType: true,
58
- };
59
-
60
- const QODER_PROVIDER_ID = "qoder" as const;
61
-
62
- export const QODER_MODEL_CONFIGS: Record<
63
- string,
64
- { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
65
- > = {
66
- auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
67
- ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
68
- performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
69
- efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
70
- lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
71
- qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
72
- qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
73
- qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
74
- dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
75
- dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
76
- gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
77
- kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
78
- mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
79
- kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
80
- };
81
-
82
- const QODER_APPCODE = "cosy";
83
- const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
84
- /**
85
- * Where a PAT is exchanged for a short-lived job token, per realm.
86
- *
87
- * The job token is realm-bound: one minted on the global host answers "Login
88
- * expired" on the China gateway. Exchanging against the wrong host therefore
89
- * looks like a dead credential rather than a routing bug, which is why the
90
- * URL is derived from the realm table instead of being one constant.
91
- *
92
- * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
93
- * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
94
- * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
95
- * silently misrouted request.
96
- */
97
- const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
98
- function qoderJobTokenUrl(realm: QoderRealm): string {
99
- return realm === "cn"
100
- ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
101
- : QODER_JOB_TOKEN_URL;
102
- }
103
- const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
104
- const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
105
- const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
106
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
107
- 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
108
- 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
109
- XcW+ML9FoCI6AOvOzwIDAQAB
110
- -----END PUBLIC KEY-----`;
111
-
112
- export interface QoderAuth {
113
- userId: string;
114
- userName: string;
115
- userType: string;
116
- securityOauthToken: string;
117
- refreshToken: string;
118
- machineId: string;
119
- }
120
- interface QoderModelConfig {
121
- id: string;
122
- display_name: string;
123
- max_input_tokens: number;
124
- max_output_tokens?: number;
125
- is_vl: boolean;
126
- is_reasoning: boolean;
127
- source?: string;
128
- }
129
-
130
-
131
- function qoderMd5(value: string | Uint8Array): string {
132
- return createHash("md5").update(value).digest("hex");
133
- }
134
- function qoderPathSignature(url: string): string {
135
- const path = new URL(url).pathname;
136
- return path.startsWith("/algo") ? path.slice(5) : path;
137
- }
138
-
139
- const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
140
- const QODER_MAX_MACHINE_IDS = 1_024;
141
- const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
142
- /**
143
- * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
144
- * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
145
- * tokens (`jt-...`) are used directly.
146
- */
147
- export function isQoderPat(secret: string): boolean {
148
- return secret.startsWith("pt-");
149
- }
150
-
151
- /**
152
- * Stable per-account machine id for OAuth dispatches. Derived from the
153
- * account id (not minted per request) so pooled accounts keep one device
154
- * fingerprint across restarts instead of looking like a new device every
155
- * time, while staying distinct per account.
156
- */
157
- export function qoderMachineIdForAccount(accountId: string): string {
158
- const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
159
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
160
- }
161
-
162
- /**
163
- * Builds dispatch auth from an OAuth device token. The token is used
164
- * directly as the COSY identity's oauth token; the user id and machine id
165
- * come from the `auth_state` the login persisted. Fails closed with a
166
- * reconnect prompt when the identity is absent (e.g. an account created
167
- * before `auth_state` existed) instead of signing with a guessed id.
168
- */
169
- export function qoderOAuthAuth(
170
- secret: string,
171
- authState: unknown,
172
- accountId: string | undefined,
173
- ): QoderAuth {
174
- const state = parseQoderOAuthState(authState);
175
- if (state === undefined || !state.userId) {
176
- throw new GatewayError(
177
- "authentication_failed",
178
- 401,
179
- "Qoder OAuth account is missing its user identity; sign in again",
180
- );
181
- }
182
- return {
183
- userId: state.userId,
184
- userName: "",
185
- userType: "personal_standard",
186
- securityOauthToken: secret,
187
- refreshToken: "",
188
- machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
189
- };
190
- }
191
-
192
- function qoderMachineIdFromPat(pat: string): string {
193
- const key = createHash("sha256").update(pat).digest("hex");
194
- const now = Date.now();
195
- const existing = qoderMachineIds.get(key);
196
- if (existing && existing.expiresAt > now) return existing.id;
197
- const id = randomUUID();
198
- qoderMachineIds.delete(key);
199
- qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
200
- for (const [candidate, value] of qoderMachineIds) {
201
- if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
202
- }
203
- while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
204
- const oldest = qoderMachineIds.keys().next();
205
- if (oldest.done) break;
206
- qoderMachineIds.delete(oldest.value);
207
- }
208
- return id;
209
- }
210
-
211
- function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
212
- const date = new Date().toUTCString();
213
- return {
214
- "cosy-machinetoken": machineId,
215
- "cosy-machinetype": "5",
216
- "cosy-machineid": machineId,
217
- "cosy-clienttype": "5",
218
- "cosy-version": version,
219
- "login-version": "v2",
220
- appcode: QODER_APPCODE,
221
- accept: "application/json",
222
- "accept-encoding": "identity",
223
- date,
224
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
225
- "content-type": "application/json",
226
- "user-agent": "Go-http-client/2.0",
227
- };
228
- }
229
-
230
- function transformQoderBody(plaintext: string): Uint8Array {
231
- const base64 = Buffer.from(plaintext).toString("base64");
232
- const third = Math.floor(base64.length / 3);
233
- const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
234
- let encoded = "";
235
- for (const char of reordered) {
236
- if (char === "=") encoded += "$";
237
- else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
238
- }
239
- return Buffer.from(encoded, "latin1");
240
- }
241
-
242
- function qoderBuildCosyHeaders(
243
- body: Uint8Array,
244
- url: string,
245
- auth: QoderAuth,
246
- version = getQoderVersion(),
247
- ): Record<string, string> {
248
- const key = randomUUID().replaceAll("-", "").slice(0, 16);
249
- const identity = JSON.stringify({
250
- name: auth.userName,
251
- aid: auth.userId,
252
- uid: auth.userId,
253
- yx_uid: "",
254
- organization_id: "",
255
- organization_name: "",
256
- user_type: auth.userType,
257
- security_oauth_token: auth.securityOauthToken,
258
- refresh_token: auth.refreshToken,
259
- });
260
- const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
261
- const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
262
- const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
263
- const payload = Buffer.from(
264
- JSON.stringify({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
265
- ).toString("base64");
266
- const date = String(Math.floor(Date.now() / 1000));
267
- const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
268
- return {
269
- authorization: `Bearer COSY.${payload}.${signature}`,
270
- "cosy-data-policy": "agree",
271
- "cosy-machinetype": "5",
272
- "cosy-machineos": qoderMachineOs(),
273
- "cosy-clienttype": "5",
274
- "cosy-date": date,
275
- "cosy-user": auth.userId,
276
- "cosy-key": cosyKey,
277
- "cosy-bodyhash": qoderMd5(body),
278
- "cosy-bodylength": String(body.byteLength),
279
- "cosy-sigpath": qoderPathSignature(url),
280
- "cosy-organization-id": "",
281
- "cosy-organization-tags": "",
282
- "x-request-id": randomUUID(),
283
- ...(MODERN_PROFILE.sendBusinessHeaders
284
- ? {
285
- "cosy-business-product": MODERN_PROFILE.businessProduct,
286
- "cosy-business-type": MODERN_PROFILE.businessType,
287
- "cosy-business-version": version,
288
- "cosy-scene": MODERN_PROFILE.cosyScene,
289
- }
290
- : {}),
291
- "cosy-version": version,
292
- "login-version": "v2",
293
- appcode: QODER_APPCODE,
294
- accept: "application/json",
295
- "accept-encoding": "identity",
296
- date: new Date().toUTCString(),
297
- signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
298
- "content-type": "application/json",
299
- "user-agent": "Go-http-client/2.0",
300
- };
301
- }
302
-
303
- type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
304
-
305
- async function exchangeQoderPat(
306
- pat: string,
307
- signal: AbortSignal,
308
- fetcher: QoderFetcher,
309
- version = getQoderVersion(),
310
- realm: QoderRealm = "intl",
311
- ): Promise<QoderAuth> {
312
- const machineId = qoderMachineIdFromPat(pat);
313
- const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
314
- const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
315
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
316
- let responseBody: unknown;
317
- try {
318
- responseBody = await response.json();
319
- } catch {
320
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
321
- }
322
- if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
323
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
324
- }
325
- const rec = responseBody as Record<string, unknown>;
326
- if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
327
- throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
328
- }
329
- return {
330
- userId: rec["id"] as string,
331
- userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
332
- userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
333
- securityOauthToken: rec["securityOauthToken"] as string,
334
- refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
335
- machineId,
336
- };
337
- }
338
-
339
- async function callQoder(
340
- url: string,
341
- body: Record<string, unknown>,
342
- modelId: string,
343
- auth: QoderAuth,
344
- signal: AbortSignal,
345
- fetcher: QoderFetcher,
346
- version = getQoderVersion(),
347
- ): Promise<Response> {
348
- const encoded = transformQoderBody(JSON.stringify(body));
349
- const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
350
- return fetcher(url, {
351
- method: "POST",
352
- headers: {
353
- ...qoderBuildCosyHeaders(encoded, url, auth, version),
354
- ...(MODERN_PROFILE.sendModelSourceHeaders
355
- ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
356
- : {}),
357
- Accept: "text/event-stream",
358
- "Cache-Control": "no-cache",
359
- "Accept-Encoding": "identity",
360
- },
361
- body: encoded as unknown as BodyInit,
362
- signal,
363
- });
364
- }
365
-
366
- /**
367
- * The human-readable text out of a Qoder error envelope.
368
- *
369
- * The envelope is Qoder's own wrapper around the stream, and it does not use
370
- * one field for the message: the text has been seen as a nested `error`
371
- * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
372
- * extractor covers the structured shapes; a string body and the top-level
373
- * fields cover the rest. Whatever survives is bounded the same way an upstream
374
- * message is, so a verbose envelope cannot blow up the public error line.
375
- */
376
- function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
377
- const structured = extractUpstreamMessage(rec["body"]);
378
- if (structured.length > 0) return structured;
379
- if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
380
- return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
381
- return extractUpstreamMessage(rec);
382
- }
383
-
384
- function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
385
- if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
386
- let envelope: unknown;
387
- try {
388
- envelope = JSON.parse(data);
389
- } catch {
390
- }
391
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
392
- const rec = envelope as Record<string, unknown>;
393
- if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
394
- const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
395
- // Qoder reports failures inside the stream envelope instead of as HTTP
396
- // responses, so classify its structured provider code and status through
397
- // the same gateway error policy as the other dispatch paths.
398
- const detail = qoderEnvelopeMessage(rec);
399
- const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
400
- // Qoder signals a queued request the same way inside the stream as it does
401
- // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
402
- // non-200 envelope. The status table would read the 403 as a refusal, and
403
- // the caller would end its turn on a request that was only waiting.
404
- const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
405
- const waitMs = queuedWaitMs
406
- ? (queueRetryAfterMs(rec["body"]) ?? undefined)
407
- : undefined;
408
- const classification = queuedWaitMs
409
- ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
410
- : classifyUpstreamError(status, identifier);
411
- throw new GatewayError(
412
- classification.code,
413
- classification.status,
414
- detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
415
- {
416
- providerId: QODER_PROVIDER_ID,
417
- providerStatus: status,
418
- ...(identifier ? { providerCode: identifier } : {}),
419
- ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
420
- ...(classification.rateLimitScope
421
- ? { rateLimitScope: classification.rateLimitScope }
422
- : {}),
423
- ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
424
- ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
425
- },
426
- classification.origin,
427
- );
428
- }
429
- const body = rec["body"];
430
- if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
431
- if (typeof body !== "string") return [];
432
- return [{ event: null, data: body.replace(/\r?\n/g, "") }];
433
- }
434
-
435
- function qoderStableHash(prefix: string, ...parts: string[]): string {
436
- const h = createHash("sha256");
437
- h.update(prefix);
438
- for (const p of parts) {
439
- h.update("\0");
440
- h.update(p);
441
- }
442
- return h.digest("hex").slice(0, 16);
443
- }
444
-
445
- function flattenCanonicalContent(content: readonly ContentPart[]): string {
446
- return content
447
- .filter((b) => b.kind === "text" || b.kind === "toolResult")
448
- .map((b) => {
449
- if (b.kind === "text") return b.text;
450
- if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
451
- return "";
452
- })
453
- .join("\n");
454
- }
455
-
456
- function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
457
- const h = createHash("sha256");
458
- h.update("qoder-record\0");
459
- h.update(modelId);
460
- for (const m of messages) {
461
- h.update("\0");
462
- h.update(m.role);
463
- const text = flattenCanonicalContent(m.content);
464
- if (text) {
465
- h.update("\0");
466
- h.update(text);
467
- }
468
- }
469
- h.update(`\0mt=${maxTokens}`);
470
- return h.digest("hex").slice(0, 16);
471
- }
472
-
473
- function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
474
- /**
475
- * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
476
- * every model alike, so a request asking for more would be silently lowered
477
- * no matter which model it named.
478
- */
479
- const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
480
- const controls = request.generation_controls as Record<string, unknown>;
481
- const requested =
482
- typeof controls["max_tokens"] === "number"
483
- ? (controls["max_tokens"] as number)
484
- : typeof controls["max_output_tokens"] === "number"
485
- ? (controls["max_output_tokens"] as number)
486
- : null;
487
- return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
488
- }
489
-
490
- /**
491
- * Whether this model needs the prior turn's reasoning echoed back.
492
- *
493
- * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
494
- * assistant turn that thought must be replayed with that field or the upstream
495
- * refuses the whole history. The upstream reference bridge keeps it for exactly
496
- * these models and drops it as noise for every other one
497
- * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
498
- * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
499
- * failures that sometimes pass on retry.
500
- *
501
- * Matched by the upstream key and by the display name, because a caller may
502
- * name either — the reference warns that matching only the "deepseek" prefix
503
- * missed every request that used the key.
504
- */
505
- function qoderKeepsReasoning(modelId: string): boolean {
506
- const key = modelId.trim().toLowerCase();
507
- if (key === "dmodel" || key === "dfmodel") return true;
508
- return key.startsWith("deepseek");
509
- }
510
-
511
- /**
512
- * The `reasoning_content` field for an assistant turn, when one is needed.
513
- *
514
- * Presence, not length, decides: a client that already knows the rule sends
515
- * `""` on a thinking turn whose text is not readable back, and dropping that
516
- * empty string re-triggers the complaint it was sent to avoid.
517
- *
518
- * DeepSeek multi-turn consistency also backfills the field on every assistant
519
- * turn once the conversation carries a reasoning trace at all — the reference
520
- * bridge does the same (`backfill_reasoning_content`), and notes the symptom
521
- * when it is missed: occasional failures that sometimes pass on retry. An
522
- * assistant turn with no reasoning and no trace gains nothing, so nothing is
523
- * fabricated.
524
- */
525
- function qoderReasoningEcho(
526
- message: { content: readonly ContentPart[] },
527
- modelId: string,
528
- hasReasoningTrace = false,
529
- ): { reasoning_content: string } | Record<string, never> {
530
- if (!qoderKeepsReasoning(modelId)) return {};
531
- const reasoning = message.content.filter((part) => part.kind === "reasoning");
532
- if (reasoning.length === 0) {
533
- return hasReasoningTrace ? { reasoning_content: "" } : {};
534
- }
535
- const text = reasoning
536
- .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
537
- .join("");
538
- return { reasoning_content: text };
539
- }
540
-
541
- /**
542
- * The tool arguments as the object the `tool_use` block expects.
543
- *
544
- * The canonical form keeps them as the raw JSON text the caller sent, which is
545
- * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
546
- * structured input, so the text is parsed back.
547
- *
548
- * The fallbacks mirror the upstream reference bridge rather than inventing
549
- * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
550
- * Smuggling the raw text through instead would emit an argument shape the
551
- * upstream has never seen for this field.
552
- */
553
- /**
554
- * Projects the canonical tools onto Qoder's chat-completions `tools` array.
555
- *
556
- * Without this the upstream is never told a tool exists, so a model that wants
557
- * one improvises markup in its prose (`<Read file_path="..." />`) instead of
558
- * emitting a structured call — the call is then neither runnable nor
559
- * recoverable, because it never was a call on either side.
560
- *
561
- * Only `function` tools are representable upstream. Anything else is dropped
562
- * rather than forwarded: Qoder rejects an unknown variant during
563
- * deserialization and fails the whole turn, which is worse than omitting a
564
- * tool the model could not have used anyway.
565
- */
566
- function qoderTools(
567
- tools: readonly ToolDefinition[] | undefined,
568
- ): Array<Record<string, unknown>> {
569
- const out: Array<Record<string, unknown>> = [];
570
- for (const tool of tools ?? []) {
571
- if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
572
- // The upstream requires `tools[].function` to be an object. A flat
573
- // declaration naming the tool at the top level is rejected outright with
574
- // "'function' is a required property, expected an object", which fails the
575
- // whole request rather than just this tool.
576
- out.push({
577
- type: "function",
578
- function: {
579
- name: tool.name,
580
- ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
581
- parameters: tool.jsonSchema ?? { type: "object", properties: {} },
582
- ...(tool.strict === undefined ? {} : { strict: tool.strict }),
583
- },
584
- });
585
- }
586
- return out;
587
- }
588
-
589
-
590
- /**
591
- * Projects the canonical tool choice onto the upstream's field.
592
- *
593
- * The upstream only accepts a string here. Given the object form
594
- * `{type:"function",function:{name}}` it ignores the field, and the model then
595
- * calls the tool with no arguments filled in.
596
- *
597
- * `allowed_tools` has no chat-completions equivalent, so it collapses to the
598
- * direction it already names rather than being dropped: a caller that asked for
599
- * `required` must not silently get `auto`.
600
- */
601
- function qoderToolChoice(choice: ToolChoice | undefined): unknown {
602
- if (choice === undefined) return undefined;
603
- if (typeof choice === "string") return choice;
604
- if (choice.type === "tool" || choice.type === "custom") {
605
- const name = choice.name.trim();
606
- return name.length > 0 ? name : "auto";
607
- }
608
- return choice.mode;
609
- }
610
-
611
- /**
612
- * Marks a tool call recorded into the text of an assistant turn.
613
- *
614
- * The upstream only reads structured turns on its CN realm, so on INTL a past
615
- * call has to be described in prose or the model loses the record of what it
616
- * asked for. The wording matches the reference bridge, which is what the
617
- * upstream has been trained to read back.
618
- */
619
- const QODER_TOOL_MARKER = "[assistant 请求调用工具]";
620
-
621
- /**
622
- * Whether this request sends its tool history as structured turns.
623
- *
624
- * The upstream honours the structured form on its CN realm only. On INTL it is
625
- * not enabled, and sending it anyway yields tool calls that come back right in
626
- * name but with no arguments filled in — a call that cannot run. So the shape
627
- * follows the realm rather than being assumed everywhere.
628
- */
629
- function qoderStructuredTools(realm: QoderRealm): boolean {
630
- return realm === "cn";
631
- }
632
-
633
- export function buildQoderRequest(
634
- modelId: string,
635
- request: CanonicalRequest,
636
- modelConfig: QoderModelConfig,
637
- auth: QoderAuth,
638
- realm: QoderRealm = "intl",
639
- ): Record<string, unknown> {
640
- const allMessages = request.messages;
641
- const systemParts: string[] = [];
642
- if (request.system) {
643
- for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
644
- }
645
- if (request.instructions) {
646
- for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
647
- }
648
- for (const m of allMessages) {
649
- if (m.role === "system" || m.role === "developer") {
650
- const t = flattenCanonicalContent(m.content);
651
- if (t) systemParts.push(t);
652
- }
653
- }
654
- const systemPrompt = systemParts.join("\n\n").trim();
655
- const qoderMessages: Array<Record<string, unknown>> = [];
656
- const structuredTools = qoderStructuredTools(realm);
657
- // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
658
- // trace, every assistant message must replay `reasoning_content` (see
659
- // `qoderReasoningEcho`).
660
- const hasReasoningTrace = allMessages.some((message) =>
661
- message.content.some((part) => part.kind === "reasoning"),
662
- );
663
- if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
664
- qoderMessages.push({
665
- role: "system",
666
- content: systemPrompt,
667
- });
668
- }
669
- for (const m of allMessages) {
670
- if (m.role === "system" || m.role === "developer") continue;
671
- // Answers may live in a `user` turn (the Messages ledger re-homes them),
672
- // so the result scan is role-agnostic. Each result becomes its own `tool`
673
- // turn carrying its `tool_call_id`; any non-result content on the same turn
674
- // is emitted after, so nothing is duplicated or lost.
675
- const results = structuredTools ? toolResultParts(m) : [];
676
- if (results.length > 0) {
677
- for (const block of results) {
678
- const text = flattenCanonicalContent(
679
- typeof block.content === "string"
680
- ? [{ kind: "text", text: block.content }]
681
- : block.content,
682
- );
683
- qoderMessages.push({
684
- role: "tool",
685
- content: text,
686
- tool_call_id: block.call_id,
687
- });
688
- }
689
- // Text that shared the turn with a result is emitted after it, minus the
690
- // result itself. Re-emitting the result's own text here was doubling
691
- // every tool answer: the `tool` turn above and a `user` turn with the
692
- // same content, which doubled the payload for no reason.
693
- const remaining = m.content.filter((part) => part.kind !== "toolResult");
694
- const flat = flattenCanonicalContent(remaining);
695
- if (flat.length === 0) continue;
696
- qoderMessages.push({
697
- role: "user",
698
- content: flat,
699
- });
700
- continue;
701
- }
702
- // An assistant turn that called tools must carry the calls, not just its
703
- // text. Falling through to the text-only emit dropped them, so the payload
704
- // arrived as a `tool` result with no preceding call and the upstream
705
- // rejected the whole transcript: "Messages with role 'tool' must be a
706
- // response to a preceding message with 'tool_calls'".
707
- const allCalls = m.content.filter((part) => part.kind === "toolCall");
708
- const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
709
- // Structured tool turns are only safe when every call carries an id — the
710
- // upstream drops or rejects a transcript whose `tool` result answers a call
711
- // without one. Fall back to prose history exactly like INTL in that case.
712
- const structuredIdsOk = allCalls.every(
713
- (part) => typeof part.call_id === "string" && part.call_id.length > 0,
714
- );
715
- if ((!structuredTools || !structuredIdsOk) && allCalls.length > 0) {
716
- // INTL does not read structured turns, so the call is written into the
717
- // text instead of being dropped. Dropping it left the next `tool` turn
718
- // answering a call that was never made, and the model had no record of
719
- // what it had asked for — so it re-emitted a call with no arguments.
720
- //
721
- // The marker and the JSON shape mirror the reference bridge, which is
722
- // what the upstream has actually been trained to read back.
723
- const textParts = m.content.filter((part) => part.kind !== "toolCall");
724
- const prose = flattenCanonicalContent(textParts);
725
- const recorded = allCalls.map((part) => ({
726
- name: part.name,
727
- arguments: typeof part.arguments === "string" ? part.arguments : "",
728
- }));
729
- qoderMessages.push({
730
- role: "assistant",
731
- content: (prose.length > 0 ? prose + "\n\n" : "") + QODER_TOOL_MARKER + "\n" + JSON.stringify(recorded),
732
- ...reasoningEcho,
733
- });
734
- continue;
735
- }
736
- const calls = structuredTools && structuredIdsOk ? allCalls : [];
737
- if (calls.length > 0) {
738
- const textParts = m.content.filter((part) => part.kind !== "toolCall");
739
- const flat = flattenCanonicalContent(textParts);
740
- qoderMessages.push({
741
- role: "assistant",
742
- content: flat,
743
- ...reasoningEcho,
744
- tool_calls: calls.map((part) => ({
745
- id: part.call_id,
746
- type: "function",
747
- function: {
748
- name: part.name,
749
- arguments: typeof part.arguments === "string" ? part.arguments : "",
750
- },
751
- })),
752
- });
753
- continue;
754
- }
755
- const role = m.role === "assistant" ? "assistant" : "user";
756
- // Images must travel as structured OpenAI parts inside the message content —
757
- // the upstream reads `image_url` parts there and not the top-level
758
- // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
759
- // bare file_id reference degrades to a text note instead of being dropped.
760
- const hasImages = m.content.some((part) => part.kind === "image");
761
- if (hasImages) {
762
- const parts: Array<Record<string, unknown>> = [];
763
- for (const part of m.content) {
764
- if (part.kind === "text") {
765
- parts.push({ type: "text", text: part.text });
766
- } else if (part.kind === "image") {
767
- const source = resolveImageSource(part.payload);
768
- if (source?.url !== undefined) {
769
- parts.push({ type: "image_url", image_url: { url: source.url } });
770
- } else {
771
- parts.push({
772
- type: "text",
773
- text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
774
- });
775
- }
776
- }
777
- }
778
- qoderMessages.push({
779
- role,
780
- content: parts,
781
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
782
- });
783
- continue;
784
- }
785
- const flat = flattenCanonicalContent(m.content);
786
- qoderMessages.push({
787
- role,
788
- content: flat,
789
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
790
- });
791
- }
792
- const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
793
- const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
794
- const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
795
- const reasoning = modelConfig.is_reasoning === true;
796
- const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
797
- const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
798
- return {
799
- request_id: randomUUID(),
800
- request_set_id: recordId,
801
- chat_record_id: recordId,
802
- session_id: sessionId,
803
- stream: true,
804
- ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
805
- chat_task: "FREE_INPUT",
806
- is_reply: true,
807
- is_retry: false,
808
- source: 1,
809
- version: "3",
810
- session_type: "qodercli",
811
- agent_id: "agent_common",
812
- task_id: "common",
813
- code_language: "",
814
- chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
815
- image_urls: null,
816
- chat_context: {
817
- chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
818
- imageUrls: null,
819
- extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
820
- features: [],
821
- // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
822
- text: { text: latestUserText },
823
- },
824
- model_config: {
825
- key: modelId,
826
- display_name: modelConfig.display_name,
827
- // Declares the wire dialect for the tool calls. Without it the upstream
828
- // does not read the OpenAI-shaped `tools` array the same way, and the
829
- // model then emits a call with no arguments filled in.
830
- format: "openai",
831
- model: "",
832
- api_key: "",
833
- url: "",
834
- is_vl: modelConfig.is_vl,
835
- is_reasoning: modelConfig.is_reasoning,
836
- max_input_tokens: modelConfig.max_input_tokens,
837
- // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
838
- // for every model alike, so the request would declare a cap the catalog
839
- // does not publish.
840
- max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
841
- source: modelConfig.source ?? "system",
842
- },
843
- messages: qoderMessages,
844
- max_tokens: maxTokens,
845
- // Always present, even when empty. Omitting the field makes Qoder fall back
846
- // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
847
- // caller that declared none — the reference bridge sends `[]` for exactly
848
- // that reason. Parallel calls are stated explicitly rather than left to the
849
- // upstream default, so a caller expecting them is not served serial
850
- // behaviour.
851
- tools: qoderTools(request.tools),
852
- parallel_tool_calls: true,
853
- ...(() => {
854
- const choice = qoderToolChoice(request.tool_choice);
855
- return choice === undefined ? {} : { tool_choice: choice };
856
- })(),
857
- };
858
- }
859
-
860
- // ---------------------------------------------------------------------------
861
- // DSML tool-call recovery — DeepSeek's native markup in the text stream
862
- // ---------------------------------------------------------------------------
863
- // Some DeepSeek-family models write their tool calls as XML-like DSML markup
864
- // inside the plain-text `content` stream instead of the structured
865
- // `tool_calls` field. Left alone, the markup reaches the caller as visible
866
- // prose and the tool never runs — which is exactly the symptom reported.
867
- //
868
- // The parser lives in `src/protocol/dsml.ts`, shared with the CodeBuddy
869
- // family; Qoder selects the fuzzy dialect, which mirrors the reference bridge's
870
- // Python regexes (`qoder_proxy.py`, `parse_dsml_tool_calls`).
871
- import {
872
- parseFuzzyDsmlToolCalls,
873
- dsmlTailHold,
874
- stripDsmlCloseTags,
875
- type DsmlToolCall,
876
- } from "../../../protocol/dsml";
877
-
878
- /** Qoder's dialect bounds the shared parser to the fuzzy (non-strict) form. */
879
- export const parseDsmlToolCalls = (text: string) => parseFuzzyDsmlToolCalls(text);
880
- export { dsmlTailHold, stripDsmlCloseTags } from "../../../protocol/dsml";
881
-
882
- /** One recovered call, in the shape the canonical events want. */
883
- export type DsmlCall = DsmlToolCall;
884
-
885
- /**
886
- * Escapes the bare quotes that belong inside a JSON string value.
887
- *
888
- * Escaped quotes are parked first so they are not escaped twice, then every
889
- * remaining quote is escaped, then the parked ones are restored.
890
- */
891
- function repairJsonQuotes(text: string): string {
892
- const ph = "\u0000";
893
- return text.replace(/\\"/g, ph).replace(/"/g, '\\"').replaceAll(ph, '\\"');
894
- }
895
-
896
- /** `[{"name":"…","arguments":"<unescaped JSON>"}]`, one call only. */
897
- const ECHO_LOOSE_RE = /^\[\s*\{\s*"name"\s*:\s*"([^"]{1,200})"\s*,\s*"arguments"\s*:\s*"(.*)"\s*\}\s*\]$/s;
898
-
899
- /**
900
- * Any spelling of the INTL tool-call leak marker — the square-bracket
901
- * canonical form, the angle-bracket variant the upstream also emits
902
- * (`<assistant 请求调用工具>`), and partial variants missing the closing
903
- * bracket. Mirrors `_LEAK_MARKER_RE` in the reference bridge.
904
- */
905
- const LEAK_MARKER_ANY_RE = /[\[<(]*\s*assistant\s*请求调用工具\s*[\]>)]?/;
906
-
907
- /** Canonical marker literal used to seed prefix matching. */
908
- const LEAK_MARKER_LITERALS: readonly string[] = [
909
- "[assistant 请求调用工具]",
910
- "<assistant 请求调用工具>",
911
- "assistant 请求调用工具",
912
- ];
913
-
914
- /** Longest partial marker we hold across frames (mirrors `_MARKER_HOLD_WINDOW`). */
915
- const LEAK_HOLD_WINDOW = 24;
916
- /** Fail-open caps so an endless lookalike cannot hold text forever. */
917
- const LEAK_HOLD_MAX_CHARS = 32_768;
918
- const LEAK_HOLD_MAX_FRAMES = 200;
919
-
920
- /**
921
- * True when `suffix` is a strict prefix of some marker literal — the stream
922
- * may be mid-marker and the rest arrives in the next frame.
923
- */
924
- function isLeakMarkerPrefix(suffix: string): boolean {
925
- if (suffix.length === 0 || suffix.length >= LEAK_HOLD_WINDOW) return false;
926
- return LEAK_MARKER_LITERALS.some((literal) => literal.startsWith(suffix));
927
- }
928
-
929
- /**
930
- * Splits one content chunk into `{ emit, hold }`, mirroring the reference
931
- * bridge's `_find_marker_split`: prose before a complete marker is emitted
932
- * immediately, the marker and everything after it is held, and a partial
933
- * marker at the very end is held for the next frame.
934
- */
935
- function qoderLeakSplit(text: string): { emit: string; hold: string } {
936
- if (text.length === 0) return { emit: "", hold: "" };
937
- const marker = LEAK_MARKER_ANY_RE.exec(text);
938
- if (marker) {
939
- return { emit: text.slice(0, marker.index), hold: text.slice(marker.index) };
940
- }
941
- // No complete marker: hold a trailing partial-marker suffix only.
942
- const tail = text.slice(-LEAK_HOLD_WINDOW);
943
- for (let n = tail.length; n >= 1; n -= 1) {
944
- const seg = tail.slice(-n);
945
- if (isLeakMarkerPrefix(seg)) {
946
- return { emit: text.slice(0, text.length - n), hold: text.slice(text.length - n) };
947
- }
948
- }
949
- return { emit: text, hold: "" };
950
- }
951
-
952
- /**
953
- * True when `text` is still plausibly the INTL leak block (marker + JSON
954
- * array). Used to keep holding across frames; false means it is ordinary
955
- * prose and can be released. Mirrors `_still_leak_candidate`.
956
- */
957
- function qoderStillLeakCandidate(text: string): boolean {
958
- if (text.length === 0) return true;
959
- const marker = LEAK_MARKER_ANY_RE.exec(text);
960
- if (!marker) {
961
- // No complete marker yet: still a candidate only while it could grow into
962
- // one (full prefix or pending marker remainder).
963
- return isLeakMarkerPrefix(text) || LEAK_MARKER_ANY_RE.test(text);
964
- }
965
- const rest = text.slice(marker.index + marker[0].length).trim();
966
- if (rest.length === 0) return true;
967
- if (rest.startsWith("[")) return true;
968
- if (rest.startsWith("{")) return true;
969
- return false;
970
- }
971
-
972
- /**
973
- * Rebuilds the single-call echo shape when strict parsing fails.
974
- *
975
- * Returns `null` when the text is not that shape at all, so a caller can fall
976
- * back rather than accepting a guess. The result still goes through the same
977
- * strict checks as a normally-parsed array.
978
- */
979
- function salvageEchoArray(rest: string): unknown {
980
- const m = ECHO_LOOSE_RE.exec(rest.trim());
981
- if (!m) return null;
982
- const name = m[1] ?? "";
983
- const raw = m[2] ?? "";
984
- for (const candidate of [raw, repairJsonQuotes(raw)]) {
985
- try {
986
- JSON.parse(candidate);
987
- return [{ name, arguments: candidate }];
988
- } catch {
989
- continue;
990
- }
991
- }
992
- return null;
993
- }
994
-
995
- /**
996
- * Recovers a tool call the model echoed back as text.
997
- *
998
- * On INTL the upstream reads tool calls as prose, so a turn that called a tool
999
- * is described with `QODER_TOOL_MARKER` followed by a JSON array of
1000
- * `{name, arguments}`. The model occasionally reproduces that shape verbatim as
1001
- * its answer instead of calling again, and left alone the client shows the JSON
1002
- * as content while the tool never runs.
1003
- *
1004
- * Only a well-formed array whose every entry names a tool and carries parseable
1005
- * arguments is accepted. Anything else returns no calls and the original text,
1006
- * so a model merely talking about the format is never mistaken for a call.
1007
- */
1008
- export function parseLeakedToolCalls(
1009
- text: string,
1010
- allowedNames?: ReadonlySet<string>,
1011
- ): { calls: DsmlCall[]; clean: string } {
1012
- if (text.length === 0) {
1013
- return { calls: [], clean: text };
1014
- }
1015
- // The upstream occasionally echoes the marker in variants — angle brackets,
1016
- // a missing closing bracket, or wrapped inside a ``` fence. Strip a leading
1017
- // fence, then locate any marker spelling, mirroring the reference bridge's
1018
- // `_normalize_leak_marker`.
1019
- const fence = text.match(/^```[a-z]*\s*\n?/i);
1020
- const src = fence && fence[0] ? text.slice(fence[0].length) : text;
1021
- const marker = LEAK_MARKER_ANY_RE.exec(src);
1022
- if (!marker) {
1023
- return { calls: [], clean: text };
1024
- }
1025
- const head = src.slice(0, marker.index).trim();
1026
- let rest = src.slice(marker.index + marker[0].length).trim();
1027
- // The model occasionally leaves raw DSML closing tags after the echo array
1028
- // (`</|DSML|parameter>` etc.), and a ``` fence may close the block. Strip
1029
- // trailing residue so the JSON parse is not poisoned by "Extra data".
1030
- rest = stripDsmlCloseTags(rest);
1031
- rest = rest.replace(/\s*```\s*$/, "").trim();
1032
-
1033
- let parsed: unknown;
1034
- try {
1035
- parsed = JSON.parse(rest);
1036
- } catch {
1037
- // A model echoing the call writes the inner quotes unescaped:
1038
- // `[{"name":"Read","arguments":"{"file_path":"a.ts"}"}]`. That is not
1039
- // valid JSON at either level, so the array has to be rebuilt before it can
1040
- // be trusted. Only the single-call shape is salvaged, which is what an echo
1041
- // almost always is.
1042
- const salvaged = salvageEchoArray(rest);
1043
- if (salvaged === null) return { calls: [], clean: text };
1044
- parsed = salvaged;
1045
- }
1046
- if (Array.isArray(parsed) && parsed.length === 0) return { calls: [], clean: text };
1047
-
1048
- // The model echoes the call either as a JSON array (`[{name,arguments},…]`)
1049
- // or as a single object (`{"name":…,"arguments":…}`). Normalize the single
1050
- // object into a one-entry array so both shapes share one validator.
1051
- const items: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
1052
- const calls: DsmlCall[] = [];
1053
- for (const item of items) {
1054
- if (typeof item !== "object" || item === null) return { calls: [], clean: text };
1055
- const rec = item as Record<string, unknown>;
1056
- const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
1057
- if (name.length === 0) return { calls: [], clean: text };
1058
- if (allowedNames && !allowedNames.has(name)) return { calls: [], clean: text };
1059
- let args = rec["arguments"];
1060
- if (typeof args === "object" && args !== null) args = JSON.stringify(args);
1061
- if (typeof args !== "string") return { calls: [], clean: text };
1062
- // Arguments that do not parse are not a call this can trust.
1063
- if (args.trim().length > 0) {
1064
- try {
1065
- JSON.parse(args);
1066
- } catch {
1067
- return { calls: [], clean: text };
1068
- }
1069
- }
1070
- calls.push({ id: `call_${randomUUID().replace(/-/g, "").slice(0, 16)}`, name, arguments: args });
1071
- }
1072
- return { calls, clean: head };
1073
- }
1074
-
1075
- // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
1076
-
1077
- function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
1078
- if (raw === "length") return "length";
1079
- if (raw === "tool_calls") return "tool_use";
1080
- if (raw === "content_filter") return "content_filter";
1081
- return undefined;
1082
- }
1083
-
1084
- export async function* qoderBodyToCanonicalEvents(
1085
- body: ReadableStream<Uint8Array>,
1086
- signal: AbortSignal,
1087
- model: string,
1088
- ): AsyncIterable<CanonicalEvent> {
1089
- let seq = 1;
1090
- yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
1091
- let finishReason: unknown = undefined;
1092
- let rawUsage: Record<string, unknown> | undefined = undefined;
1093
- let hasTerminal = false;
1094
- /** First-sighting id per wire tool-call index, for continuation chunks. */
1095
- const indexIds = new Map<number, string>();
1096
- /** Last seen call id/name, for index-less continuation fragments. */
1097
- let lastSeenToolId: string | undefined = undefined;
1098
- let lastSeenToolName: string | undefined = undefined;
1099
- // DSML recovery state. While `buf` is empty the text passes through
1100
- // untouched; once a chunk carries DSML the rest is buffered so the markup can
1101
- // be parsed whole at end of stream. `tail` holds a possible half-tag that
1102
- // straddles a frame boundary.
1103
- let buf = "";
1104
- let tail = "";
1105
- // Text that flowed through while not in a DSML stream. The INTL leak marker
1106
- // (`[assistant 请求调用工具] …`) is not DSML, so it was never buffered and
1107
- // recovered; keeping the plain text lets the end-of-stream recovery see it.
1108
- let plainBuf = "";
1109
- // INTL leak-marker hold-back. Text that may grow into the leak block is
1110
- // withheld from the client (not yielded) until the stream proves it is
1111
- // either a tool-call echo (recovered at end of stream) or ordinary prose
1112
- // (released). Mirrors the reference bridge's `hold` logic.
1113
- let leakBuf = "";
1114
- let leakFrames = 0;
1115
- /** Calls recovered from the thinking channel, emitted if content had none. */
1116
- let reasonCalls: readonly DsmlCall[] = [];
1117
- /** True once any tool call has been emitted by any channel this turn. */
1118
- let hasEmittedToolCall = false;
1119
- // The same recovery for the thinking channel. A model that plans its call
1120
- // inside `reasoning_content` writes the markup there instead of in `content`,
1121
- // so recovering only the content channel loses the call *and* leaves the
1122
- // tags visible in the user's thinking.
1123
- let reasonBuf = "";
1124
- let reasonTail = "";
1125
-
1126
- for await (const sse of decodeSseEvents(body, { signal })) {
1127
- const sseData = sse.data;
1128
- let frames: Array<{ event: string | null; data: string }>;
1129
- try {
1130
- frames = qoderEnvelopeToFrames(sseData);
1131
- } catch (e) {
1132
- throw e;
1133
- }
1134
- for (const frame of frames) {
1135
- const data = frame.data.trim();
1136
- if (!data) continue;
1137
- if (data === "[DONE]") {
1138
- hasTerminal = true;
1139
- continue;
1140
- }
1141
- let json: Record<string, unknown>;
1142
- try {
1143
- json = JSON.parse(data) as Record<string, unknown>;
1144
- } catch {
1145
- continue;
1146
- }
1147
- if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
1148
- const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
1149
- for (const choice of choices) {
1150
- const delta = choice["delta"] as Record<string, unknown> | undefined;
1151
- if (delta) {
1152
- if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1153
- const piece = tail + (delta["content"] as string);
1154
- tail = "";
1155
- if (buf.length === 0) {
1156
- // Not yet in a DSML stream: hold back only a possible tag start,
1157
- // and pass everything before it through unchanged.
1158
- const hold = dsmlTailHold(piece);
1159
- const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1160
- if (body.includes("DSML")) {
1161
- buf = body;
1162
- tail = hold;
1163
- continue;
1164
- }
1165
- // INTL leak-marker hold-back: do not let a marker echo reach the
1166
- // client as text. Prose before a complete marker is emitted now;
1167
- // the marker and anything after it are held until the stream
1168
- // decides (a valid echo → tool calls; anything else → released).
1169
- if (leakBuf.length > 0) {
1170
- leakBuf += body;
1171
- leakFrames += 1;
1172
- if (
1173
- leakBuf.length > LEAK_HOLD_MAX_CHARS ||
1174
- leakFrames > LEAK_HOLD_MAX_FRAMES ||
1175
- !qoderStillLeakCandidate(leakBuf)
1176
- ) {
1177
- // Released as ordinary prose: it was already shown, so it
1178
- // must not re-enter `plainBuf` (the end-of-stream recovery
1179
- // would re-parse it and emit a duplicate tool call).
1180
- if (leakBuf.length > 0) {
1181
- yield {
1182
- type: "content_delta",
1183
- sequence_number: seq++,
1184
- content: { kind: "text", text: leakBuf },
1185
- } as CanonicalEvent;
1186
- }
1187
- leakBuf = "";
1188
- leakFrames = 0;
1189
- }
1190
- tail = hold;
1191
- continue;
1192
- }
1193
- const { emit, hold: leakHold } = qoderLeakSplit(body);
1194
- if (emit.length > 0) {
1195
- plainBuf += emit;
1196
- yield {
1197
- type: "content_delta",
1198
- sequence_number: seq++,
1199
- content: { kind: "text", text: emit },
1200
- } as CanonicalEvent;
1201
- }
1202
- if (leakHold.length > 0) {
1203
- leakBuf = leakHold;
1204
- leakFrames = 1;
1205
- tail = hold;
1206
- continue;
1207
- }
1208
- // No marker at all: `emit` already covered the whole body, so
1209
- // only yield here when the split held nothing and emitted nothing.
1210
- if (emit.length === 0 && body.length > 0) {
1211
- plainBuf += body;
1212
- yield {
1213
- type: "content_delta",
1214
- sequence_number: seq++,
1215
- content: { kind: "text", text: body },
1216
- } as CanonicalEvent;
1217
- }
1218
- tail = hold;
1219
- continue;
1220
- }
1221
- buf += piece;
1222
- continue;
1223
- }
1224
- const reasoningText = readReasoningText(delta);
1225
- if (typeof reasoningText === "string" && reasoningText.length > 0) {
1226
- const piece = reasonTail + reasoningText;
1227
- reasonTail = "";
1228
- if (reasonBuf.length === 0) {
1229
- const hold = dsmlTailHold(piece);
1230
- const body = hold.length > 0 ? piece.slice(0, piece.length - hold.length) : piece;
1231
- if (body.includes("DSML")) {
1232
- reasonBuf = body;
1233
- reasonTail = hold;
1234
- continue;
1235
- }
1236
- const visible = stripDsmlCloseTags(body);
1237
- if (visible.length > 0) {
1238
- yield {
1239
- type: "content_delta",
1240
- sequence_number: seq++,
1241
- content: { kind: "reasoning", payload: null, summary: visible },
1242
- } as CanonicalEvent;
1243
- }
1244
- reasonTail = hold;
1245
- continue;
1246
- }
1247
- reasonBuf += piece;
1248
- continue;
1249
- }
1250
- const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1251
- if (toolCalls) {
1252
- for (const tc of toolCalls) {
1253
- const fn = tc["function"] as Record<string, unknown> | undefined;
1254
- const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1255
- const declaredId = typeof tc["id"] === "string" ? (tc["id"] as string) : undefined;
1256
- const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1257
- // OpenAI continuation chunks re-identify their call by wire
1258
- // `index` alone — the `id` appears only once, in the first chunk.
1259
- // Some bridges omit `index` too, so a fragment then falls back to
1260
- // the most recently seen call id (mirroring the generic chat
1261
- // parser's `lastSeenToolId`). Pinning the first id per index and
1262
- // reusing it for continuations is what keeps fragments attached
1263
- // to the right call; synthesizing a fresh `call_<index>` id per
1264
- // chunk instead made every continuation an unknown nameless call
1265
- // that encoders dropped, so the client saw a call whose arguments
1266
- // were always empty.
1267
- let callId: string;
1268
- if (declaredId !== undefined) {
1269
- callId = declaredId;
1270
- if (index !== undefined) indexIds.set(index, callId);
1271
- lastSeenToolId = callId;
1272
- if (fragName !== undefined) lastSeenToolName = fragName;
1273
- } else if (index !== undefined && indexIds.has(index)) {
1274
- callId = indexIds.get(index)!;
1275
- } else if (
1276
- lastSeenToolId !== undefined &&
1277
- (fragName === undefined || lastSeenToolName === fragName)
1278
- ) {
1279
- callId = lastSeenToolId;
1280
- } else {
1281
- callId = `call_${String(index ?? 0)}`;
1282
- }
1283
- hasEmittedToolCall = true;
1284
- yield {
1285
- type: "tool_call_delta",
1286
- sequence_number: seq++,
1287
- call_id: callId,
1288
- ...(index !== undefined ? { index } : {}),
1289
- ...(fragName === undefined ? {} : { name: fragName }),
1290
- arguments_delta: (fn?.["arguments"] as string) ?? "",
1291
- } as CanonicalEvent;
1292
- }
1293
- }
1294
- }
1295
- const fr = choice["finish_reason"];
1296
- if (fr !== null && fr !== undefined) finishReason = fr;
1297
- // Some providers send usage in choices[0].usage or top-level
1298
- if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
1299
- }
1300
- // Some Qoder chunks carry finish_reason at top level
1301
- if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
1302
- }
1303
- }
1304
- // End of stream: resolve whatever the DSML buffer collected.
1305
- if (reasonBuf.length > 0) {
1306
- const { calls, clean } = parseDsmlToolCalls(reasonBuf);
1307
- const visible = stripDsmlCloseTags(clean).trim();
1308
- if (visible.length > 0) {
1309
- yield {
1310
- type: "content_delta",
1311
- sequence_number: seq++,
1312
- content: { kind: "reasoning", payload: null, summary: visible },
1313
- } as CanonicalEvent;
1314
- }
1315
- // A call recovered here counts exactly like one recovered from the content
1316
- // channel — the two are deduplicated below, so a model that writes the
1317
- // block in one channel and its tail in the other still yields one call.
1318
- if (calls.length > 0) reasonCalls = calls;
1319
- }
1320
- if (buf.length > 0) {
1321
- // Two ways a call can arrive as text: DSML markup, and the marker + JSON
1322
- // array the upstream itself uses on INTL. The second is tried only when the
1323
- // first finds nothing, so a DSML stream is never re-read as an echo.
1324
- let { calls, clean } = parseDsmlToolCalls(buf);
1325
- if (calls.length === 0) {
1326
- const leaked = parseLeakedToolCalls(buf);
1327
- if (leaked.calls.length > 0) {
1328
- calls = leaked.calls;
1329
- clean = leaked.clean;
1330
- }
1331
- }
1332
- if (calls.length > 0) {
1333
- const visible = stripDsmlCloseTags(clean).trim();
1334
- if (visible.length > 0) {
1335
- yield {
1336
- type: "content_delta",
1337
- sequence_number: seq++,
1338
- content: { kind: "text", text: visible },
1339
- } as CanonicalEvent;
1340
- }
1341
- for (const call of calls) {
1342
- hasEmittedToolCall = true;
1343
- yield {
1344
- type: "tool_call_delta",
1345
- sequence_number: seq++,
1346
- call_id: call.id,
1347
- name: call.name,
1348
- arguments_delta: call.arguments,
1349
- } as CanonicalEvent;
1350
- }
1351
- reasonCalls = [];
1352
- // The turn ended by calling a tool, whatever the upstream reported.
1353
- finishReason = "tool_calls";
1354
- } else {
1355
- // Unparseable markup: emit as prose rather than dropping it, so nothing
1356
- // the model said is lost.
1357
- const visible = stripDsmlCloseTags(buf).trim();
1358
- if (visible.length > 0) {
1359
- yield {
1360
- type: "content_delta",
1361
- sequence_number: seq++,
1362
- content: { kind: "text", text: visible },
1363
- } as CanonicalEvent;
1364
- }
1365
- }
1366
- }
1367
- // Content carried no call of its own: whatever the thinking channel
1368
- // recovered is the turn's call.
1369
- for (const call of reasonCalls) {
1370
- hasEmittedToolCall = true;
1371
- yield {
1372
- type: "tool_call_delta",
1373
- sequence_number: seq++,
1374
- call_id: call.id,
1375
- name: call.name,
1376
- arguments_delta: call.arguments,
1377
- } as CanonicalEvent;
1378
- }
1379
- // The INTL leak marker is plain text, not DSML, so it flowed through to
1380
- // `plainBuf` instead of the buffered `buf`. Recover it the same way: a
1381
- // model that answers with `[assistant 请求调用工具] [{"name":…,"arguments":…}]`
1382
- // intended the tool to run, and left alone the client sees the JSON as
1383
- // content while nothing executes. Only when no other channel already
1384
- // claimed the turn.
1385
- if (!hasEmittedToolCall) {
1386
- // Held-back marker block first: it was withheld from the client, so a
1387
- // recovery here never duplicates text already shown. A valid echo becomes
1388
- // tool calls; anything else is released as ordinary prose.
1389
- if (leakBuf.length > 0) {
1390
- const leaked = parseLeakedToolCalls(leakBuf);
1391
- if (leaked.calls.length > 0) {
1392
- for (const call of leaked.calls) {
1393
- hasEmittedToolCall = true;
1394
- yield {
1395
- type: "tool_call_delta",
1396
- sequence_number: seq++,
1397
- call_id: call.id,
1398
- name: call.name,
1399
- arguments_delta: call.arguments,
1400
- } as CanonicalEvent;
1401
- }
1402
- finishReason = "tool_calls";
1403
- } else if (leaked.clean.length > 0) {
1404
- yield {
1405
- type: "content_delta",
1406
- sequence_number: seq++,
1407
- content: { kind: "text", text: leaked.clean },
1408
- } as CanonicalEvent;
1409
- }
1410
- }
1411
- if (plainBuf.length > 0 && !hasEmittedToolCall) {
1412
- const leaked = parseLeakedToolCalls(plainBuf);
1413
- if (leaked.calls.length > 0) {
1414
- for (const call of leaked.calls) {
1415
- hasEmittedToolCall = true;
1416
- yield {
1417
- type: "tool_call_delta",
1418
- sequence_number: seq++,
1419
- call_id: call.id,
1420
- name: call.name,
1421
- arguments_delta: call.arguments,
1422
- } as CanonicalEvent;
1423
- }
1424
- finishReason = "tool_calls";
1425
- }
1426
- }
1427
- }
1428
- // A recovered call outranks what the upstream reported. It says `stop`
1429
- // because the markup never became a real tool call on its side, so leaving it
1430
- // alone ends the turn "finished" with a call the caller never sees run.
1431
- if (reasonCalls.length > 0) finishReason = "tool_calls";
1432
- if (tail.length > 0) {
1433
- yield {
1434
- type: "content_delta",
1435
- sequence_number: seq++,
1436
- content: { kind: "text", text: tail },
1437
- } as CanonicalEvent;
1438
- }
1439
- // If no terminal arrived, synthesize one (prevents truncation error)
1440
- const stopReason = mapQoderFinishReason(finishReason);
1441
- const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1442
- void hasTerminal;
1443
- yield {
1444
- type: "terminal",
1445
- sequence_number: seq++,
1446
- state: "complete",
1447
- ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
1448
- ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
1449
- usage: usageFromProvider(rawUsage),
1450
- } as CanonicalEvent;
1451
- }
1452
-
1453
- // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
1454
-
1455
- /**
1456
- * The intl catalog this provider publishes.
1457
- *
1458
- * `QODER_MODEL_CONFIGS` is not the source here: it freezes most rows at 180k
1459
- * while the upstream reports 1M for the same keys. That number is not
1460
- * cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a stale
1461
- * low value tells Qoder the conversation window is small and makes it behave
1462
- * that way. Both the published catalog and the dispatched request come from the
1463
- * realm's roster, which carries the upstream's own numbers.
1464
- */
1465
- export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
1466
-
1467
- // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
1468
-
1469
- interface QoderAdapterOptions {
1470
- readonly fetch?: typeof fetch;
1471
- /** Which deployment this adapter dispatches to. Defaults to intl. */
1472
- readonly realm?: QoderRealm;
1473
- /** Bundled provider id, so the adapter's own id matches the route's. */
1474
- readonly providerId?: string;
1475
- }
1476
-
1477
- class QoderAdapter implements ProviderAdapter {
1478
- readonly provider_id: ProviderId;
1479
- private readonly realm: QoderRealm;
1480
- private readonly fetchFn: typeof fetch;
1481
-
1482
- constructor(options: QoderAdapterOptions = {}) {
1483
- this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
1484
- this.realm = options.realm ?? "intl";
1485
- this.fetchFn = options.fetch ?? globalThis.fetch;
1486
- }
1487
-
1488
- /**
1489
- * The static roster for this adapter's realm.
1490
- *
1491
- * A single merged table advertised CN-only keys to global accounts and vice
1492
- * versa, so a request was routed to a deployment that refuses it.
1493
- */
1494
- #modelConfigs(): Record<string, QoderModelConfig> {
1495
- return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
1496
- string,
1497
- QoderModelConfig
1498
- >;
1499
- }
1500
-
1501
- async *dispatch(
1502
- request: CanonicalRequest,
1503
- candidate: ProviderDispatchTarget,
1504
- context: ProviderDispatchContext,
1505
- ): AsyncIterable<CanonicalEvent> {
1506
- if (candidate.wire_family !== "chat") {
1507
- throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
1508
- }
1509
- const secret = readCredentialSecret(context.credential, "Qoder credential is required");
1510
-
1511
- /**
1512
- * A caller-facing virtual name is translated to its roster key here.
1513
- *
1514
- * The translation has to happen before the config lookup and before the
1515
- * request body: the body carries the roster key, and looking up the
1516
- * virtual id in the config table would fall through to the synthetic
1517
- * default and silently dispatch under-declared capabilities.
1518
- */
1519
- const requestedModelId = candidate.model_id || request.model;
1520
- if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
1521
- throw new GatewayError(
1522
- "model_not_found",
1523
- 404,
1524
- `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
1525
- { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
1526
- "upstream",
1527
- );
1528
- }
1529
- const modelId = qoderDispatchModelId(requestedModelId);
1530
- const modelConfig = this.#modelConfigs()[modelId] ?? {
1531
- id: modelId,
1532
- display_name: modelId,
1533
- max_input_tokens: 180_000,
1534
- is_vl: true,
1535
- is_reasoning: false,
1536
- };
1537
-
1538
- const lifecycle = createUpstreamDeadlineLifecycle(context);
1539
-
1540
- // Outbound fetch respects SSRF binding when provided
1541
- const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
1542
-
1543
- const fetcher: QoderFetcher = (url, init) =>
1544
- outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
1545
-
1546
- try {
1547
- const version = getQoderVersion();
1548
- // Dual mode like Cline: a pasted PAT is exchanged for a job token per
1549
- // request, while an OAuth device token is used directly with the
1550
- // identity the login persisted.
1551
- const auth = isQoderPat(secret)
1552
- ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
1553
- : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
1554
- const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
1555
- // A full URL from the route wins, but the fallback is this realm's
1556
- // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1557
- // a CN credential to `*.qoder.sh` is refused outright.
1558
- const url =
1559
- candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
1560
- ? candidate.endpoint_path
1561
- : `${qoderGatewayCandidates(this.realm)[0] ?? ""}${QODER_CHAT_PATH}`;
1562
-
1563
- const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
1564
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
1565
- if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
1566
- // Headers arrived: the pre-stream deadline has served its purpose.
1567
- // From here the gateway stall/first-chunk watchdog (propagated via
1568
- // context.abort_signal) governs the body. Keeping this timer armed
1569
- // would kill healthy long streams at the stale pre-stream deadline.
1570
- lifecycle.release();
1571
- yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
1572
- } catch (err: unknown) {
1573
- const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
1574
- if (abortError) throw abortError;
1575
- throw err;
1576
- } finally {
1577
- lifecycle.release();
1578
- }
1579
- }
1580
- }
1581
-
1582
- export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1583
- return new QoderAdapter(options);
1584
- }
1585
-
1586
- /**
1587
- * Adapter for the China deployment.
1588
- *
1589
- * Same wire, different gateway host and roster — see `qoder-realm.ts`.
1590
- */
1591
- export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1592
- return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
1593
- }
1594
-
1595
- /**
1596
- * A COSY signer for one already-resolved account identity.
1597
- *
1598
- * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
1599
- * import this module (the adapter imports its rosters — that would be a cycle).
1600
- * Exporting the signer keeps one signing implementation and lets discovery
1601
- * stay pure and injectable.
1602
- */
1603
- export function qoderSignerFor(
1604
- auth: QoderAuth,
1605
- version = getQoderVersion(),
1606
- ): (url: string, body: string) => Record<string, string> {
1607
- return (url: string, body: string): Record<string, string> =>
1608
- qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
1609
- }
1610
-
1611
- /** Qoder's own base64 alphabet transform, for a request body. */
1612
- export function qoderEncodeBody(plaintext: Uint8Array | string): string {
1613
- const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
1614
- return Buffer.from(transformQoderBody(text)).toString("latin1");
1615
- }
1616
-
1617
- /**
1618
- * Resolves the dispatch identity for one Qoder credential.
1619
- *
1620
- * Shared by the adapter and by discovery: both need the same `QoderAuth` the
1621
- * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
1622
- * let the two drift.
1623
- */
1624
- export async function resolveQoderAuth(input: {
1625
- readonly secret: string;
1626
- readonly authState: unknown;
1627
- readonly signal: AbortSignal;
1628
- readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
1629
- /** Which deployment mints the job token; a PAT is realm-bound. */
1630
- readonly realm?: QoderRealm;
1631
- }): Promise<QoderAuth> {
1632
- const realm = input.realm ?? "intl";
1633
- if (isQoderPat(input.secret)) {
1634
- return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
1635
- }
1636
- // Discovery is handed only the secret and the persisted state, so the
1637
- // account id is read from that state — the same place `qoderOAuthAuth` reads
1638
- // it — rather than being a separate field callers can pass wrongly.
1639
- const state = parseQoderOAuthState(input.authState);
1640
- return qoderOAuthAuth(input.secret, input.authState, state?.userId);
1641
- }
1
+ // Factory-blocked (Phase C4): PAT-to-jobToken session handshake, COSY
2
+ // AES/RSA request signing, alphabet-scrambled binary body, enveloped SSE
3
+ // frames — session/crypto protocol outside factory reach; stays bespoke.
4
+ import { constants, createCipheriv, createHash, publicEncrypt, randomUUID } from "node:crypto";
5
+ import {
6
+ classifyUpstreamError,
7
+ extractUpstreamMessage,
8
+ isUpstreamQueued,
9
+ mapUpstreamHttpError,
10
+ queueRetryAfterMs,
11
+ upstreamErrorIdentifier,
12
+ } from "../../../transport/failure-policy";
13
+ import { GatewayError } from "../../../protocol/gateway-error";
14
+ import { resolveImageSource } from "../../../protocol/codecs";
15
+ import type {
16
+ CanonicalEvent,
17
+ CanonicalRequest,
18
+ CanonicalStopReason,
19
+ ContentPart,
20
+ ToolChoice,
21
+ ToolDefinition,
22
+ } from "../../../protocol/canonical-model";
23
+ import { toolResultParts, toolCallParts } from "../../../protocol/canonical-model";
24
+ import { decodeSseEvents } from "../../../protocol/sse-decoder";
25
+ import { usageFromProvider, readReasoningText } from "../../provider-usage";
26
+ import { readCredentialSecret, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
27
+ import type { ModelDefinition } from "../../provider-model-definition";
28
+ import { parseQoderOAuthState } from "./qoder-oauth";
29
+ import { getQoderVersion } from "../../operations/client-versions";
30
+ import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
31
+ import { qoderMachineOs } from "../../operations/cli-platform";
32
+ import { qoderInferenceCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
33
+ import { QODER_CN_MODEL_CONFIGS, QODER_INTL_MODEL_CONFIGS, QODER_INTL_MODELS } from "./qoder-catalog";
34
+ import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
35
+ import {
36
+ LEAK_MARKER_ANY_RE,
37
+ LEAK_MARKER_LITERALS,
38
+ parseQoderTextToolCalls,
39
+ isQoderEchoAttempt,
40
+ } from "./qoder-text-tools";
41
+
42
+ /** How long a trailing partial marker may be before it is held for the next frame. */
43
+ const LEAK_HOLD_WINDOW = 24;
44
+ /** Fail-open caps so an endless lookalike cannot hold text forever. */
45
+ const LEAK_HOLD_MAX_CHARS = 32_768;
46
+ const LEAK_HOLD_MAX_FRAMES = 200;
47
+
48
+ const BAD_ECHO_MESSAGE =
49
+ "Qoder returned an unparseable tool-call echo; no tools were emitted";
50
+
51
+ function badEchoError(): GatewayError {
52
+ return new GatewayError(
53
+ "platform_unavailable",
54
+ 502,
55
+ BAD_ECHO_MESSAGE,
56
+ { providerId: QODER_PROVIDER_ID },
57
+ "upstream",
58
+ );
59
+ }
60
+
61
+ /** True when `suffix` is a strict prefix of some marker literal. */
62
+ function isLeakMarkerPrefix(suffix: string): boolean {
63
+ if (suffix.length === 0 || suffix.length >= LEAK_HOLD_WINDOW) return false;
64
+ return LEAK_MARKER_LITERALS.some((literal) => literal.startsWith(suffix));
65
+ }
66
+
67
+ /**
68
+ * True when `text` is still plausibly the INTL marker echo block (marker +
69
+ * JSON array/object, or a tool-style tag). Used to keep holding across frames;
70
+ * false means it is ordinary prose and can be released.
71
+ */
72
+ function qoderStillLeakCandidate(text: string): boolean {
73
+ if (text.length === 0) return true;
74
+ const marker = LEAK_MARKER_ANY_RE.exec(text);
75
+ if (!marker) {
76
+ return isLeakMarkerPrefix(text) || LEAK_MARKER_ANY_RE.test(text);
77
+ }
78
+ const rest = text.slice(marker.index + marker[0].length).trim();
79
+ if (rest.length === 0) return true;
80
+ if (rest.startsWith("[") || rest.startsWith("{")) return true;
81
+ if (/^<[a-zA-Z0-9_.:-]+\b/.test(rest)) return true;
82
+ // Angle-wrapped JSON: `<{` / `<[` — candidate until a closing `>` appears.
83
+ if (rest.startsWith("<{") || rest.startsWith("<[")) {
84
+ return !rest.includes(">") || rest.endsWith(">");
85
+ }
86
+ return false;
87
+ }
88
+
89
+ interface QoderModeProfile {
90
+ readonly chatUrl: string;
91
+ readonly businessProduct: string;
92
+ readonly businessType: string;
93
+ readonly businessVersion: string;
94
+ readonly cosyScene: string;
95
+ readonly mirrorTopLevelSystem: boolean;
96
+ readonly sendBusinessHeaders: boolean;
97
+ readonly sendModelSourceHeaders: boolean;
98
+ readonly emptyAliyunUserType: boolean;
99
+ }
100
+
101
+ export const MODERN_PROFILE: QoderModeProfile = {
102
+ chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
103
+ businessProduct: "cli",
104
+ businessType: "agent",
105
+ businessVersion: "1.0.22",
106
+ cosyScene: "assistant",
107
+ mirrorTopLevelSystem: true,
108
+ sendBusinessHeaders: true,
109
+ sendModelSourceHeaders: true,
110
+ emptyAliyunUserType: true,
111
+ };
112
+
113
+ const QODER_PROVIDER_ID = "qoder" as const;
114
+
115
+ export const QODER_MODEL_CONFIGS: Record<
116
+ string,
117
+ { id: string; display_name: string; max_input_tokens: number; max_output_tokens?: number; is_vl: boolean; is_reasoning: boolean; source?: string }
118
+ > = {
119
+ auto: { id: "auto", display_name: "Auto", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
120
+ ultimate: { id: "ultimate", display_name: "Ultimate", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
121
+ performance: { id: "performance", display_name: "Performance", max_input_tokens: 272000, is_vl: true, is_reasoning: false },
122
+ efficient: { id: "efficient", display_name: "Efficient", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
123
+ lite: { id: "lite", display_name: "Lite", max_input_tokens: 180000, is_vl: false, is_reasoning: true },
124
+ qmodel: { id: "qmodel", display_name: "Qwen 3.6 Plus", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
125
+ qmodel_latest: { id: "qmodel_latest", display_name: "Qwen 3.7 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
126
+ qmodel_preview: { id: "qmodel_preview", display_name: "Qwen 3.8 Max", max_input_tokens: 1000000, is_vl: true, is_reasoning: false },
127
+ dmodel: { id: "dmodel", display_name: "DeepSeek V4 Pro", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
128
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek V4 Flash", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
129
+ gm51model: { id: "gm51model", display_name: "GLM 5.1", max_input_tokens: 180000, is_vl: true, is_reasoning: true },
130
+ kmodel: { id: "kmodel", display_name: "Kimi K2.6", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
131
+ mmodel: { id: "mmodel", display_name: "MiniMax M2.7", max_input_tokens: 180000, is_vl: true, is_reasoning: false },
132
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi K2.7 Latest", max_input_tokens: 256000, is_vl: true, is_reasoning: false },
133
+ };
134
+
135
+ const QODER_APPCODE = "cosy";
136
+ const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
137
+ /**
138
+ * Where a PAT is exchanged for a short-lived job token, per realm.
139
+ *
140
+ * The job token is realm-bound: one minted on the global host answers "Login
141
+ * expired" on the China gateway. Exchanging against the wrong host therefore
142
+ * looks like a dead credential rather than a routing bug, which is why the
143
+ * URL is derived from the realm table instead of being one constant.
144
+ *
145
+ * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
146
+ * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
147
+ * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
148
+ * silently misrouted request.
149
+ */
150
+ const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
151
+ function qoderJobTokenUrl(realm: QoderRealm): string {
152
+ return realm === "cn"
153
+ ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
154
+ : QODER_JOB_TOKEN_URL;
155
+ }
156
+ const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
157
+ const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
158
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
159
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
160
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
161
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
162
+ XcW+ML9FoCI6AOvOzwIDAQAB
163
+ -----END PUBLIC KEY-----`;
164
+
165
+ export interface QoderAuth {
166
+ userId: string;
167
+ userName: string;
168
+ userType: string;
169
+ securityOauthToken: string;
170
+ refreshToken: string;
171
+ machineId: string;
172
+ }
173
+ interface QoderModelConfig {
174
+ id: string;
175
+ display_name: string;
176
+ max_input_tokens: number;
177
+ max_output_tokens?: number;
178
+ is_vl: boolean;
179
+ is_reasoning: boolean;
180
+ source?: string;
181
+ }
182
+
183
+ function qoderMd5(value: string | Uint8Array): string {
184
+ return createHash("md5").update(value).digest("hex");
185
+ }
186
+
187
+ /**
188
+ * Canonical compact JSON used by Qoder's COSY signing: keys sorted
189
+ * lexicographically, no whitespace, null/undefined rendered as empty string.
190
+ * The upstream verifies the signature against this exact serialization, so a
191
+ * payload built with plain `JSON.stringify` (insertion order) fails with
192
+ * `code 101 / Signature invalid`.
193
+ */
194
+ function qoderJsonSortedCompact(mapping: Record<string, unknown>): string {
195
+ const parts: string[] = [];
196
+ for (const key of Object.keys(mapping).sort()) {
197
+ const value = mapping[key] === null || mapping[key] === undefined ? "" : mapping[key];
198
+ parts.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
199
+ }
200
+ return `{${parts.join(",")}}`;
201
+ }
202
+ function qoderPathSignature(url: string): string {
203
+ const path = new URL(url).pathname;
204
+ return path.startsWith("/algo") ? path.slice(5) : path;
205
+ }
206
+
207
+ const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
208
+ const QODER_MAX_MACHINE_IDS = 1_024;
209
+ const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
210
+ /**
211
+ * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
212
+ * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
213
+ * tokens (`jt-...`) are used directly.
214
+ */
215
+ export function isQoderPat(secret: string): boolean {
216
+ return secret.startsWith("pt-");
217
+ }
218
+
219
+ /**
220
+ * Stable per-account machine id for OAuth dispatches. Derived from the
221
+ * account id (not minted per request) so pooled accounts keep one device
222
+ * fingerprint across restarts instead of looking like a new device every
223
+ * time, while staying distinct per account.
224
+ */
225
+ export function qoderMachineIdForAccount(accountId: string): string {
226
+ const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
227
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
228
+ }
229
+
230
+ /**
231
+ * Builds dispatch auth from an OAuth device token. The token is used
232
+ * directly as the COSY identity's oauth token; the user id and machine id
233
+ * come from the `auth_state` the login persisted. Fails closed with a
234
+ * reconnect prompt when the identity is absent (e.g. an account created
235
+ * before `auth_state` existed) instead of signing with a guessed id.
236
+ */
237
+ export function qoderOAuthAuth(
238
+ secret: string,
239
+ authState: unknown,
240
+ accountId: string | undefined,
241
+ ): QoderAuth {
242
+ const state = parseQoderOAuthState(authState);
243
+ if (state === undefined || !state.userId) {
244
+ throw new GatewayError(
245
+ "authentication_failed",
246
+ 401,
247
+ "Qoder OAuth account is missing its user identity; sign in again",
248
+ );
249
+ }
250
+ return {
251
+ userId: state.userId,
252
+ userName: "",
253
+ userType: "personal_standard",
254
+ securityOauthToken: secret,
255
+ refreshToken: "",
256
+ machineId: state.machineId || (accountId ? qoderMachineIdForAccount(accountId) : randomUUID()),
257
+ };
258
+ }
259
+
260
+ function qoderMachineIdFromPat(pat: string): string {
261
+ const key = createHash("sha256").update(pat).digest("hex");
262
+ const now = Date.now();
263
+ const existing = qoderMachineIds.get(key);
264
+ if (existing && existing.expiresAt > now) return existing.id;
265
+ const id = randomUUID();
266
+ qoderMachineIds.delete(key);
267
+ qoderMachineIds.set(key, { id, expiresAt: now + QODER_MACHINE_ID_TTL_MS });
268
+ for (const [candidate, value] of qoderMachineIds) {
269
+ if (value.expiresAt <= now) qoderMachineIds.delete(candidate);
270
+ }
271
+ while (qoderMachineIds.size > QODER_MAX_MACHINE_IDS) {
272
+ const oldest = qoderMachineIds.keys().next();
273
+ if (oldest.done) break;
274
+ qoderMachineIds.delete(oldest.value);
275
+ }
276
+ return id;
277
+ }
278
+
279
+ function qoderStaticHeaders(machineId: string, version = getQoderVersion()): Record<string, string> {
280
+ const date = new Date().toUTCString();
281
+ return {
282
+ "cosy-machinetoken": machineId,
283
+ "cosy-machinetype": "5",
284
+ "cosy-machineid": machineId,
285
+ "cosy-clienttype": "5",
286
+ "cosy-version": version,
287
+ "login-version": "v2",
288
+ appcode: QODER_APPCODE,
289
+ accept: "application/json",
290
+ "accept-encoding": "identity",
291
+ date,
292
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${date}`),
293
+ "content-type": "application/json",
294
+ "user-agent": "Go-http-client/2.0",
295
+ };
296
+ }
297
+
298
+ function transformQoderBody(plaintext: string): Uint8Array {
299
+ const base64 = Buffer.from(plaintext).toString("base64");
300
+ const third = Math.floor(base64.length / 3);
301
+ const reordered = base64.slice(base64.length - third) + base64.slice(third, base64.length - third) + base64.slice(0, third);
302
+ let encoded = "";
303
+ for (const char of reordered) {
304
+ if (char === "=") encoded += "$";
305
+ else encoded += QODER_ALPHABET[QODER_STANDARD_ALPHABET.indexOf(char)] ?? char;
306
+ }
307
+ return Buffer.from(encoded, "latin1");
308
+ }
309
+
310
+ function qoderBuildCosyHeaders(
311
+ body: Uint8Array,
312
+ url: string,
313
+ auth: QoderAuth,
314
+ version = getQoderVersion(),
315
+ ): Record<string, string> {
316
+ const key = randomUUID().replaceAll("-", "").slice(0, 16);
317
+ const identity = qoderJsonSortedCompact({
318
+ name: auth.userName,
319
+ aid: auth.userId,
320
+ uid: auth.userId,
321
+ yx_uid: "",
322
+ organization_id: "",
323
+ organization_name: "",
324
+ user_type: auth.userType,
325
+ security_oauth_token: auth.securityOauthToken,
326
+ refresh_token: auth.refreshToken,
327
+ });
328
+ const cipher = createCipheriv("aes-128-cbc", Buffer.from(key), Buffer.from(key));
329
+ const info = Buffer.concat([cipher.update(identity), cipher.final()]).toString("base64");
330
+ const cosyKey = publicEncrypt({ key: QODER_RSA_PUBLIC_KEY, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(key)).toString("base64");
331
+ const payload = Buffer.from(
332
+ qoderJsonSortedCompact({ version: "v1", requestId: randomUUID(), info, cosyVersion: version, ideVersion: "" }),
333
+ ).toString("base64");
334
+ const date = String(Math.floor(Date.now() / 1000));
335
+ const signature = qoderMd5(`${payload}\n${cosyKey}\n${date}\n${Buffer.from(body).toString("latin1")}\n${qoderPathSignature(url)}`);
336
+ return {
337
+ authorization: `Bearer COSY.${payload}.${signature}`,
338
+ "cosy-data-policy": "agree",
339
+ "cosy-machinetype": "5",
340
+ "cosy-machineos": qoderMachineOs(),
341
+ "cosy-clienttype": "5",
342
+ "cosy-date": date,
343
+ "cosy-user": auth.userId,
344
+ "cosy-key": cosyKey,
345
+ "cosy-bodyhash": qoderMd5(body),
346
+ "cosy-bodylength": String(body.byteLength),
347
+ "cosy-sigpath": qoderPathSignature(url),
348
+ "cosy-organization-id": "",
349
+ "cosy-organization-tags": "",
350
+ "x-request-id": randomUUID(),
351
+ ...(MODERN_PROFILE.sendBusinessHeaders
352
+ ? {
353
+ "cosy-business-product": MODERN_PROFILE.businessProduct,
354
+ "cosy-business-type": MODERN_PROFILE.businessType,
355
+ "cosy-business-version": version,
356
+ "cosy-scene": MODERN_PROFILE.cosyScene,
357
+ }
358
+ : {}),
359
+ "cosy-version": version,
360
+ "login-version": "v2",
361
+ appcode: QODER_APPCODE,
362
+ accept: "application/json",
363
+ "accept-encoding": "identity",
364
+ date: new Date().toUTCString(),
365
+ signature: qoderMd5(`${QODER_APPCODE}&${QODER_SIG_SECRET}&${new Date().toUTCString()}`),
366
+ "content-type": "application/json",
367
+ "user-agent": "Go-http-client/2.0",
368
+ };
369
+ }
370
+
371
+ type QoderFetcher = (url: string, init: RequestInit) => Promise<Response>;
372
+
373
+ async function exchangeQoderPat(
374
+ pat: string,
375
+ signal: AbortSignal,
376
+ fetcher: QoderFetcher,
377
+ version = getQoderVersion(),
378
+ realm: QoderRealm = "intl",
379
+ ): Promise<QoderAuth> {
380
+ const machineId = qoderMachineIdFromPat(pat);
381
+ const encodedBody = transformQoderBody(JSON.stringify({ payload: JSON.stringify({ personalToken: pat, securityOauthToken: "", refreshToken: "", needRefresh: false, authInfo: {} }), encodeVersion: "1" }));
382
+ const response = await fetcher(qoderJobTokenUrl(realm), { method: "POST", headers: qoderStaticHeaders(machineId, version), body: encodedBody as unknown as BodyInit, signal });
383
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
384
+ let responseBody: unknown;
385
+ try {
386
+ responseBody = await response.json();
387
+ } catch {
388
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned invalid JSON", {}, "upstream");
389
+ }
390
+ if (typeof responseBody !== "object" || responseBody === null || Array.isArray(responseBody)) {
391
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned unexpected response", {}, "upstream");
392
+ }
393
+ const rec = responseBody as Record<string, unknown>;
394
+ if (typeof rec["id"] !== "string" || typeof rec["securityOauthToken"] !== "string") {
395
+ throw new GatewayError("platform_unavailable", 502, "Qoder PAT exchange returned incomplete credentials", {}, "upstream");
396
+ }
397
+ return {
398
+ userId: rec["id"] as string,
399
+ userName: typeof rec["name"] === "string" ? (rec["name"] as string) : "",
400
+ userType: typeof rec["userType"] === "string" ? (rec["userType"] as string) : "personal_standard",
401
+ securityOauthToken: rec["securityOauthToken"] as string,
402
+ refreshToken: typeof rec["refreshToken"] === "string" ? (rec["refreshToken"] as string) : "",
403
+ machineId,
404
+ };
405
+ }
406
+
407
+ async function callQoder(
408
+ url: string,
409
+ body: Record<string, unknown>,
410
+ modelId: string,
411
+ auth: QoderAuth,
412
+ signal: AbortSignal,
413
+ fetcher: QoderFetcher,
414
+ version = getQoderVersion(),
415
+ ): Promise<Response> {
416
+ const encoded = transformQoderBody(JSON.stringify(body));
417
+ const modelConfig = (body["model_config"] as Record<string, unknown> | null) ?? null;
418
+ return fetcher(url, {
419
+ method: "POST",
420
+ headers: {
421
+ ...qoderBuildCosyHeaders(encoded, url, auth, version),
422
+ ...(MODERN_PROFILE.sendModelSourceHeaders
423
+ ? { "x-model-key": modelId, "x-model-source": typeof modelConfig?.["source"] === "string" ? (modelConfig["source"] as string) : "system" }
424
+ : {}),
425
+ Accept: "text/event-stream",
426
+ "Cache-Control": "no-cache",
427
+ "Accept-Encoding": "identity",
428
+ },
429
+ body: encoded as unknown as BodyInit,
430
+ signal,
431
+ });
432
+ }
433
+
434
+ /**
435
+ * The human-readable text out of a Qoder error envelope.
436
+ *
437
+ * The envelope is Qoder's own wrapper around the stream, and it does not use
438
+ * one field for the message: the text has been seen as a nested `error`
439
+ * object, a bare string `body`, and a top-level `message`/`msg`. The canonical
440
+ * extractor covers the structured shapes; a string body and the top-level
441
+ * fields cover the rest. Whatever survives is bounded the same way an upstream
442
+ * message is, so a verbose envelope cannot blow up the public error line.
443
+ */
444
+ function qoderEnvelopeMessage(rec: Record<string, unknown>): string {
445
+ const structured = extractUpstreamMessage(rec["body"]);
446
+ if (structured.length > 0) return structured;
447
+ if (typeof rec["body"] === "string" && rec["body"].trim().length > 0 && rec["body"] !== "[DONE]")
448
+ return rec["body"].replace(/[\r\n]+/g, " ").trim().slice(0, 500);
449
+ return extractUpstreamMessage(rec);
450
+ }
451
+
452
+ function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
453
+ if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
454
+ let envelope: unknown;
455
+ try {
456
+ envelope = JSON.parse(data);
457
+ } catch {
458
+ }
459
+ if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
460
+ const rec = envelope as Record<string, unknown>;
461
+ if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
462
+ const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
463
+ // Qoder reports failures inside the stream envelope instead of as HTTP
464
+ // responses, so classify its structured provider code and status through
465
+ // the same gateway error policy as the other dispatch paths.
466
+ const detail = qoderEnvelopeMessage(rec);
467
+ const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
468
+ // Qoder signals a queued request the same way inside the stream as it does
469
+ // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
470
+ // non-200 envelope. The status table would read the 403 as a refusal, and
471
+ // the caller would end its turn on a request that was only waiting.
472
+ const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
473
+ const waitMs = queuedWaitMs
474
+ ? (queueRetryAfterMs(rec["body"]) ?? undefined)
475
+ : undefined;
476
+ const classification = queuedWaitMs
477
+ ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
478
+ : classifyUpstreamError(status, identifier);
479
+ throw new GatewayError(
480
+ classification.code,
481
+ classification.status,
482
+ detail.length > 0 ? detail : `Qoder stream envelope returned HTTP ${status}`,
483
+ {
484
+ providerId: QODER_PROVIDER_ID,
485
+ providerStatus: status,
486
+ ...(identifier ? { providerCode: identifier } : {}),
487
+ ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
488
+ ...(classification.rateLimitScope
489
+ ? { rateLimitScope: classification.rateLimitScope }
490
+ : {}),
491
+ ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
492
+ ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
493
+ },
494
+ classification.origin,
495
+ );
496
+ }
497
+ const body = rec["body"];
498
+ if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
499
+ if (typeof body !== "string") return [];
500
+ return [{ event: null, data: body.replace(/\r?\n/g, "") }];
501
+ }
502
+
503
+ function qoderStableHash(prefix: string, ...parts: string[]): string {
504
+ const h = createHash("sha256");
505
+ h.update(prefix);
506
+ for (const p of parts) {
507
+ h.update("\0");
508
+ h.update(p);
509
+ }
510
+ return h.digest("hex").slice(0, 16);
511
+ }
512
+
513
+ function flattenCanonicalContent(content: readonly ContentPart[]): string {
514
+ return content
515
+ .filter((b) => b.kind === "text" || b.kind === "toolResult")
516
+ .map((b) => {
517
+ if (b.kind === "text") return b.text;
518
+ if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
519
+ return "";
520
+ })
521
+ .join("\n");
522
+ }
523
+
524
+ function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
525
+ const h = createHash("sha256");
526
+ h.update("qoder-record\0");
527
+ h.update(modelId);
528
+ for (const m of messages) {
529
+ h.update("\0");
530
+ h.update(m.role);
531
+ const text = flattenCanonicalContent(m.content);
532
+ if (text) {
533
+ h.update("\0");
534
+ h.update(text);
535
+ }
536
+ }
537
+ h.update(`\0mt=${maxTokens}`);
538
+ return h.digest("hex").slice(0, 16);
539
+ }
540
+
541
+ function qoderRequestedMaxTokens(request: CanonicalRequest, modelConfig: QoderModelConfig): number {
542
+ /**
543
+ * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
544
+ * every model alike, so a request asking for more would be silently lowered
545
+ * no matter which model it named.
546
+ */
547
+ const configured = typeof modelConfig.max_output_tokens === "number" ? modelConfig.max_output_tokens : 65_536;
548
+ const controls = request.generation_controls as Record<string, unknown>;
549
+ const requested =
550
+ typeof controls["max_tokens"] === "number"
551
+ ? (controls["max_tokens"] as number)
552
+ : typeof controls["max_output_tokens"] === "number"
553
+ ? (controls["max_output_tokens"] as number)
554
+ : null;
555
+ return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
556
+ }
557
+
558
+ /**
559
+ * Whether this model needs the prior turn's reasoning echoed back.
560
+ *
561
+ * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
562
+ * assistant turn that thought must be replayed with that field or the upstream
563
+ * refuses the whole history. The upstream reference bridge keeps it for exactly
564
+ * these models and drops it as noise for every other one
565
+ * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
566
+ * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
567
+ * failures that sometimes pass on retry.
568
+ *
569
+ * Matched by the upstream key and by the display name, because a caller may
570
+ * name either — the reference warns that matching only the "deepseek" prefix
571
+ * missed every request that used the key.
572
+ */
573
+ function qoderKeepsReasoning(modelId: string): boolean {
574
+ const key = modelId.trim().toLowerCase();
575
+ if (key === "dmodel" || key === "dfmodel") return true;
576
+ return key.startsWith("deepseek");
577
+ }
578
+
579
+ /**
580
+ * The `reasoning_content` field for an assistant turn, when one is needed.
581
+ *
582
+ * Presence, not length, decides: a client that already knows the rule sends
583
+ * `""` on a thinking turn whose text is not readable back, and dropping that
584
+ * empty string re-triggers the complaint it was sent to avoid.
585
+ *
586
+ * DeepSeek multi-turn consistency also backfills the field on every assistant
587
+ * turn once the conversation carries a reasoning trace at all — the reference
588
+ * bridge does the same (`backfill_reasoning_content`), and notes the symptom
589
+ * when it is missed: occasional failures that sometimes pass on retry. An
590
+ * assistant turn with no reasoning and no trace gains nothing, so nothing is
591
+ * fabricated.
592
+ */
593
+ function qoderReasoningEcho(
594
+ message: { content: readonly ContentPart[] },
595
+ modelId: string,
596
+ hasReasoningTrace = false,
597
+ ): { reasoning_content: string } | Record<string, never> {
598
+ if (!qoderKeepsReasoning(modelId)) return {};
599
+ const reasoning = message.content.filter((part) => part.kind === "reasoning");
600
+ if (reasoning.length === 0) {
601
+ return hasReasoningTrace ? { reasoning_content: "" } : {};
602
+ }
603
+ const text = reasoning
604
+ .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
605
+ .join("");
606
+ return { reasoning_content: text };
607
+ }
608
+
609
+ /**
610
+ * The tool arguments as the object the `tool_use` block expects.
611
+ *
612
+ * The canonical form keeps them as the raw JSON text the caller sent, which is
613
+ * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
614
+ * structured input, so the text is parsed back.
615
+ *
616
+ * The fallbacks mirror the upstream reference bridge rather than inventing
617
+ * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
618
+ * Smuggling the raw text through instead would emit an argument shape the
619
+ * upstream has never seen for this field.
620
+ */
621
+ /**
622
+ * Projects the canonical tools onto Qoder's chat-completions `tools` array.
623
+ *
624
+ * Without this the upstream is never told a tool exists, so a model that wants
625
+ * one improvises markup in its prose (`<Read file_path="..." />`) instead of
626
+ * emitting a structured call — the call is then neither runnable nor
627
+ * recoverable, because it never was a call on either side.
628
+ *
629
+ * Only `function` tools are representable upstream. Anything else is dropped
630
+ * rather than forwarded: Qoder rejects an unknown variant during
631
+ * deserialization and fails the whole turn, which is worse than omitting a
632
+ * tool the model could not have used anyway.
633
+ */
634
+ function qoderTools(
635
+ tools: readonly ToolDefinition[] | undefined,
636
+ ): Array<Record<string, unknown>> {
637
+ const out: Array<Record<string, unknown>> = [];
638
+ for (const tool of tools ?? []) {
639
+ if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
640
+ // The upstream requires `tools[].function` to be an object. A flat
641
+ // declaration naming the tool at the top level is rejected outright with
642
+ // "'function' is a required property, expected an object", which fails the
643
+ // whole request rather than just this tool.
644
+ out.push({
645
+ type: "function",
646
+ function: {
647
+ name: tool.name,
648
+ ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
649
+ parameters: tool.jsonSchema ?? { type: "object", properties: {} },
650
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
651
+ },
652
+ });
653
+ }
654
+ return out;
655
+ }
656
+
657
+ /**
658
+ * Projects the canonical tool choice onto the upstream's field.
659
+ *
660
+ * The upstream only accepts a string here. Given the object form
661
+ * `{type:"function",function:{name}}` it ignores the field, and the model then
662
+ * calls the tool with no arguments filled in.
663
+ *
664
+ * `allowed_tools` has no chat-completions equivalent, so it collapses to the
665
+ * direction it already names rather than being dropped: a caller that asked for
666
+ * `required` must not silently get `auto`.
667
+ */
668
+ function qoderToolChoice(choice: ToolChoice | undefined): unknown {
669
+ if (choice === undefined) return undefined;
670
+ if (typeof choice === "string") return choice;
671
+ if (choice.type === "tool" || choice.type === "custom") {
672
+ const name = choice.name.trim();
673
+ return name.length > 0 ? name : "auto";
674
+ }
675
+ return choice.mode;
676
+ }
677
+
678
+ /**
679
+ * Whether this request sends its tool history as structured turns.
680
+ *
681
+ * Qoder's wire is OpenAI-compatible `tool_calls` deltas on every realm; the
682
+ * model is fed the same structured turns in both INTL and CN. Writing a past
683
+ * call as prose (`[assistant 请求调用工具] …`) contaminates the conversation
684
+ * history with a text echo the model starts imitating — the origin of the
685
+ * text-leak feedback loop. Structured history keeps the model seeing only real
686
+ * tool calls, so it never learns the prose marker.
687
+ */
688
+ function qoderStructuredTools(_realm: QoderRealm): boolean {
689
+ return true;
690
+ }
691
+
692
+ export function buildQoderRequest(
693
+ modelId: string,
694
+ request: CanonicalRequest,
695
+ modelConfig: QoderModelConfig,
696
+ auth: QoderAuth,
697
+ realm: QoderRealm = "intl",
698
+ ): Record<string, unknown> {
699
+ const allMessages = request.messages;
700
+ const systemParts: string[] = [];
701
+ if (request.system) {
702
+ for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
703
+ }
704
+ if (request.instructions) {
705
+ for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
706
+ }
707
+ for (const m of allMessages) {
708
+ if (m.role === "system" || m.role === "developer") {
709
+ const t = flattenCanonicalContent(m.content);
710
+ if (t) systemParts.push(t);
711
+ }
712
+ }
713
+ const systemPrompt = systemParts.join("\n\n").trim();
714
+ const qoderMessages: Array<Record<string, unknown>> = [];
715
+ const structuredTools = qoderStructuredTools(realm);
716
+ // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
717
+ // trace, every assistant message must replay `reasoning_content` (see
718
+ // `qoderReasoningEcho`).
719
+ const hasReasoningTrace = allMessages.some((message) =>
720
+ message.content.some((part) => part.kind === "reasoning"),
721
+ );
722
+ if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
723
+ qoderMessages.push({
724
+ role: "system",
725
+ content: systemPrompt,
726
+ });
727
+ }
728
+ // Every tool result across the whole conversation, keyed by call id. An
729
+ // assistant `tool_calls` turn is only sent structured when every call has a
730
+ // matching result somewhere in the history — otherwise the upstream rejects
731
+ // the transcript ("An assistant message with 'tool_calls' must be followed by
732
+ // tool messages responding to each 'tool_call_id'"). Calls without a result
733
+ // are left out of the structured turn instead of poisoning it.
734
+ const toolResultsById = new Map<string, string>();
735
+ for (const msg of allMessages) {
736
+ for (const part of toolResultParts(msg)) {
737
+ if (typeof part.call_id === "string" && part.call_id.length > 0) {
738
+ toolResultsById.set(part.call_id, part.call_id);
739
+ }
740
+ }
741
+ }
742
+ // The tool calls that actually survive onto the wire. A call only makes it
743
+ // when every call on its assistant turn carries an id AND a matching result
744
+ // exists in the history. This is the single source of truth for both sides
745
+ // of the transcript: the assistant `tool_calls` emit and the `tool` result
746
+ // turns. If a result's call is not here, its `tool` turn is dropped too —
747
+ // leaving it would emit an orphan `tool` turn with no preceding call, which
748
+ // the upstream rejects ("Messages with role 'tool' must be a response to a
749
+ // preceding message with 'tool_calls'"). This is what a cancelled or
750
+ // compacted parallel call looks like, and it is the "insufficient tool
751
+ // messages" failure the client sees on the turn after.
752
+ const emittableCallIds = new Set<string>();
753
+ for (const msg of allMessages) {
754
+ if (msg.role !== "assistant") continue;
755
+ const calls = toolCallParts(msg);
756
+ if (calls.length === 0) continue;
757
+ const idsOk = calls.every(
758
+ (part) => typeof part.call_id === "string" && part.call_id.length > 0,
759
+ );
760
+ const everyCallHasResult = calls.every((part) =>
761
+ typeof part.call_id === "string" ? toolResultsById.has(part.call_id) : false,
762
+ );
763
+ if (idsOk && everyCallHasResult) {
764
+ for (const part of calls) emittableCallIds.add(part.call_id as string);
765
+ }
766
+ }
767
+ for (const m of allMessages) {
768
+ if (m.role === "system" || m.role === "developer") continue;
769
+ // Answers may live in a `user` turn (the Messages ledger re-homes them),
770
+ // so the result scan is role-agnostic. Each result becomes its own `tool`
771
+ // turn carrying its `tool_call_id`; any non-result content on the same turn
772
+ // is emitted after, so nothing is duplicated or lost.
773
+ const results = structuredTools ? toolResultParts(m) : [];
774
+ if (results.length > 0) {
775
+ let emittedAnyTool = false;
776
+ for (const block of results) {
777
+ // Drop orphan results: their assistant `tool_calls` turn was filtered
778
+ // out (a sibling call has no result), and emitting the `tool` turn
779
+ // anyway would make the upstream reject the whole transcript.
780
+ if (!emittableCallIds.has(block.call_id)) continue;
781
+ emittedAnyTool = true;
782
+ const text = flattenCanonicalContent(
783
+ typeof block.content === "string"
784
+ ? [{ kind: "text", text: block.content }]
785
+ : block.content,
786
+ );
787
+ qoderMessages.push({
788
+ role: "tool",
789
+ content: text,
790
+ tool_call_id: block.call_id,
791
+ });
792
+ }
793
+ // Text that shared the turn with a result is emitted after it, minus the
794
+ // result itself. Re-emitting the result's own text here was doubling
795
+ // every tool answer: the `tool` turn above and a `user` turn with the
796
+ // same content, which doubled the payload for no reason.
797
+ const remaining = m.content.filter((part) => part.kind !== "toolResult");
798
+ const flat = flattenCanonicalContent(remaining);
799
+ if (flat.length === 0 && !emittedAnyTool) continue;
800
+ if (flat.length > 0) {
801
+ qoderMessages.push({
802
+ role: "user",
803
+ content: flat,
804
+ });
805
+ }
806
+ continue;
807
+ }
808
+ // An assistant turn that called tools must carry the calls, not just its
809
+ // text. Falling through to the text-only emit dropped them, so the payload
810
+ // arrived as a `tool` result with no preceding call and the upstream
811
+ // rejected the whole transcript: "Messages with role 'tool' must be a
812
+ // response to a preceding message with 'tool_calls'".
813
+ const allCalls = m.content.filter((part) => part.kind === "toolCall");
814
+ const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
815
+ // Structured tool turns are only safe when every call carries an id AND a
816
+ // matching result exists in the history — the upstream rejects a transcript
817
+ // whose `tool_calls` is not fully answered by `tool` turns. The same set
818
+ // that gates the `tool` emit above decides this, so the two halves of the
819
+ // transcript can never disagree.
820
+ const calls =
821
+ structuredTools && allCalls.every((part) => emittableCallIds.has(part.call_id))
822
+ ? allCalls
823
+ : [];
824
+ if (calls.length > 0) {
825
+ const textParts = m.content.filter((part) => part.kind !== "toolCall");
826
+ const flat = flattenCanonicalContent(textParts);
827
+ qoderMessages.push({
828
+ role: "assistant",
829
+ content: flat,
830
+ ...reasoningEcho,
831
+ tool_calls: calls.map((part) => ({
832
+ id: part.call_id,
833
+ type: "function",
834
+ function: {
835
+ name: part.name,
836
+ arguments:
837
+ typeof part.arguments === "string"
838
+ ? part.arguments
839
+ : part.arguments !== undefined && part.arguments !== null
840
+ ? JSON.stringify(part.arguments)
841
+ : "",
842
+ },
843
+ })),
844
+ });
845
+ continue;
846
+ }
847
+ const role = m.role === "assistant" ? "assistant" : "user";
848
+ // Images must travel as structured OpenAI parts inside the message content —
849
+ // the upstream reads `image_url` parts there and not the top-level
850
+ // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
851
+ // bare file_id reference degrades to a text note instead of being dropped.
852
+ const hasImages = m.content.some((part) => part.kind === "image");
853
+ if (hasImages) {
854
+ const parts: Array<Record<string, unknown>> = [];
855
+ for (const part of m.content) {
856
+ if (part.kind === "text") {
857
+ parts.push({ type: "text", text: part.text });
858
+ } else if (part.kind === "image") {
859
+ const source = resolveImageSource(part.payload);
860
+ if (source?.url !== undefined) {
861
+ parts.push({ type: "image_url", image_url: { url: source.url } });
862
+ } else {
863
+ parts.push({
864
+ type: "text",
865
+ text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
866
+ });
867
+ }
868
+ }
869
+ }
870
+ qoderMessages.push({
871
+ role,
872
+ content: parts,
873
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
874
+ });
875
+ continue;
876
+ }
877
+ const flat = flattenCanonicalContent(m.content);
878
+ qoderMessages.push({
879
+ role,
880
+ content: flat,
881
+ ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
882
+ });
883
+ }
884
+ const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
885
+ const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
886
+ const maxTokens = qoderRequestedMaxTokens(request, modelConfig);
887
+ const reasoning = modelConfig.is_reasoning === true;
888
+ const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
889
+ const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
890
+ return {
891
+ request_id: randomUUID(),
892
+ request_set_id: recordId,
893
+ chat_record_id: recordId,
894
+ session_id: sessionId,
895
+ stream: true,
896
+ ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
897
+ chat_task: "FREE_INPUT",
898
+ is_reply: true,
899
+ is_retry: false,
900
+ source: 1,
901
+ version: "3",
902
+ session_type: "qodercli",
903
+ agent_id: "agent_common",
904
+ task_id: "common",
905
+ code_language: "",
906
+ chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
907
+ image_urls: null,
908
+ chat_context: {
909
+ chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
910
+ imageUrls: null,
911
+ extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
912
+ features: [],
913
+ // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
914
+ text: { text: latestUserText },
915
+ },
916
+ model_config: {
917
+ key: modelId,
918
+ display_name: modelConfig.display_name,
919
+ // Declares the wire dialect for the tool calls. Without it the upstream
920
+ // does not read the OpenAI-shaped `tools` array the same way, and the
921
+ // model then emits a call with no arguments filled in.
922
+ format: "openai",
923
+ model: "",
924
+ api_key: "",
925
+ url: "",
926
+ is_vl: modelConfig.is_vl,
927
+ is_reasoning: modelConfig.is_reasoning,
928
+ max_input_tokens: modelConfig.max_input_tokens,
929
+ // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
930
+ // for every model alike, so the request would declare a cap the catalog
931
+ // does not publish.
932
+ max_output_tokens: modelConfig.max_output_tokens ?? 65_536,
933
+ source: modelConfig.source ?? "system",
934
+ },
935
+ messages: qoderMessages,
936
+ max_tokens: maxTokens,
937
+ // Always present, even when empty. Omitting the field makes Qoder fall back
938
+ // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
939
+ // caller that declared none — the reference bridge sends `[]` for exactly
940
+ // that reason. Parallel calls are stated explicitly rather than left to the
941
+ // upstream default, so a caller expecting them is not served serial
942
+ // behaviour.
943
+ tools: qoderTools(request.tools),
944
+ parallel_tool_calls: true,
945
+ ...(() => {
946
+ const choice = qoderToolChoice(request.tool_choice);
947
+ return choice === undefined ? {} : { tool_choice: choice };
948
+ })(),
949
+ };
950
+ }
951
+
952
+ // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
953
+
954
+ function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
955
+ if (raw === "length") return "length";
956
+ if (raw === "tool_calls") return "tool_use";
957
+ if (raw === "content_filter") return "content_filter";
958
+ return undefined;
959
+ }
960
+
961
+ export async function* qoderBodyToCanonicalEvents(
962
+ body: ReadableStream<Uint8Array>,
963
+ signal: AbortSignal,
964
+ model: string,
965
+ ): AsyncIterable<CanonicalEvent> {
966
+ let seq = 1;
967
+ yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
968
+ let finishReason: unknown = undefined;
969
+ let rawUsage: Record<string, unknown> | undefined = undefined;
970
+ let hasTerminal = false;
971
+ /** First-sighting id per wire tool-call index, for continuation chunks. */
972
+ const indexIds = new Map<number, string>();
973
+ /** Last seen call id/name, for index-less continuation fragments. */
974
+ let lastSeenToolId: string | undefined = undefined;
975
+ let lastSeenToolName: string | undefined = undefined;
976
+ // INTL leak-marker hold-back: text that may grow into the marker+JSON echo
977
+ // is withheld from the client until end of stream proves it is a tool call
978
+ // (recovered) or ordinary prose (released).
979
+ let leakBuf = "";
980
+ let leakFrames = 0;
981
+
982
+ for await (const sse of decodeSseEvents(body, { signal })) {
983
+ const sseData = sse.data;
984
+ let frames: Array<{ event: string | null; data: string }>;
985
+ try {
986
+ frames = qoderEnvelopeToFrames(sseData);
987
+ } catch (e) {
988
+ throw e;
989
+ }
990
+ for (const frame of frames) {
991
+ const data = frame.data.trim();
992
+ if (!data) continue;
993
+ if (data === "[DONE]") {
994
+ hasTerminal = true;
995
+ continue;
996
+ }
997
+ let json: Record<string, unknown>;
998
+ try {
999
+ json = JSON.parse(data) as Record<string, unknown>;
1000
+ } catch {
1001
+ continue;
1002
+ }
1003
+ if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
1004
+ const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
1005
+ for (const choice of choices) {
1006
+ const delta = choice["delta"] as Record<string, unknown> | undefined;
1007
+ if (delta) {
1008
+ if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1009
+ const piece = delta["content"] as string;
1010
+ // Marker+JSON hold-back. A complete marker and everything after it is
1011
+ // held until end of stream; prose before it is emitted now.
1012
+ if (leakBuf.length > 0) {
1013
+ leakBuf += piece;
1014
+ leakFrames = piece.length === 0 ? leakFrames + 1 : 0;
1015
+ if (
1016
+ leakBuf.length > LEAK_HOLD_MAX_CHARS ||
1017
+ leakFrames > LEAK_HOLD_MAX_FRAMES ||
1018
+ !qoderStillLeakCandidate(leakBuf)
1019
+ ) {
1020
+ if (leakBuf.length > 0) {
1021
+ yield {
1022
+ type: "content_delta",
1023
+ sequence_number: seq++,
1024
+ content: { kind: "text", text: leakBuf },
1025
+ } as CanonicalEvent;
1026
+ }
1027
+ leakBuf = "";
1028
+ leakFrames = 0;
1029
+ }
1030
+ continue;
1031
+ }
1032
+ const marker = LEAK_MARKER_ANY_RE.exec(piece);
1033
+ if (marker) {
1034
+ const emit = piece.slice(0, marker.index);
1035
+ if (emit.length > 0) {
1036
+ yield {
1037
+ type: "content_delta",
1038
+ sequence_number: seq++,
1039
+ content: { kind: "text", text: emit },
1040
+ } as CanonicalEvent;
1041
+ }
1042
+ leakBuf = piece.slice(marker.index);
1043
+ leakFrames = 1;
1044
+ continue;
1045
+ }
1046
+ // No marker: hold only a possible partial-marker suffix.
1047
+ const tail = piece.slice(-LEAK_HOLD_WINDOW);
1048
+ let heldFrom = -1;
1049
+ for (let n = tail.length; n >= 1; n -= 1) {
1050
+ if (isLeakMarkerPrefix(tail.slice(-n))) {
1051
+ heldFrom = piece.length - n;
1052
+ break;
1053
+ }
1054
+ }
1055
+ if (heldFrom >= 0) {
1056
+ const emit = piece.slice(0, heldFrom);
1057
+ if (emit.length > 0) {
1058
+ yield {
1059
+ type: "content_delta",
1060
+ sequence_number: seq++,
1061
+ content: { kind: "text", text: emit },
1062
+ } as CanonicalEvent;
1063
+ }
1064
+ leakBuf = piece.slice(heldFrom);
1065
+ leakFrames = 1;
1066
+ continue;
1067
+ }
1068
+ yield {
1069
+ type: "content_delta",
1070
+ sequence_number: seq++,
1071
+ content: { kind: "text", text: piece },
1072
+ } as CanonicalEvent;
1073
+ continue;
1074
+ }
1075
+ const reasoningText = readReasoningText(delta);
1076
+ if (typeof reasoningText === "string" && reasoningText.length > 0) {
1077
+ yield {
1078
+ type: "content_delta",
1079
+ sequence_number: seq++,
1080
+ content: { kind: "reasoning", payload: null, summary: reasoningText },
1081
+ } as CanonicalEvent;
1082
+ continue;
1083
+ }
1084
+ const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1085
+ if (toolCalls) {
1086
+ for (const tc of toolCalls) {
1087
+ const fn = tc["function"] as Record<string, unknown> | undefined;
1088
+ const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1089
+ const declaredId = typeof tc["id"] === "string" ? (tc["id"] as string) : undefined;
1090
+ const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1091
+ // OpenAI continuation chunks re-identify their call by wire
1092
+ // `index` alone — the `id` appears only once, in the first chunk.
1093
+ // Some bridges omit `index` too, so a fragment then falls back to
1094
+ // the most recently seen call id (mirroring the generic chat
1095
+ // parser's `lastSeenToolId`). Pinning the first id per index and
1096
+ // reusing it for continuations is what keeps fragments attached
1097
+ // to the right call; synthesizing a fresh `call_<index>` id per
1098
+ // chunk instead made every continuation an unknown nameless call
1099
+ // that encoders dropped, so the client saw a call whose arguments
1100
+ // were always empty.
1101
+ let callId: string;
1102
+ if (declaredId !== undefined) {
1103
+ callId = declaredId;
1104
+ if (index !== undefined) indexIds.set(index, callId);
1105
+ lastSeenToolId = callId;
1106
+ if (fragName !== undefined) lastSeenToolName = fragName;
1107
+ } else if (index !== undefined && indexIds.has(index)) {
1108
+ callId = indexIds.get(index)!;
1109
+ } else if (
1110
+ lastSeenToolId !== undefined &&
1111
+ (fragName === undefined || lastSeenToolName === fragName)
1112
+ ) {
1113
+ callId = lastSeenToolId;
1114
+ } else {
1115
+ callId = `call_${String(index ?? 0)}`;
1116
+ }
1117
+ yield {
1118
+ type: "tool_call_delta",
1119
+ sequence_number: seq++,
1120
+ call_id: callId,
1121
+ ...(index !== undefined ? { index } : {}),
1122
+ ...(fragName === undefined ? {} : { name: fragName }),
1123
+ arguments_delta: (fn?.["arguments"] as string) ?? "",
1124
+ } as CanonicalEvent;
1125
+ }
1126
+ }
1127
+ }
1128
+ const fr = choice["finish_reason"];
1129
+ if (fr !== null && fr !== undefined) finishReason = fr;
1130
+ // Some providers send usage in choices[0].usage or top-level
1131
+ if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
1132
+ }
1133
+ // Some Qoder chunks carry finish_reason at top level
1134
+ if (json["finish_reason"] !== undefined) finishReason = json["finish_reason"];
1135
+ }
1136
+ }
1137
+ // End of stream: recover a held-back marker+JSON echo. A valid echo becomes
1138
+ // tool calls; anything else is released as ordinary prose (or fails closed
1139
+ // when it is unmistakably an echo that cannot be parsed).
1140
+ if (leakBuf.length > 0) {
1141
+ const leaked = parseQoderTextToolCalls(leakBuf);
1142
+ if (leaked.calls.length > 0) {
1143
+ for (const call of leaked.calls) {
1144
+ yield {
1145
+ type: "tool_call_delta",
1146
+ sequence_number: seq++,
1147
+ call_id: call.id,
1148
+ name: call.name,
1149
+ arguments_delta: call.arguments,
1150
+ } as CanonicalEvent;
1151
+ }
1152
+ finishReason = "tool_calls";
1153
+ } else {
1154
+ // An echo that could not be recovered must fail the turn (502) so the
1155
+ // harness retries — never surface protocol text as visible content.
1156
+ if (isQoderEchoAttempt(leakBuf)) {
1157
+ throw badEchoError();
1158
+ }
1159
+ const visible = leaked.clean.trim();
1160
+ if (visible.length > 0) {
1161
+ yield {
1162
+ type: "content_delta",
1163
+ sequence_number: seq++,
1164
+ content: { kind: "text", text: visible },
1165
+ } as CanonicalEvent;
1166
+ }
1167
+ }
1168
+ }
1169
+ // If no terminal arrived, synthesize one (prevents truncation error)
1170
+ const stopReason = mapQoderFinishReason(finishReason);
1171
+ const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1172
+ void hasTerminal;
1173
+ yield {
1174
+ type: "terminal",
1175
+ sequence_number: seq++,
1176
+ state: "complete",
1177
+ ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
1178
+ ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
1179
+ usage: usageFromProvider(rawUsage),
1180
+ } as CanonicalEvent;
1181
+ }
1182
+
1183
+ // Model catalog — maps QODER_MODEL_CONFIGS (provider) to Cartethyia ModelDefinition
1184
+
1185
+ /**
1186
+ * The intl catalog this provider publishes.
1187
+ *
1188
+ * `QODER_MODEL_CONFIGS` is not the source here: it freezes most rows at 180k
1189
+ * while the upstream reports 1M for the same keys. That number is not
1190
+ * cosmetic: it is sent upstream as `model_config.max_input_tokens`, so a stale
1191
+ * low value tells Qoder the conversation window is small and makes it behave
1192
+ * that way. Both the published catalog and the dispatched request come from the
1193
+ * realm's roster, which carries the upstream's own numbers.
1194
+ */
1195
+ export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
1196
+
1197
+ // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
1198
+
1199
+ interface QoderAdapterOptions {
1200
+ readonly fetch?: typeof fetch;
1201
+ /** Which deployment this adapter dispatches to. Defaults to intl. */
1202
+ readonly realm?: QoderRealm;
1203
+ /** Bundled provider id, so the adapter's own id matches the route's. */
1204
+ readonly providerId?: string;
1205
+ }
1206
+
1207
+ class QoderAdapter implements ProviderAdapter {
1208
+ readonly provider_id: ProviderId;
1209
+ private readonly realm: QoderRealm;
1210
+ private readonly fetchFn: typeof fetch;
1211
+
1212
+ constructor(options: QoderAdapterOptions = {}) {
1213
+ this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
1214
+ this.realm = options.realm ?? "intl";
1215
+ this.fetchFn = options.fetch ?? globalThis.fetch;
1216
+ }
1217
+
1218
+ /**
1219
+ * The static roster for this adapter's realm.
1220
+ *
1221
+ * A single merged table advertised CN-only keys to global accounts and vice
1222
+ * versa, so a request was routed to a deployment that refuses it.
1223
+ */
1224
+ #modelConfigs(): Record<string, QoderModelConfig> {
1225
+ return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
1226
+ string,
1227
+ QoderModelConfig
1228
+ >;
1229
+ }
1230
+
1231
+ async *dispatch(
1232
+ request: CanonicalRequest,
1233
+ candidate: ProviderDispatchTarget,
1234
+ context: ProviderDispatchContext,
1235
+ ): AsyncIterable<CanonicalEvent> {
1236
+ if (candidate.wire_family !== "chat") {
1237
+ throw new GatewayError("capability_unsupported", 400, `qoder supports chat only, got ${candidate.wire_family}`);
1238
+ }
1239
+ const secret = readCredentialSecret(context.credential, "Qoder credential is required");
1240
+
1241
+ /**
1242
+ * A caller-facing virtual name is translated to its roster key here.
1243
+ *
1244
+ * The translation has to happen before the config lookup and before the
1245
+ * request body: the body carries the roster key, and looking up the
1246
+ * virtual id in the config table would fall through to the synthetic
1247
+ * default and silently dispatch under-declared capabilities.
1248
+ */
1249
+ const requestedModelId = candidate.model_id || request.model;
1250
+ if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
1251
+ throw new GatewayError(
1252
+ "model_not_found",
1253
+ 404,
1254
+ `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve`,
1255
+ { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
1256
+ "upstream",
1257
+ );
1258
+ }
1259
+ const modelId = qoderDispatchModelId(requestedModelId);
1260
+ const modelConfig = this.#modelConfigs()[modelId] ?? {
1261
+ id: modelId,
1262
+ display_name: modelId,
1263
+ max_input_tokens: 180_000,
1264
+ is_vl: true,
1265
+ is_reasoning: false,
1266
+ };
1267
+
1268
+ const lifecycle = createUpstreamDeadlineLifecycle(context);
1269
+
1270
+ // Outbound fetch respects SSRF binding when provided
1271
+ const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
1272
+
1273
+ const fetcher: QoderFetcher = (url, init) =>
1274
+ outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
1275
+
1276
+ try {
1277
+ const version = getQoderVersion();
1278
+ // Dual mode like Cline: a pasted PAT is exchanged for a job token per
1279
+ // request, while an OAuth device token is used directly with the
1280
+ // identity the login persisted.
1281
+ const auth = isQoderPat(secret)
1282
+ ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
1283
+ : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
1284
+ const qoderBody = buildQoderRequest(modelId, request, modelConfig, auth, this.realm);
1285
+ // A full URL from the route wins, but the fallback is this realm's
1286
+ // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1287
+ // a CN credential to `*.qoder.sh` is refused outright. The host order
1288
+ // follows the token kind: job tokens (`jt-...`) belong on `api2`, which
1289
+ // is the only intl host that serves them (`api3` answers "Login expired").
1290
+ const url =
1291
+ candidate.endpoint_path && candidate.endpoint_path.startsWith("http")
1292
+ ? candidate.endpoint_path
1293
+ : `${qoderInferenceCandidates(this.realm, auth.securityOauthToken)[0] ?? ""}${QODER_CHAT_PATH}`;
1294
+
1295
+ const response = await callQoder(url, qoderBody, modelId, auth, lifecycle.signal, fetcher, version);
1296
+ if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
1297
+ if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body", {}, "upstream");
1298
+ // Headers arrived: the pre-stream deadline has served its purpose.
1299
+ // From here the gateway stall/first-chunk watchdog (propagated via
1300
+ // context.abort_signal) governs the body. Keeping this timer armed
1301
+ // would kill healthy long streams at the stale pre-stream deadline.
1302
+ lifecycle.release();
1303
+ yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
1304
+ } catch (err: unknown) {
1305
+ const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
1306
+ if (abortError) throw abortError;
1307
+ throw err;
1308
+ } finally {
1309
+ lifecycle.release();
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1315
+ return new QoderAdapter(options);
1316
+ }
1317
+
1318
+ /**
1319
+ * Adapter for the China deployment.
1320
+ *
1321
+ * Same wire, different gateway host and roster — see `qoder-realm.ts`.
1322
+ */
1323
+ export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1324
+ return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
1325
+ }
1326
+
1327
+ /**
1328
+ * A COSY signer for one already-resolved account identity.
1329
+ *
1330
+ * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
1331
+ * import this module (the adapter imports its rosters — that would be a cycle).
1332
+ * Exporting the signer keeps one signing implementation and lets discovery
1333
+ * stay pure and injectable.
1334
+ */
1335
+ export function qoderSignerFor(
1336
+ auth: QoderAuth,
1337
+ version = getQoderVersion(),
1338
+ ): (url: string, body: string) => Record<string, string> {
1339
+ return (url: string, body: string): Record<string, string> =>
1340
+ qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
1341
+ }
1342
+
1343
+ /** Qoder's own base64 alphabet transform, for a request body. */
1344
+ export function qoderEncodeBody(plaintext: Uint8Array | string): string {
1345
+ const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
1346
+ return Buffer.from(transformQoderBody(text)).toString("latin1");
1347
+ }
1348
+
1349
+ /**
1350
+ * Resolves the dispatch identity for one Qoder credential.
1351
+ *
1352
+ * Shared by the adapter and by discovery: both need the same `QoderAuth` the
1353
+ * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
1354
+ * let the two drift.
1355
+ */
1356
+ export async function resolveQoderAuth(input: {
1357
+ readonly secret: string;
1358
+ readonly authState: unknown;
1359
+ readonly signal: AbortSignal;
1360
+ readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
1361
+ /** Which deployment mints the job token; a PAT is realm-bound. */
1362
+ readonly realm?: QoderRealm;
1363
+ }): Promise<QoderAuth> {
1364
+ const realm = input.realm ?? "intl";
1365
+ if (isQoderPat(input.secret)) {
1366
+ return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
1367
+ }
1368
+ // Discovery is handed only the secret and the persisted state, so the
1369
+ // account id is read from that state — the same place `qoderOAuthAuth` reads
1370
+ // it — rather than being a separate field callers can pass wrongly.
1371
+ const state = parseQoderOAuthState(input.authState);
1372
+ return qoderOAuthAuth(input.secret, input.authState, state?.userId);
1373
+ }