cartethyia 2.7.6 → 2.7.7

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 (126) hide show
  1. package/.env.example +4 -2
  2. package/README.md +25 -1
  3. package/dist/dashboard/assets/{BannedPage-B1N603cM.js → BannedPage-B05aF8Dg.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-B05aF8Dg.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-9q4_4NoK.js → CliToolDetailPage-CLUhmjUy.js} +2 -2
  6. package/dist/dashboard/assets/CliToolDetailPage-CLUhmjUy.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-DwshGtK-.js → CliToolsPage-CQmrqqrk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-CQmrqqrk.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-D762MIkp.js → CombosPage-CHG_1Wr3.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CHG_1Wr3.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-KRYjHZsX.js → ConsoleLogPage-DPgbklad.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-DPgbklad.js.gz +0 -0
  13. package/dist/dashboard/assets/CustomizationPage-pnfAqr_-.js +1 -0
  14. package/dist/dashboard/assets/CustomizationPage-pnfAqr_-.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-DGJuU2A6.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-DGJuU2A6.js.gz +0 -0
  17. package/dist/dashboard/assets/{InjectSystemPromptPanel-d5v3K_Yc.js → InjectSystemPromptPanel-C-e2AMz6.js} +2 -2
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-C-e2AMz6.js.gz +0 -0
  19. package/dist/dashboard/assets/{LoginPage-Ccv4tjNS.js → LoginPage-J8wPbmfT.js} +1 -1
  20. package/dist/dashboard/assets/LoginPage-J8wPbmfT.js.gz +0 -0
  21. package/dist/dashboard/assets/{OverviewPage-DN9fYNWA.js → OverviewPage-BSQnF2Lp.js} +1 -1
  22. package/dist/dashboard/assets/OverviewPage-BSQnF2Lp.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DK_e0FJz.js +7 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-DK_e0FJz.js.gz +0 -0
  25. package/dist/dashboard/assets/ProvidersPage-D_a3r6Gc.js +1 -0
  26. package/dist/dashboard/assets/ProvidersPage-D_a3r6Gc.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-DxjvUzSD.js → ProxyPage-BBTRBAQ3.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BBTRBAQ3.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B2WqMSG1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B2WqMSG1.js.gz +0 -0
  31. package/dist/dashboard/assets/{SettingsPage-BW4AKj2H.js → SettingsPage-D7xp5Ij_.js} +1 -1
  32. package/dist/dashboard/assets/SettingsPage-D7xp5Ij_.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-44BZrxFC.js → SetupPage-LND6_Ss7.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-LND6_Ss7.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-DRPavCdU.js → StudioPage-BzNnm6Bi.js} +2 -2
  36. package/dist/dashboard/assets/StudioPage-BzNnm6Bi.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-B7M7LvMM.js → UsagePage-C1TbL24N.js} +2 -2
  38. package/dist/dashboard/assets/UsagePage-C1TbL24N.js.gz +0 -0
  39. package/dist/dashboard/assets/{entry-D6WvojeD.js → entry-vqGb8yVz.js} +3 -3
  40. package/dist/dashboard/assets/entry-vqGb8yVz.js.gz +0 -0
  41. package/dist/dashboard/assets/formatters-DP-5E01-.js +1 -0
  42. package/dist/dashboard/assets/formatters-DP-5E01-.js.gz +0 -0
  43. package/dist/dashboard/assets/{index-Ko4dLRnV.js → index-D36vCZht.js} +2 -2
  44. package/dist/dashboard/assets/index-D36vCZht.js.gz +0 -0
  45. package/dist/dashboard/assets/{select-BHqpd-bg.js → select-C9Ssle9b.js} +1 -1
  46. package/dist/dashboard/assets/select-C9Ssle9b.js.gz +0 -0
  47. package/dist/dashboard/index.html +2 -2
  48. package/dist/dashboard/index.html.gz +0 -0
  49. package/dist/dashboard/precompressed-assets.json +22 -22
  50. package/migrations/full-postgres/0043_add_provider_custom_user_agent.sql +14 -0
  51. package/migrations/lite-sqlite3/0043_add_provider_custom_user_agent.sql +10 -0
  52. package/package.json +3 -1
  53. package/src/config/security.ts +1 -1
  54. package/src/config/spec.ts +13 -6
  55. package/src/console/cli-tools/cartethyia-websearch-plugin.ts +186 -0
  56. package/src/console/cli-tools/injectors/opencode.ts +42 -14
  57. package/src/console/providers/catalog/contracts.ts +4 -0
  58. package/src/console/providers/catalog/routes.ts +1 -0
  59. package/src/console/providers/detail/routes.ts +8 -0
  60. package/src/console/providers/detail/store.ts +4 -0
  61. package/src/console/quota/account-quota-tenant.ts +74 -0
  62. package/src/observability/telemetry-buffer.ts +2 -1
  63. package/src/persistence/boot.ts +13 -4
  64. package/src/persistence/schema.ts +7 -1
  65. package/src/protocol/canonical-model.ts +7 -0
  66. package/src/protocol/request/gemini.ts +20 -1
  67. package/src/protocol/tool-identity.ts +15 -1
  68. package/src/providers/default-registry.ts +8 -2
  69. package/src/providers/discovery/discovery-types.ts +18 -0
  70. package/src/providers/discovery/probe-phases.ts +45 -0
  71. package/src/providers/discovery/probing-service.ts +41 -6
  72. package/src/providers/integrations/antigravity/antigravity-protocol.ts +20 -2
  73. package/src/providers/integrations/antigravity/antigravity.ts +24 -0
  74. package/src/providers/integrations/codex/codex.ts +28 -1
  75. package/src/providers/integrations/qoder/chat-proto.ts +328 -0
  76. package/src/providers/integrations/qoder/claim-qoder-credits.ts +258 -0
  77. package/src/providers/integrations/qoder/qoder-adapter.ts +275 -0
  78. package/src/providers/integrations/qoder/qoder-auth.ts +201 -0
  79. package/src/providers/integrations/qoder/qoder-body.ts +78 -0
  80. package/src/providers/integrations/qoder/qoder-catalog.ts +5 -1
  81. package/src/providers/integrations/qoder/qoder-chat.ts +514 -0
  82. package/src/providers/integrations/qoder/qoder-cosy.ts +162 -0
  83. package/src/providers/integrations/qoder/qoder-grpc.ts +667 -0
  84. package/src/providers/integrations/qoder/qoder-ide.ts +584 -0
  85. package/src/providers/integrations/qoder/qoder-oauth.ts +4 -4
  86. package/src/providers/integrations/qoder/qoder-quota.ts +1 -1
  87. package/src/providers/integrations/qoder/qoder-realm.ts +40 -4
  88. package/src/providers/integrations/qoder/qoder-types.ts +51 -0
  89. package/src/providers/integrations/qoder/qoder-umid.ts +123 -8
  90. package/src/providers/integrations/qoder/qoder.ts +147 -1889
  91. package/src/providers/provider-contracts.ts +7 -0
  92. package/src/providers/provider-usage.ts +9 -6
  93. package/src/security/abuse.ts +5 -5
  94. package/src/transport/dispatch/upstream.ts +4 -2
  95. package/src/transport/routing/route-catalog.ts +8 -0
  96. package/src/transport/routing/route-model.ts +4 -0
  97. package/src/transport/routing/router.ts +8 -117
  98. package/dist/dashboard/assets/BannedPage-B1N603cM.js.gz +0 -0
  99. package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js.gz +0 -0
  100. package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js.gz +0 -0
  101. package/dist/dashboard/assets/CombosPage-D762MIkp.js.gz +0 -0
  102. package/dist/dashboard/assets/ConsoleLogPage-KRYjHZsX.js.gz +0 -0
  103. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js +0 -1
  104. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js.gz +0 -0
  105. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js +0 -1
  106. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js.gz +0 -0
  107. package/dist/dashboard/assets/InjectSystemPromptPanel-d5v3K_Yc.js.gz +0 -0
  108. package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js.gz +0 -0
  109. package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js.gz +0 -0
  110. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js +0 -5
  111. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js.gz +0 -0
  112. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js +0 -1
  113. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js.gz +0 -0
  114. package/dist/dashboard/assets/ProxyPage-DxjvUzSD.js.gz +0 -0
  115. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js +0 -1
  116. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js.gz +0 -0
  117. package/dist/dashboard/assets/SettingsPage-BW4AKj2H.js.gz +0 -0
  118. package/dist/dashboard/assets/SetupPage-44BZrxFC.js.gz +0 -0
  119. package/dist/dashboard/assets/StudioPage-DRPavCdU.js.gz +0 -0
  120. package/dist/dashboard/assets/UsagePage-B7M7LvMM.js.gz +0 -0
  121. package/dist/dashboard/assets/entry-D6WvojeD.js.gz +0 -0
  122. package/dist/dashboard/assets/formatters-u7JPB5BX.js +0 -1
  123. package/dist/dashboard/assets/formatters-u7JPB5BX.js.gz +0 -0
  124. package/dist/dashboard/assets/index-Ko4dLRnV.js.gz +0 -0
  125. package/dist/dashboard/assets/select-BHqpd-bg.js.gz +0 -0
  126. package/src/providers/integrations/qoder/qoder-text-tools.ts +0 -277
@@ -1,1892 +1,150 @@
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 { reasoningEffortFromIntent } from "../../../protocol/reasoning";
16
- import type {
17
- CanonicalEvent,
18
- CanonicalRequest,
19
- CanonicalStopReason,
20
- ContentPart,
21
- ToolChoice,
22
- ToolDefinition,
23
- } from "../../../protocol/canonical-model";
24
- import { toolResultParts, toolCallParts } from "../../../protocol/canonical-model";
25
- import { decodeQoderSseEvents } from "./qoder-sse";
26
- import { usageFromProvider, readReasoningText } from "../../provider-usage";
27
- import { readCredentialSecret, assertWireFamily, type ProviderDispatchTarget, type ProviderAdapter, type ProviderDispatchContext, type ProviderId } from "../../provider-contracts";
28
- import type { ModelDefinition } from "../../provider-model-definition";
29
- import { parseQoderOAuthState } from "./qoder-oauth";
30
- import { getQoderVersion } from "../../operations/client-versions";
31
- import { abortGatewayError, createUpstreamDeadlineLifecycle } from "../../operations/upstream-deadline";
32
- import { qoderMachineOs, qoderMachineHostname } from "../../operations/cli-platform";
33
- import { qoderIdeChatUrl, qoderInferenceCandidates, QODER_CHAT_PATH, QODER_REALMS, type QoderRealm } from "./qoder-realm";
34
- import {
35
- QODER_CN_MODEL_CONFIGS,
36
- QODER_INTL_MODEL_CONFIGS,
1
+ /**
2
+ * Qoder integration — public barrel.
3
+ *
4
+ * Re-exports every public API of the integration. The folder was restructured
5
+ * (2026-11-05) into focused modules; this barrel keeps every existing import
6
+ * path (`src/providers/integrations/qoder/qoder.ts`) working unchanged.
7
+ *
8
+ * Surface contract (see `qoder-adapter.ts`):
9
+ * - `grpc` — default billed carrier (1M-token context).
10
+ * - `cosy` — free/lite + forced legacy (COSY Bearer signing).
11
+ * - `ide` — probe/testing only, never production.
12
+ */
13
+
14
+ // Auth: PAT detection, machine id, OAuth auth, PAT→jt exchange v1, session id
15
+ export {
16
+ isQoderPat,
17
+ qoderMachineIdForAccount,
18
+ qoderOAuthAuth,
19
+ qoderSessionId,
20
+ resolveQoderAuth,
21
+ } from "./qoder-auth";
22
+ export type { QoderAuth, QoderModelConfig } from "./qoder-types";
23
+
24
+ // COSY signing: alphabet transform, Bearer headers, signer, body encode
25
+ export {
26
+ qoderBuildCosyHeaders,
27
+ transformQoderBody,
28
+ qoderEncodeBody,
29
+ qoderSignerFor,
30
+ } from "./qoder-cosy";
31
+
32
+ // Shared message helpers
33
+ export { flattenCanonicalContent, qoderTools, qoderToolChoice } from "./qoder-body";
34
+
35
+ // Chat builder + legacy COSY transport
36
+ export { buildQoderRequest, callQoder } from "./qoder-chat";
37
+ export type { QoderModeProfile } from "./qoder-chat";
38
+ export { MODERN_PROFILE } from "./qoder-chat";
39
+
40
+ // IDE surface (probe/testing only)
41
+ export {
42
+ buildQoderIdeRequest,
43
+ qoderBodyToCanonicalEvents,
44
+ qoderEnvelopeToFrames,
45
+ fetchQoderIdeWithEmptyRecovery,
46
+ } from "./qoder-ide";
47
+
48
+ // gRPC transport
49
+ export {
50
+ buildQoderGrpcRequest,
51
+ qoderGrpcMetadata,
52
+ qoderGrpcToCanonicalEvents,
53
+ qoderGrpcClient,
54
+ qoderGrpcChannelOptions,
55
+ fromProtoStruct,
56
+ QoderToolCallBuffer,
57
+ resolveQoderToolFragmentId,
58
+ } from "./qoder-grpc";
59
+ export type { QoderProtoToolFragment, QoderDrainedToolCall, QoderToolFragmentState } from "./qoder-grpc";
60
+
61
+ // Adapter + transport selection
62
+ export { createQoderAdapter, createQoderCnAdapter, resolveQoderTransport } from "./qoder-adapter";
63
+
64
+ // Realm constants & helpers
65
+ export {
66
+ QODER_CHAT_PATH,
67
+ QODER_MODELS_PATH,
68
+ QODER_IDE_CHAT_PATH,
69
+ QODER_REALMS,
70
+ QODER_PROVIDER_ID,
71
+ qoderIdeChatUrl,
72
+ qoderGrpcHost,
73
+ qoderRealmForProvider,
74
+ qoderGatewayCandidates,
75
+ qoderInferenceCandidates,
76
+ } from "./qoder-realm";
77
+ export type { QoderRealm, QoderRealmConfig } from "./qoder-realm";
78
+
79
+ // Catalog & virtual models
80
+ export {
37
81
  QODER_INTL_MODELS,
82
+ QODER_INTL_MODELS as QODER_MODELS,
83
+ QODER_CN_MODELS,
84
+ QODER_INTL_MODEL_CONFIGS,
85
+ QODER_CN_MODEL_CONFIGS,
86
+ discoverQoderModels,
87
+ mapQoderCatalogResponse,
88
+ qoderCatalogModel,
89
+ qoderMaxContextWindow,
90
+ qoderVirtualModels,
38
91
  qoderDiscoveredMaxContexts,
92
+ resetQoderDiscoveryCache,
39
93
  } from "./qoder-catalog";
40
- import { isQoderVirtualModel, qoderDispatchModelId } from "./qoder-virtual";
41
- import {
42
- LEAK_MARKER_ANY_RE,
43
- LEAK_MARKER_LITERALS,
44
- parseQoderTextToolCalls,
45
- isQoderEchoAttempt,
46
- } from "./qoder-text-tools";
47
-
48
- /** How long a trailing partial marker may be before it is held for the next frame. */
49
- const LEAK_HOLD_WINDOW = 24;
50
- /** Fail-open caps so an endless lookalike cannot hold text forever. */
51
- const LEAK_HOLD_MAX_CHARS = 32_768;
52
- const LEAK_HOLD_MAX_FRAMES = 200;
53
-
54
- const BAD_ECHO_MESSAGE =
55
- "Qoder returned an unparseable tool-call echo (request {requestId}); no tools were emitted. The upstream came back empty-handed — retry 🌙";
56
-
57
- function badEchoError(): GatewayError {
58
- return new GatewayError(
59
- "platform_unavailable",
60
- 502,
61
- BAD_ECHO_MESSAGE,
62
- { providerId: QODER_PROVIDER_ID },
63
- "upstream",
64
- );
65
- }
66
-
67
- /** True when `suffix` is a strict prefix of some marker literal. */
68
- function isLeakMarkerPrefix(suffix: string): boolean {
69
- if (suffix.length === 0 || suffix.length >= LEAK_HOLD_WINDOW) return false;
70
- return LEAK_MARKER_LITERALS.some((literal) => literal.startsWith(suffix));
71
- }
72
-
73
- /**
74
- * True when `text` is still plausibly the INTL marker echo block (marker +
75
- * JSON array/object, or a tool-style tag). Used to keep holding across frames;
76
- * false means it is ordinary prose and can be released.
77
- */
78
- function qoderStillLeakCandidate(text: string): boolean {
79
- if (text.length === 0) return true;
80
- const marker = LEAK_MARKER_ANY_RE.exec(text);
81
- if (!marker) {
82
- return isLeakMarkerPrefix(text) || LEAK_MARKER_ANY_RE.test(text);
83
- }
84
- const rest = text.slice(marker.index + marker[0].length).trim();
85
- if (rest.length === 0) return true;
86
- if (rest.startsWith("[") || rest.startsWith("{")) return true;
87
- if (/^<[a-zA-Z0-9_.:-]+\b/.test(rest)) return true;
88
- // Angle-wrapped JSON: `<{` / `<[` — candidate until a closing `>` appears.
89
- if (rest.startsWith("<{") || rest.startsWith("<[")) {
90
- return !rest.includes(">") || rest.endsWith(">");
91
- }
92
- return false;
93
- }
94
-
95
- interface QoderModeProfile {
96
- readonly chatUrl: string;
97
- readonly businessProduct: string;
98
- readonly businessType: string;
99
- readonly businessVersion: string;
100
- readonly cosyScene: string;
101
- readonly mirrorTopLevelSystem: boolean;
102
- readonly sendBusinessHeaders: boolean;
103
- readonly sendModelSourceHeaders: boolean;
104
- readonly emptyAliyunUserType: boolean;
105
- }
106
-
107
- export const MODERN_PROFILE: QoderModeProfile = {
108
- chatUrl: "https://api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1",
109
- businessProduct: "cli",
110
- businessType: "agent",
111
- businessVersion: "1.0.22",
112
- cosyScene: "assistant",
113
- mirrorTopLevelSystem: true,
114
- sendBusinessHeaders: true,
115
- sendModelSourceHeaders: true,
116
- emptyAliyunUserType: true,
117
- };
118
-
119
- const QODER_PROVIDER_ID = "qoder" as const;
120
-
121
- const QODER_APPCODE = "cosy";
122
- const QODER_SIG_SECRET = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
123
- /**
124
- * Where a PAT is exchanged for a short-lived job token, per realm.
125
- *
126
- * The job token is realm-bound: one minted on the global host answers "Login
127
- * expired" on the China gateway. Exchanging against the wrong host therefore
128
- * looks like a dead credential rather than a routing bug, which is why the
129
- * URL is derived from the realm table instead of being one constant.
130
- *
131
- * `intl` keeps its historical `center.qoder.sh` v3 endpoint. `cn` uses the
132
- * openapi host the donor's `PATH_JOB_EXCHANGE` describes; if that path is
133
- * ever wrong for CN, the failure surfaces as a 401/403 on exchange, not as a
134
- * silently misrouted request.
135
- */
136
- const QODER_JOB_TOKEN_URL = "https://center.qoder.sh/algo/api/v3/user/jobToken?Encode=1";
137
- function qoderJobTokenUrl(realm: QoderRealm): string {
138
- return realm === "cn"
139
- ? `${QODER_REALMS.cn.openapi}/api/v1/jobToken/exchange`
140
- : QODER_JOB_TOKEN_URL;
141
- }
142
- const QODER_STANDARD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
143
- const QODER_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
144
- const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
145
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
146
- 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
147
- 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
148
- XcW+ML9FoCI6AOvOzwIDAQAB
149
- -----END PUBLIC KEY-----`;
150
-
151
- export interface QoderAuth {
152
- userId: string;
153
- userName: string;
154
- userType: string;
155
- securityOauthToken: string;
156
- refreshToken: string;
157
- machineId: string;
158
- }
159
- interface QoderModelConfig {
160
- id: string;
161
- display_name: string;
162
- max_input_tokens: number;
163
- max_output_tokens?: number;
164
- is_vl: boolean;
165
- is_reasoning: boolean;
166
- /**
167
- * Whether upstream publishes this row as free. Only an explicit `true`
168
- * marks the model as free-tier; absent or `false` means billed/unknown, so
169
- * a 402/116 there is genuine quota exhaustion, not a free-tier entitlement
170
- * refusal, and must not trigger a legacy-surface fallback.
171
- */
172
- is_free?: boolean;
173
- source?: string;
174
- /**
175
- * Reasoning effort to send when the caller did not ask for one. Some models
176
- * on the IDE surface stream no reasoning unless an effort is present.
177
- */
178
- default_reasoning_effort?: string;
179
- }
180
-
181
- function qoderMd5(value: string | Uint8Array): string {
182
- return createHash("md5").update(value).digest("hex");
183
- }
184
-
185
- /**
186
- * Canonical compact JSON used by Qoder's COSY signing: keys sorted
187
- * lexicographically, no whitespace, null/undefined rendered as empty string.
188
- * The upstream verifies the signature against this exact serialization, so a
189
- * payload built with plain `JSON.stringify` (insertion order) fails with
190
- * `code 101 / Signature invalid`.
191
- */
192
- function qoderJsonSortedCompact(mapping: Record<string, unknown>): string {
193
- const parts: string[] = [];
194
- for (const key of Object.keys(mapping).sort()) {
195
- const value = mapping[key] === null || mapping[key] === undefined ? "" : mapping[key];
196
- parts.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
197
- }
198
- return `{${parts.join(",")}}`;
199
- }
200
- function qoderPathSignature(url: string): string {
201
- const path = new URL(url).pathname;
202
- return path.startsWith("/algo") ? path.slice(5) : path;
203
- }
204
-
205
- const QODER_MACHINE_ID_TTL_MS = 60 * 60 * 1000;
206
- const QODER_MAX_MACHINE_IDS = 1_024;
207
- const qoderMachineIds = new Map<string, { id: string; expiresAt: number }>();
208
- /**
209
- * A PAT (`pt-...`) must be exchanged for a short-lived job token before it
210
- * can sign COSY requests. Device tokens (`dt-...`) from OAuth login and job
211
- * tokens (`jt-...`) are used directly.
212
- */
213
- export function isQoderPat(secret: string): boolean {
214
- return secret.startsWith("pt-");
215
- }
216
-
217
- /**
218
- * Stable per-account machine id for OAuth dispatches. Derived from the
219
- * account id (not minted per request) so pooled accounts keep one device
220
- * fingerprint across restarts instead of looking like a new device every
221
- * time, while staying distinct per account.
222
- */
223
- export function qoderMachineIdForAccount(accountId: string): string {
224
- const hex = createHash("sha256").update(`qoder-machine-v1:${accountId}`).digest("hex");
225
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
226
- }
227
-
228
- /**
229
- * Builds dispatch auth from an OAuth device token. The token is used
230
- * directly as the COSY identity's oauth token; the user id and machine id
231
- * come from the `auth_state` the login persisted. Fails closed with a
232
- * reconnect prompt when the identity is absent (e.g. an account created
233
- * before `auth_state` existed, or an import that dropped the field) instead
234
- * of signing with a guessed id. The message names the missing field so an
235
- * operator can fix the export instead of re-logging blind.
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) {
244
- throw new GatewayError(
245
- "authentication_failed",
246
- 401,
247
- "Qoder OAuth account is missing its user identity (request {requestId}) (auth_state.userId/machineId absent or empty — re-login, or re-export with authState included); sign in again. Wrong key, right door — check the credential 🔑",
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 (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "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 (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "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 (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "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
- export function qoderEnvelopeToFrames(data: string): Array<{ event: string | null; data: string }> {
453
- if (data === "[DONE]") return [{ event: null, data: "[DONE]" }];
454
- let envelope: unknown;
455
- // The IDE surface sometimes embeds literal newlines inside JSON string
456
- // values (e.g. the raw_usage event carries keys like
457
- // `"data_policy_preferre\n\nnd_count"`). Those bytes are not valid JSON, so
458
- // the first parse fails; when it does, salvage the event by escaping the
459
- // raw newlines inside string values and parse again. The repaired text is
460
- // also what the direct-chunk branch hands to the chunk parser below, so its
461
- // own `JSON.parse` succeeds too.
462
- let repaired = data;
463
- try {
464
- envelope = JSON.parse(data);
465
- } catch {
466
- const attempted = escapeJsonRawNewlines(data);
467
- if (attempted !== data) {
468
- try {
469
- envelope = JSON.parse(attempted);
470
- repaired = attempted;
471
- } catch {
472
- envelope = undefined;
473
- }
474
- }
475
- }
476
- if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) return [];
477
- const rec = envelope as Record<string, unknown>;
478
- if (rec["statusCodeValue"] !== undefined && rec["statusCodeValue"] !== 200) {
479
- const status = typeof rec["statusCodeValue"] === "number" ? (rec["statusCodeValue"] as number) : 502;
480
- // Qoder reports failures inside the stream envelope instead of as HTTP
481
- // responses, so classify its structured provider code and status through
482
- // the same gateway error policy as the other dispatch paths.
483
- const detail = qoderEnvelopeMessage(rec);
484
- const identifier = upstreamErrorIdentifier(rec["body"]) ?? upstreamErrorIdentifier(rec);
485
- // Qoder signals a queued request the same way inside the stream as it does
486
- // on HTTP: `{"code":"10605", "isQueued":true, "retryAfterSeconds":N}` on a
487
- // non-200 envelope. The status table would read the 403 as a refusal, and
488
- // the caller would end its turn on a request that was only waiting.
489
- const queuedWaitMs = isUpstreamQueued(rec["body"]) ?? false;
490
- const waitMs = queuedWaitMs
491
- ? (queueRetryAfterMs(rec["body"]) ?? undefined)
492
- : undefined;
493
- const classification = queuedWaitMs
494
- ? { code: "upstream_queued" as const, status: 503, origin: "upstream" as const }
495
- : classifyUpstreamError(status, identifier);
496
- throw new GatewayError(
497
- classification.code,
498
- classification.status,
499
- detail.length > 0 ? `${detail} (request {requestId}). The upstream came back empty-handed — retry 🌙` : `Qoder stream envelope returned HTTP ${status} (request {requestId}). The upstream came back empty-handed — retry 🌙`,
500
- {
501
- providerId: QODER_PROVIDER_ID,
502
- providerStatus: status,
503
- ...(identifier ? { providerCode: identifier } : {}),
504
- ...(classification.credentialEvidence ? { credentialEvidence: true } : {}),
505
- ...(classification.rateLimitScope
506
- ? { rateLimitScope: classification.rateLimitScope }
507
- : {}),
508
- ...(waitMs === undefined ? {} : { retryAfterMs: waitMs, cooldownMs: waitMs }),
509
- ...(queuedWaitMs ? { reason: "upstream-queued" } : {}),
510
- },
511
- classification.origin,
512
- );
513
- }
514
- const body = rec["body"];
515
- if (body === "[DONE]") return [{ event: null, data: "[DONE]" }];
516
- if (typeof body === "string") return [{ event: null, data: body.replace(/\r?\n/g, "") }];
517
- // Direct OpenAI chunk (IDE surface): no envelope body, but a `choices` array
518
- // is present. Pass the (repaired) raw data through so the chunk parser sees
519
- // it.
520
- if (Array.isArray(rec["choices"])) return [{ event: null, data: repaired }];
521
- return [];
522
- }
523
-
524
- /**
525
- * Escapes raw newline bytes that appear inside JSON string values.
526
- *
527
- * The IDE surface emits `data:` events whose JSON payload contains literal
528
- * newlines inside strings (the terminal `raw_usage` event carries keys like
529
- * `"data_policy_preferre\n\nnd_count"`). `JSON.parse` rejects those bytes, so
530
- * before parsing we rewrite each raw `\n` found inside a string to the two
531
- * characters `\n`. Bytes outside strings (whitespace, structural separators)
532
- * are left untouched.
533
- */
534
- function escapeJsonRawNewlines(data: string): string {
535
- let out = "";
536
- let inString = false;
537
- let escaped = false;
538
- for (let i = 0; i < data.length; i += 1) {
539
- const ch = data[i]!;
540
- if (inString) {
541
- if (escaped) {
542
- out += ch;
543
- escaped = false;
544
- continue;
545
- }
546
- if (ch === "\\") {
547
- out += ch;
548
- escaped = true;
549
- continue;
550
- }
551
- if (ch === '"') {
552
- inString = false;
553
- out += ch;
554
- continue;
555
- }
556
- if (ch === "\n" || ch === "\r") {
557
- out += "\\n";
558
- continue;
559
- }
560
- out += ch;
561
- continue;
562
- }
563
- if (ch === '"') {
564
- inString = true;
565
- out += ch;
566
- continue;
567
- }
568
- out += ch;
569
- }
570
- return out;
571
- }
572
-
573
- function qoderStableHash(prefix: string, ...parts: string[]): string {
574
- const h = createHash("sha256");
575
- h.update(prefix);
576
- for (const p of parts) {
577
- h.update("\0");
578
- h.update(p);
579
- }
580
- return h.digest("hex").slice(0, 16);
581
- }
582
-
583
- function flattenCanonicalContent(content: readonly ContentPart[]): string {
584
- return content
585
- .filter((b) => b.kind === "text" || b.kind === "toolResult")
586
- .map((b) => {
587
- if (b.kind === "text") return b.text;
588
- if (b.kind === "toolResult") return typeof b.content === "string" ? b.content : JSON.stringify(b.content);
589
- return "";
590
- })
591
- .join("\n");
592
- }
593
-
594
- function qoderStableRecordId(modelId: string, messages: CanonicalRequest["messages"], maxTokens: number): string {
595
- const h = createHash("sha256");
596
- h.update("qoder-record\0");
597
- h.update(modelId);
598
- for (const m of messages) {
599
- h.update("\0");
600
- h.update(m.role);
601
- const text = flattenCanonicalContent(m.content);
602
- if (text) {
603
- h.update("\0");
604
- h.update(text);
605
- }
606
- }
607
- h.update(`\0mt=${maxTokens}`);
608
- return h.digest("hex").slice(0, 16);
609
- }
610
-
611
- /**
612
- * The widest selectable context window for one model key.
613
- *
614
- * The live list's `context_config` is the authority when discovery has run:
615
- * it names every window the account can switch to, and the widest one is what
616
- * a dispatch should send (`model_config.max_input_tokens`) so a conversation is
617
- * never capped at the *currently active* window (often 180k) when the account
618
- * can use 1M. Falls back to the static roster's stated window otherwise.
619
- */
620
- function qoderMaxContextFor(modelId: string, modelConfig: QoderModelConfig, realm: QoderRealm): number {
621
- const discovered = qoderDiscoveredMaxContexts(realm).get(modelId);
622
- if (discovered !== undefined) return discovered;
623
- return modelConfig.max_input_tokens;
624
- }
625
-
626
- function qoderRequestedMaxTokens(
627
- request: CanonicalRequest,
628
- modelConfig: QoderModelConfig,
629
- realm: QoderRealm,
630
- ): number {
631
- /**
632
- * The ceiling is the roster's stated one. A fixed 32k fallback would clamp
633
- * every model alike, so a request asking for more would be silently lowered
634
- * no matter which model it named. A discovered row carries no
635
- * `max_output_tokens` (upstream's model list reports none), so consult the
636
- * static roster for the key before the 65_536 last resort — and keep that
637
- * last resort only when neither the caller nor the roster states a limit.
638
- */
639
- const configured =
640
- typeof modelConfig.max_output_tokens === "number"
641
- ? modelConfig.max_output_tokens
642
- : qoderRosterMaxOutput(modelConfig.id, realm) ?? 65_536;
643
- const controls = request.generation_controls as Record<string, unknown>;
644
- const requested =
645
- typeof controls["max_tokens"] === "number"
646
- ? (controls["max_tokens"] as number)
647
- : typeof controls["max_output_tokens"] === "number"
648
- ? (controls["max_output_tokens"] as number)
649
- : null;
650
- return typeof requested === "number" && requested > 0 ? Math.min(requested, configured) : configured;
651
- }
652
-
653
- /**
654
- * The static roster's stated output ceiling for one key, when the roster states
655
- * one. Dispatch builds a live-discovered model's config without an output limit
656
- * (upstream's model list reports none); this lookup restores the roster's
657
- * ceiling so such a model is not capped at the 65_536 last resort.
658
- */
659
- function qoderRosterMaxOutput(modelId: string, realm: QoderRealm): number | undefined {
660
- const configs = realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS;
661
- return configs[modelId]?.max_output_tokens;
662
- }
663
-
664
- /**
665
- * Whether this model needs the prior turn's reasoning echoed back.
666
- *
667
- * The DeepSeek family ties multi-turn consistency to `reasoning_content`: an
668
- * assistant turn that thought must be replayed with that field or the upstream
669
- * refuses the whole history. The upstream reference bridge keeps it for exactly
670
- * these models and drops it as noise for every other one
671
- * (`keep_reasoning=is_deepseek_model(...)`, and `reasoning_content` in
672
- * `NOISE_KEYS` otherwise), and notes the symptom when it is missed: occasional
673
- * failures that sometimes pass on retry.
674
- *
675
- * Matched by the upstream key and by the display name, because a caller may
676
- * name either — the reference warns that matching only the "deepseek" prefix
677
- * missed every request that used the key.
678
- */
679
- function qoderKeepsReasoning(modelId: string): boolean {
680
- const key = modelId.trim().toLowerCase();
681
- if (key === "dmodel" || key === "dfmodel") return true;
682
- return key.startsWith("deepseek");
683
- }
684
-
685
- /**
686
- * The `reasoning_content` field for an assistant turn, when one is needed.
687
- *
688
- * Presence, not length, decides: a client that already knows the rule sends
689
- * `""` on a thinking turn whose text is not readable back, and dropping that
690
- * empty string re-triggers the complaint it was sent to avoid.
691
- *
692
- * DeepSeek multi-turn consistency also backfills the field on every assistant
693
- * turn once the conversation carries a reasoning trace at all — the reference
694
- * bridge does the same (`backfill_reasoning_content`), and notes the symptom
695
- * when it is missed: occasional failures that sometimes pass on retry. An
696
- * assistant turn with no reasoning and no trace gains nothing, so nothing is
697
- * fabricated.
698
- */
699
- function qoderReasoningEcho(
700
- message: { content: readonly ContentPart[] },
701
- modelId: string,
702
- hasReasoningTrace = false,
703
- ): { reasoning_content: string } | Record<string, never> {
704
- if (!qoderKeepsReasoning(modelId)) return {};
705
- const reasoning = message.content.filter((part) => part.kind === "reasoning");
706
- if (reasoning.length === 0) {
707
- return hasReasoningTrace ? { reasoning_content: "" } : {};
708
- }
709
- const text = reasoning
710
- .map((part) => (part.kind === "reasoning" ? (part.summary ?? part.payload ?? "") : ""))
711
- .join("");
712
- return { reasoning_content: text };
713
- }
714
-
715
- /**
716
- * The tool arguments as the object the `tool_use` block expects.
717
- *
718
- * The canonical form keeps them as the raw JSON text the caller sent, which is
719
- * what a chat-completions `tool_calls` entry holds. A `tool_use` block wants
720
- * structured input, so the text is parsed back.
721
- *
722
- * The fallbacks mirror the upstream reference bridge rather than inventing
723
- * shapes: unparseable text becomes `{}` and a non-object becomes `{value}`.
724
- * Smuggling the raw text through instead would emit an argument shape the
725
- * upstream has never seen for this field.
726
- */
727
- /**
728
- * Projects the canonical tools onto Qoder's chat-completions `tools` array.
729
- *
730
- * Without this the upstream is never told a tool exists, so a model that wants
731
- * one improvises markup in its prose (`<Read file_path="..." />`) instead of
732
- * emitting a structured call — the call is then neither runnable nor
733
- * recoverable, because it never was a call on either side.
734
- *
735
- * Only `function` tools are representable upstream. Anything else is dropped
736
- * rather than forwarded: Qoder rejects an unknown variant during
737
- * deserialization and fails the whole turn, which is worse than omitting a
738
- * tool the model could not have used anyway.
739
- */
740
- function qoderTools(
741
- tools: readonly ToolDefinition[] | undefined,
742
- ): Array<Record<string, unknown>> {
743
- const out: Array<Record<string, unknown>> = [];
744
- for (const tool of tools ?? []) {
745
- if (tool.tool_type !== undefined && tool.tool_type !== "function") continue;
746
- // The upstream requires `tools[].function` to be an object. A flat
747
- // declaration naming the tool at the top level is rejected outright with
748
- // "'function' is a required property, expected an object", which fails the
749
- // whole request rather than just this tool.
750
- out.push({
751
- type: "function",
752
- function: {
753
- name: tool.name,
754
- ...(tool.description === undefined ? { description: "" } : { description: tool.description }),
755
- parameters: tool.jsonSchema ?? { type: "object", properties: {} },
756
- ...(tool.strict === undefined ? {} : { strict: tool.strict }),
757
- },
758
- });
759
- }
760
- return out;
761
- }
762
-
763
- /**
764
- * Projects the canonical tool choice onto the upstream's field.
765
- *
766
- * The upstream only accepts a string here. Given the object form
767
- * `{type:"function",function:{name}}` it ignores the field, and the model then
768
- * calls the tool with no arguments filled in.
769
- *
770
- * `allowed_tools` has no chat-completions equivalent, so it collapses to the
771
- * direction it already names rather than being dropped: a caller that asked for
772
- * `required` must not silently get `auto`.
773
- */
774
- function qoderToolChoice(choice: ToolChoice | undefined): unknown {
775
- if (choice === undefined) return undefined;
776
- if (typeof choice === "string") return choice;
777
- if (choice.type === "tool" || choice.type === "custom") {
778
- const name = choice.name.trim();
779
- return name.length > 0 ? name : "auto";
780
- }
781
- return choice.mode;
782
- }
783
-
784
- /**
785
- * Whether this request sends its tool history as structured turns.
786
- *
787
- * Qoder's wire is OpenAI-compatible `tool_calls` deltas on every realm; the
788
- * model is fed the same structured turns in both INTL and CN. Writing a past
789
- * call as prose (`[assistant 请求调用工具] …`) contaminates the conversation
790
- * history with a text echo the model starts imitating — the origin of the
791
- * text-leak feedback loop. Structured history keeps the model seeing only real
792
- * tool calls, so it never learns the prose marker.
793
- */
794
- function qoderStructuredTools(_realm: QoderRealm): boolean {
795
- return true;
796
- }
797
-
798
- export function buildQoderRequest(
799
- modelId: string,
800
- request: CanonicalRequest,
801
- modelConfig: QoderModelConfig,
802
- auth: QoderAuth,
803
- realm: QoderRealm = "intl",
804
- ): Record<string, unknown> {
805
- const allMessages = request.messages;
806
- const systemParts: string[] = [];
807
- if (request.system) {
808
- for (const p of request.system) if (p.kind === "text") systemParts.push(p.text);
809
- }
810
- if (request.instructions) {
811
- for (const p of request.instructions) if (p.kind === "text") systemParts.push(p.text);
812
- }
813
- for (const m of allMessages) {
814
- if (m.role === "system" || m.role === "developer") {
815
- const t = flattenCanonicalContent(m.content);
816
- if (t) systemParts.push(t);
817
- }
818
- }
819
- const systemPrompt = systemParts.join("\n\n").trim();
820
- const qoderMessages: Array<Record<string, unknown>> = [];
821
- const structuredTools = qoderStructuredTools(realm);
822
- // DeepSeek multi-turn: once any turn in the conversation carries a reasoning
823
- // trace, every assistant message must replay `reasoning_content` (see
824
- // `qoderReasoningEcho`).
825
- const hasReasoningTrace = allMessages.some((message) =>
826
- message.content.some((part) => part.kind === "reasoning"),
827
- );
828
- if (MODERN_PROFILE.mirrorTopLevelSystem && systemPrompt.length > 0) {
829
- qoderMessages.push({
830
- role: "system",
831
- content: systemPrompt,
832
- });
833
- }
834
- // Every tool result across the whole conversation, keyed by call id. An
835
- // assistant `tool_calls` turn is only sent structured when every call has a
836
- // matching result somewhere in the history — otherwise the upstream rejects
837
- // the transcript ("An assistant message with 'tool_calls' must be followed by
838
- // tool messages responding to each 'tool_call_id'"). Calls without a result
839
- // are left out of the structured turn instead of poisoning it.
840
- const toolResultsById = new Map<string, string>();
841
- for (const msg of allMessages) {
842
- for (const part of toolResultParts(msg)) {
843
- if (typeof part.call_id === "string" && part.call_id.length > 0) {
844
- toolResultsById.set(part.call_id, part.call_id);
845
- }
846
- }
847
- }
848
- // The tool calls that actually survive onto the wire. A call only makes it
849
- // when every call on its assistant turn carries an id AND a matching result
850
- // exists in the history. This is the single source of truth for both sides
851
- // of the transcript: the assistant `tool_calls` emit and the `tool` result
852
- // turns. If a result's call is not here, its `tool` turn is dropped too —
853
- // leaving it would emit an orphan `tool` turn with no preceding call, which
854
- // the upstream rejects ("Messages with role 'tool' must be a response to a
855
- // preceding message with 'tool_calls'"). This is what a cancelled or
856
- // compacted parallel call looks like, and it is the "insufficient tool
857
- // messages" failure the client sees on the turn after.
858
- const emittableCallIds = new Set<string>();
859
- for (const msg of allMessages) {
860
- if (msg.role !== "assistant") continue;
861
- const calls = toolCallParts(msg);
862
- if (calls.length === 0) continue;
863
- const idsOk = calls.every(
864
- (part) => typeof part.call_id === "string" && part.call_id.length > 0,
865
- );
866
- const everyCallHasResult = calls.every((part) =>
867
- typeof part.call_id === "string" ? toolResultsById.has(part.call_id) : false,
868
- );
869
- if (idsOk && everyCallHasResult) {
870
- for (const part of calls) emittableCallIds.add(part.call_id as string);
871
- }
872
- }
873
- for (const m of allMessages) {
874
- if (m.role === "system" || m.role === "developer") continue;
875
- // Answers may live in a `user` turn (the Messages ledger re-homes them),
876
- // so the result scan is role-agnostic. Each result becomes its own `tool`
877
- // turn carrying its `tool_call_id`; any non-result content on the same turn
878
- // is emitted after, so nothing is duplicated or lost.
879
- const results = structuredTools ? toolResultParts(m) : [];
880
- if (results.length > 0) {
881
- let emittedAnyTool = false;
882
- for (const block of results) {
883
- // Drop orphan results: their assistant `tool_calls` turn was filtered
884
- // out (a sibling call has no result), and emitting the `tool` turn
885
- // anyway would make the upstream reject the whole transcript.
886
- if (!emittableCallIds.has(block.call_id)) continue;
887
- emittedAnyTool = true;
888
- const text = flattenCanonicalContent(
889
- typeof block.content === "string"
890
- ? [{ kind: "text", text: block.content }]
891
- : block.content,
892
- );
893
- qoderMessages.push({
894
- role: "tool",
895
- content: text,
896
- tool_call_id: block.call_id,
897
- });
898
- }
899
- // Text that shared the turn with a result is emitted after it, minus the
900
- // result itself. Re-emitting the result's own text here was doubling
901
- // every tool answer: the `tool` turn above and a `user` turn with the
902
- // same content, which doubled the payload for no reason.
903
- const remaining = m.content.filter((part) => part.kind !== "toolResult");
904
- const flat = flattenCanonicalContent(remaining);
905
- if (flat.length === 0 && !emittedAnyTool) continue;
906
- if (flat.length > 0) {
907
- qoderMessages.push({
908
- role: "user",
909
- content: flat,
910
- });
911
- }
912
- continue;
913
- }
914
- // An assistant turn that called tools must carry the calls, not just its
915
- // text. Falling through to the text-only emit dropped them, so the payload
916
- // arrived as a `tool` result with no preceding call and the upstream
917
- // rejected the whole transcript: "Messages with role 'tool' must be a
918
- // response to a preceding message with 'tool_calls'".
919
- const allCalls = m.content.filter((part) => part.kind === "toolCall");
920
- const reasoningEcho = qoderReasoningEcho(m, modelId, hasReasoningTrace);
921
- // Structured tool turns are only safe when every call carries an id AND a
922
- // matching result exists in the history — the upstream rejects a transcript
923
- // whose `tool_calls` is not fully answered by `tool` turns. The same set
924
- // that gates the `tool` emit above decides this, so the two halves of the
925
- // transcript can never disagree.
926
- const calls =
927
- structuredTools && allCalls.every((part) => emittableCallIds.has(part.call_id))
928
- ? allCalls
929
- : [];
930
- if (calls.length > 0) {
931
- const textParts = m.content.filter((part) => part.kind !== "toolCall");
932
- const flat = flattenCanonicalContent(textParts);
933
- qoderMessages.push({
934
- role: "assistant",
935
- content: flat,
936
- ...reasoningEcho,
937
- tool_calls: calls.map((part) => ({
938
- id: part.call_id,
939
- type: "function",
940
- function: {
941
- name: part.name,
942
- arguments:
943
- typeof part.arguments === "string"
944
- ? part.arguments
945
- : part.arguments !== undefined && part.arguments !== null
946
- ? JSON.stringify(part.arguments)
947
- : "",
948
- },
949
- })),
950
- });
951
- continue;
952
- }
953
- const role = m.role === "assistant" ? "assistant" : "user";
954
- // Images must travel as structured OpenAI parts inside the message content —
955
- // the upstream reads `image_url` parts there and not the top-level
956
- // `image_urls` (mirroring `flatten_messages` in the reference bridge). A
957
- // bare file_id reference degrades to a text note instead of being dropped.
958
- const hasImages = m.content.some((part) => part.kind === "image");
959
- if (hasImages) {
960
- const parts: Array<Record<string, unknown>> = [];
961
- for (const part of m.content) {
962
- if (part.kind === "text") {
963
- parts.push({ type: "text", text: part.text });
964
- } else if (part.kind === "image") {
965
- const source = resolveImageSource(part.payload);
966
- if (source?.url !== undefined) {
967
- parts.push({ type: "image_url", image_url: { url: source.url } });
968
- } else {
969
- parts.push({
970
- type: "text",
971
- text: source?.fileId !== undefined ? `[image: ${source.fileId}]` : "[image: unsupported source]",
972
- });
973
- }
974
- }
975
- }
976
- qoderMessages.push({
977
- role,
978
- content: parts,
979
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
980
- });
981
- continue;
982
- }
983
- const flat = flattenCanonicalContent(m.content);
984
- qoderMessages.push({
985
- role,
986
- content: flat,
987
- ...(role === "assistant" ? qoderReasoningEcho(m, modelId, hasReasoningTrace) : {}),
988
- });
989
- }
990
- const latestUserMessage = [...qoderMessages].reverse().find((m) => m["role"] === "user");
991
- const latestUserText = typeof latestUserMessage?.["content"] === "string" ? (latestUserMessage["content"] as string) : "";
992
- const maxTokens = qoderRequestedMaxTokens(request, modelConfig, realm);
993
- const reasoning = modelConfig.is_reasoning === true;
994
- // 1:1 with the IDE worker's `FTn` legacy builder, which sends reasoning
995
- // effort as a top-level camelCase field (`reasoningEffort`), derived from
996
- // the canonical intent when one is present. Omitted entirely when the
997
- // caller asked for no reasoning, matching the reference's conditional send.
998
- // When the caller sends no intent but the model needs the push to think
999
- // (verified live: Sonus/Cantus stream no reasoning without it), use the
1000
- // roster's default effort.
1001
- //
1002
- // Only a reasoning-capable model carries the field. A non-reasoning model
1003
- // (e.g. `lite`) cannot think, so an effort is meaningless there; sending it
1004
- // would also assert a capability the model does not advertise. The upstream
1005
- // tolerates it today, but declaring reasoning on a model that has none is a
1006
- // latent contract violation, so the field is kept off non-reasoning models.
1007
- const reasoningEffort =
1008
- reasoning
1009
- ? (reasoningEffortFromIntent(request.reasoning) ?? modelConfig.default_reasoning_effort)
1010
- : undefined;
1011
- const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
1012
- const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
1013
- return {
1014
- // 1:1 with the IDE worker's `FTn` legacy request builder, which always
1015
- // sends a top-level `model` equal to the resolved roster key (falling
1016
- // back to "auto" only when no key is known). The upstream honours
1017
- // `model_config.key` for actual dispatch and echoes a fixed "auto" in
1018
- // stream chunks, but the field is part of the wire shape the IDE sends.
1019
- model: modelId,
1020
- request_id: randomUUID(),
1021
- request_set_id: recordId,
1022
- chat_record_id: recordId,
1023
- session_id: sessionId,
1024
- stream: true,
1025
- ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
1026
- ...(MODERN_PROFILE.emptyAliyunUserType ? { aliyun_user_type: "" } : {}),
1027
- chat_task: "FREE_INPUT",
1028
- is_reply: true,
1029
- is_retry: false,
1030
- source: 1,
1031
- version: "3",
1032
- session_type: "qodercli",
1033
- agent_id: "agent_common",
1034
- task_id: "common",
1035
- code_language: "",
1036
- chat_prompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
1037
- image_urls: null,
1038
- chat_context: {
1039
- chatPrompt: MODERN_PROFILE.mirrorTopLevelSystem ? systemPrompt : "",
1040
- imageUrls: null,
1041
- extra: { context: [], modelConfig: { key: modelId, is_reasoning: reasoning }, originalContent: { text: latestUserText } },
1042
- features: [],
1043
- // An object, as the reference sends it: `cc["text"]["text"] = prompt`.
1044
- text: { text: latestUserText },
1045
- },
1046
- model_config: {
1047
- key: modelId,
1048
- display_name: modelConfig.display_name,
1049
- // Declares the wire dialect for the tool calls. Without it the upstream
1050
- // does not read the OpenAI-shaped `tools` array the same way, and the
1051
- // model then emits a call with no arguments filled in.
1052
- format: "openai",
1053
- model: "",
1054
- api_key: "",
1055
- url: "",
1056
- is_vl: modelConfig.is_vl,
1057
- is_reasoning: modelConfig.is_reasoning,
1058
- // Send the widest selectable window from the live list's `context_config`
1059
- // (falling back to the static roster's stated window). Without this, the
1060
- // request would declare the *currently active* window — often 180k — even
1061
- // when the account can use 1M, capping the conversation the upstream sees.
1062
- max_input_tokens: qoderMaxContextFor(modelId, modelConfig, realm),
1063
- // Mirrors the roster's stated ceiling. A fixed 32k fallback would be sent
1064
- // for every model alike, so the request would declare a cap the catalog
1065
- // does not publish. A discovered model's config carries no output limit,
1066
- // so consult the roster for the key before the 65_536 last resort.
1067
- max_output_tokens: modelConfig.max_output_tokens ?? qoderRosterMaxOutput(modelId, realm) ?? 65_536,
1068
- source: modelConfig.source ?? "system",
1069
- },
1070
- messages: qoderMessages,
1071
- max_tokens: maxTokens,
1072
- // Always present, even when empty. Omitting the field makes Qoder fall back
1073
- // to the desktop agent's own toolset (Bash/Edit/...), which then leaks to a
1074
- // caller that declared none — the reference bridge sends `[]` for exactly
1075
- // that reason. Parallel calls are stated explicitly rather than left to the
1076
- // upstream default, so a caller expecting them is not served serial
1077
- // behaviour.
1078
- tools: qoderTools(request.tools),
1079
- parallel_tool_calls: true,
1080
- ...(() => {
1081
- const choice = qoderToolChoice(request.tool_choice);
1082
- return choice === undefined ? {} : { tool_choice: choice };
1083
- })(),
1084
- // Passthrough knobs the IDE surface reads from `parameters` (worker `vyl`):
1085
- // parallel_tool_calls, tool_choice, top_p, seed, user, response_format, …
1086
- // Only include what the caller actually set.
1087
- parameters: (() => {
1088
- const out: Record<string, unknown> = {};
1089
- const choice = qoderToolChoice(request.tool_choice);
1090
- if (choice !== undefined) out["tool_choice"] = choice;
1091
- const parallel = request.generation_controls.parallel_tool_calls;
1092
- if (parallel !== undefined) out["parallel_tool_calls"] = parallel;
1093
- return Object.keys(out).length > 0 ? out : undefined;
1094
- })(),
1095
- };
1096
- }
1097
-
1098
- /**
1099
- * Builds the modern model-server (`/model/v1/chat/completions`) request body.
1100
- *
1101
- * It reuses the message translation from `buildQoderRequest` (tool turns,
1102
- * reasoning echo, image parts) and wraps the result in the OpenAI-shaped body
1103
- * the IDE surface expects: top-level `model`, `messages`, `stream_options`,
1104
- * and a `metadata.context`/`metadata.business` block. `model` is the roster
1105
- * key after virtual-name resolution, so the upstream never sees a virtual id
1106
- * (which it answers by falling back to `auto`).
1107
- */
1108
- export function buildQoderIdeRequest(
1109
- modelId: string,
1110
- request: CanonicalRequest,
1111
- modelConfig: QoderModelConfig,
1112
- auth: QoderAuth,
1113
- realm: QoderRealm = "intl",
1114
- version = getQoderVersion(),
1115
- ): Record<string, unknown> {
1116
- const legacy = buildQoderRequest(modelId, request, modelConfig, auth, realm);
1117
- const messages = legacy.messages as Array<Record<string, unknown>>;
1118
- // The IDE surface (`model/v1/chat/completions`) must ship the same tool
1119
- // declaration as the legacy builder. The unpacked desktop client (worker
1120
- // `PFn`) injects `tools` directly when present — without it the upstream
1121
- // sees no toolset and answers with a DSML text block (or claims no tool
1122
- // exists) instead of a structured `tool_calls`. The remaining knobs
1123
- // (`parallel_tool_calls`, `tool_choice`, …) ride the `parameters`
1124
- // passthrough (worker `vyl`), matching the reference exactly.
1125
- const tools = legacy.tools as Array<Record<string, unknown>> | undefined;
1126
- return {
1127
- model: modelId,
1128
- messages,
1129
- stream: true,
1130
- stream_options: { include_usage: true },
1131
- ...(tools !== undefined && tools.length > 0 ? { tools } : {}),
1132
- ...(legacy.parameters === undefined ? {} : { parameters: legacy.parameters }),
1133
- // The same reasoning-effort field the legacy surface uses; without it the
1134
- // IDE surface streams no thinking for models that need the push (Sonus,
1135
- // Cantus). Undefined when the caller asked for none and the roster has no
1136
- // default, matching the legacy builder.
1137
- ...(legacy.reasoningEffort === undefined ? {} : { reasoningEffort: legacy.reasoningEffort }),
1138
- // The full wire config (model key, window, output ceiling, tier) the legacy
1139
- // surface sends. The IDE surface reads the same block, so shipping it keeps
1140
- // the request the same shape for every Qoder model — including lite's 1M
1141
- // window and 64k output cap.
1142
- ...(legacy.model_config === undefined ? {} : { model_config: legacy.model_config }),
1143
- metadata: {
1144
- context: {
1145
- request_id: legacy.request_id,
1146
- request_set_id: legacy.request_set_id,
1147
- session_id: legacy.session_id,
1148
- source_session_id: "",
1149
- task_id: "common",
1150
- client_type: "5",
1151
- },
1152
- business: {
1153
- business_product: "cli",
1154
- business_type: "agent",
1155
- business_version: version,
1156
- scene: "assistant",
1157
- },
1158
- },
1159
- max_tokens: (legacy.max_tokens as number) ?? 65_536,
1160
- };
1161
- }
1162
-
1163
- /**
1164
- * Identity headers for the modern model-server surface (plain bearer).
1165
- *
1166
- * Mirrors the desktop IDE's `openResponse` exactly: the IDE sends the five
1167
- * OpenAI-surface headers (Authorization, Content-Type, Accept, X-Request-ID,
1168
- * X-Session-ID) and the global client-identity interceptor adds Cosy-Version,
1169
- * Cosy-ClientType, Cosy-MachineOS, and Cosy-MachineHostname (for infer-sse
1170
- * requests). It does NOT send Cosy-MachineId / Cosy-MachineToken /
1171
- * Cosy-MachineType on this surface, so neither do we.
1172
- */
1173
- function qoderIdeHeaders(auth: QoderAuth, version: string): Record<string, string> {
1174
- return {
1175
- Authorization: `Bearer ${auth.securityOauthToken}`,
1176
- "Content-Type": "application/json",
1177
- Accept: "text/event-stream",
1178
- "X-Request-ID": randomUUID(),
1179
- "X-Session-ID": randomUUID(),
1180
- "Cosy-Version": version,
1181
- "Cosy-ClientType": "5",
1182
- "Cosy-MachineOS": qoderMachineOs(),
1183
- "Cosy-MachineHostname": qoderMachineHostname(),
1184
- };
1185
- }
1186
-
1187
- async function callQoderIde(
1188
- url: string,
1189
- body: Record<string, unknown>,
1190
- auth: QoderAuth,
1191
- signal: AbortSignal,
1192
- fetcher: QoderFetcher,
1193
- version = getQoderVersion(),
1194
- ): Promise<Response> {
1195
- return fetcher(url, {
1196
- method: "POST",
1197
- headers: qoderIdeHeaders(auth, version),
1198
- body: JSON.stringify(body),
1199
- signal,
1200
- });
1201
- }
1202
-
1203
- /**
1204
- * True when the modern model-server surface refused the request on entitlement
1205
- * grounds. This only detects the refusal — whether a legacy fallback is worth
1206
- * trying is the caller's decision, and it is gated on the model being free-tier
1207
- * (see `dispatch`).
1208
- *
1209
- * The upstream's refusal envelope is `{"code":116,"error":"quota exceeded"}`
1210
- * on HTTP 402. The IDE's own error set classifies 116 as a quota/entitlement
1211
- * failure (not an auth error), and the gRPC surface reports the same code as
1212
- * status 13. For a free-tier model this is an entitlement refusal the legacy
1213
- * COSY surface will serve with the same credential; for a billed model the
1214
- * same code is genuine quota exhaustion (Qoder's daily exhaustion and cooldown
1215
- * run on a 1-day cycle) and must surface as-is so the quota/cooldown path can
1216
- * see it. A 401/403 or a model-level error is never fallback-class either way.
1217
- */
1218
- function qoderIdeEntitlementRefused(response: Response): boolean {
1219
- // Status alone is the signal; the body is left intact for
1220
- // `mapUpstreamHttpError` to surface if the fallback also fails.
1221
- return response.status === 402;
1222
- }
1223
-
1224
- /**
1225
- * Qoder upstream streams are flaky: the IDE surface sometimes answers a full
1226
- * normal stop with reasoning only and no output text/tool calls (observed ~1
1227
- * in 4 runs with Sonus). That is not a valid completion — the lab would show a
1228
- * thought with "No text came back". Replay such a stream before committing to
1229
- * it. `sniffQoderStream` below inspects just enough of the body to make that
1230
- * call without buffering the whole (possibly long) stream.
1231
- */
1232
-
1233
- /**
1234
- * Reads the upstream body just far enough to decide whether it carries real
1235
- * output, then returns a replay stream that re-emits the already-read bytes
1236
- * followed by the rest of the original body. If the body ends before any
1237
- * meaningful output is seen, `meaningful` is false and the caller replays the
1238
- * whole request (empty / thought-only recovery) without ever buffering a large
1239
- * response: the original `drainQoderResponse` read the entire stream before
1240
- * forwarding, which made every stream appear to complete in one burst at the
1241
- * end.
1242
- */
1243
- async function sniffQoderStream(
1244
- response: Response,
1245
- signal: AbortSignal,
1246
- ): Promise<{ meaningful: boolean; response: Response }> {
1247
- const body = response.body;
1248
- if (body === null) return { meaningful: true, response };
1249
- const reader = body.getReader();
1250
- const decoder = new TextDecoder();
1251
- const pending: Uint8Array[] = [];
1252
- let inspected = "";
1253
- let meaningful = false;
1254
- for (let i = 0; i < 32 && !meaningful; i += 1) {
1255
- const { done, value } = await reader.read();
1256
- if (signal.aborted) break;
1257
- if (done) break;
1258
- pending.push(value);
1259
- inspected += decoder.decode(value, { stream: true });
1260
- // Peek at completed SSE frames only: a content delta or a tool-call
1261
- // fragment means real output is coming. Reasoning-only streams are NOT
1262
- // meaningful (they are exactly what empty-stream recovery replays), and a
1263
- // finish reason alone is not output either.
1264
- if (/data:/.test(inspected)) {
1265
- for (const frame of inspected.split(/\r?\n\r?\n/)) {
1266
- if (!frame.includes("data:")) continue;
1267
- if (frame.includes('"content":"') || frame.includes('"tool_calls"')) {
1268
- meaningful = true;
1269
- break;
1270
- }
1271
- }
1272
- }
1273
- if (inspected.length > 65536) {
1274
- meaningful = true;
1275
- break;
1276
- }
1277
- }
1278
- // Replay: previously-read bytes first, then continue the original body.
1279
- let offset = 0;
1280
- const replay = new ReadableStream<Uint8Array>({
1281
- start(controller) {
1282
- for (; offset < pending.length; offset += 1) controller.enqueue(pending[offset]);
1283
- },
1284
- async pull(controller) {
1285
- if (offset < pending.length) {
1286
- controller.enqueue(pending[offset]);
1287
- offset += 1;
1288
- return;
1289
- }
1290
- const { done, value } = await reader.read();
1291
- if (signal.aborted) {
1292
- controller.close();
1293
- return;
1294
- }
1295
- if (done) {
1296
- controller.close();
1297
- return;
1298
- }
1299
- controller.enqueue(value);
1300
- },
1301
- cancel(reason) {
1302
- return reader.cancel(reason);
1303
- },
1304
- });
1305
- return { meaningful, response: new Response(replay, response) };
1306
- }
1307
-
1308
- /**
1309
- * Runs the IDE request up to `1 + maxReplays` times, returning the first
1310
- * response whose stream carried real output (text or tool calls). A 200 with
1311
- * no output (empty or thought-only) is replayed rather than surfaced. The
1312
- * stream is forwarded progressively (not buffered to completion).
1313
- */
1314
- async function fetchQoderIdeWithEmptyRecovery(
1315
- requestOnce: () => Promise<Response>,
1316
- signal: AbortSignal,
1317
- maxReplays = 2,
1318
- ): Promise<Response> {
1319
- let last: Response | undefined;
1320
- for (let attempt = 0; attempt <= maxReplays; attempt += 1) {
1321
- const res = await requestOnce();
1322
- if (!res.ok || !res.body) return res;
1323
- const sniff = await sniffQoderStream(res, signal);
1324
- last = sniff.response;
1325
- if (sniff.meaningful) return last;
1326
- }
1327
- return last!;
1328
- }
1329
-
1330
- export { fetchQoderIdeWithEmptyRecovery };
1331
-
1332
- // Stream transformation — decode SSE, unwrap COSY envelope, map OpenAI chat chunks to CanonicalEvent
1333
-
1334
- function mapQoderFinishReason(raw: unknown): CanonicalStopReason | undefined {
1335
- if (raw === "stop") return "stop";
1336
- if (raw === "length") return "length";
1337
- if (raw === "tool_calls") return "tool_use";
1338
- if (raw === "content_filter") return "content_filter";
1339
- return undefined;
1340
- }
1341
-
1342
- export async function* qoderBodyToCanonicalEvents(
1343
- body: ReadableStream<Uint8Array>,
1344
- signal: AbortSignal,
1345
- model: string,
1346
- ): AsyncIterable<CanonicalEvent> {
1347
- let seq = 1;
1348
- let started = false;
1349
- let finishReason: unknown = undefined;
1350
- let rawUsage: Record<string, unknown> | undefined = undefined;
1351
- let hasTerminal = false;
1352
- /** First-sighting id per wire tool-call index, for continuation chunks. */
1353
- const indexIds = new Map<number, string>();
1354
- /** Last seen call id/name, for index-less continuation fragments. */
1355
- let lastSeenToolId: string | undefined = undefined;
1356
- let lastSeenToolName: string | undefined = undefined;
1357
- // INTL leak-marker hold-back: text that may grow into the marker+JSON echo
1358
- // is withheld from the client until end of stream proves it is a tool call
1359
- // (recovered) or ordinary prose (released).
1360
- let leakBuf = "";
1361
- let leakFrames = 0;
1362
- // Tool-call argument fragments, keyed by call id, reconciled at end of
1363
- // stream (see the tool_calls handling in the loop).
1364
- const toolCallBuffer = new Map<string, { index: number; name: string | undefined; arguments: string }>();
1365
-
1366
- for await (const sse of decodeQoderSseEvents(body, signal)) {
1367
- const sseData = sse.data;
1368
- let frames: Array<{ event: string | null; data: string }>;
1369
- try {
1370
- frames = qoderEnvelopeToFrames(sseData);
1371
- } catch (e) {
1372
- throw e;
1373
- }
1374
- for (const frame of frames) {
1375
- const data = frame.data.trim();
1376
- if (!data) continue;
1377
- if (data === "[DONE]") {
1378
- hasTerminal = true;
1379
- if (!started) {
1380
- yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
1381
- started = true;
1382
- }
1383
- continue;
1384
- }
1385
- let json: Record<string, unknown>;
1386
- try {
1387
- json = JSON.parse(data) as Record<string, unknown>;
1388
- } catch {
1389
- continue;
1390
- }
1391
- // The upstream names the real model in each chunk. Use the first one we
1392
- // see for the opening event so the client sees the same model the
1393
- // provider actually served, not the (virtual) id that was requested.
1394
- if (!started && typeof json["model"] === "string") {
1395
- const upstreamModel = (json["model"] as string).trim();
1396
- yield {
1397
- type: "response_start",
1398
- sequence_number: seq++,
1399
- model: upstreamModel.length > 0 ? upstreamModel : model,
1400
- } as CanonicalEvent;
1401
- started = true;
1402
- }
1403
- // IDE surface error event: `{"code":"invalid_model_error","message":"..."}`
1404
- // carries no choices. Surface it as an upstream failure instead of an
1405
- // empty successful stream.
1406
- if (json["error"] !== undefined && json["choices"] === undefined) {
1407
- const error = json["error"] as Record<string, unknown> | string;
1408
- const message =
1409
- typeof error === "string"
1410
- ? error
1411
- : typeof error?.["message"] === "string"
1412
- ? (error["message"] as string)
1413
- : typeof json["message"] === "string"
1414
- ? (json["message"] as string)
1415
- : "Qoder model server error";
1416
- const code = typeof json["code"] === "string" ? (json["code"] as string) : undefined;
1417
- throw new GatewayError(
1418
- code === "invalid_model_error" ? "model_not_found" : "platform_unavailable",
1419
- code === "invalid_model_error" ? 404 : 502,
1420
- `${message} (request {requestId}). The upstream came back empty-handed — retry 🌙`,
1421
- { providerId: QODER_PROVIDER_ID, ...(code === undefined ? {} : { providerCode: code }) },
1422
- "upstream",
1423
- );
1424
- }
1425
- if (json["usage"] && typeof json["usage"] === "object") rawUsage = json["usage"] as Record<string, unknown>;
1426
- // IDE surface terminal event: `choices:[]` + `raw_usage`. It carries the
1427
- // final usage and confirms the turn ended, so record it and synthesize a
1428
- // stop reason — the terminal event has no `choices[].finish_reason`.
1429
- if (json["raw_usage"] && typeof json["raw_usage"] === "object") {
1430
- const rawUsageObj = json["raw_usage"] as Record<string, unknown>;
1431
- rawUsage = (rawUsageObj["data"] as Record<string, unknown> | undefined) ?? rawUsageObj;
1432
- if (json["choices"] === undefined || (json["choices"] as unknown[]).length === 0) {
1433
- finishReason = finishReason ?? "stop";
1434
- }
1435
- }
1436
- const choices = (json["choices"] as Array<Record<string, unknown>>) ?? [];
1437
- for (const choice of choices) {
1438
- const delta = choice["delta"] as Record<string, unknown> | undefined;
1439
- if (delta) {
1440
- if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1441
- const piece = delta["content"] as string;
1442
- // Marker+JSON hold-back. A complete marker and everything after it is
1443
- // held until end of stream; prose before it is emitted now.
1444
- if (leakBuf.length > 0) {
1445
- leakBuf += piece;
1446
- leakFrames = piece.length === 0 ? leakFrames + 1 : 0;
1447
- if (
1448
- leakBuf.length > LEAK_HOLD_MAX_CHARS ||
1449
- leakFrames > LEAK_HOLD_MAX_FRAMES ||
1450
- !qoderStillLeakCandidate(leakBuf)
1451
- ) {
1452
- if (leakBuf.length > 0) {
1453
- yield {
1454
- type: "content_delta",
1455
- sequence_number: seq++,
1456
- content: { kind: "text", text: leakBuf },
1457
- } as CanonicalEvent;
1458
- }
1459
- leakBuf = "";
1460
- leakFrames = 0;
1461
- }
1462
- continue;
1463
- }
1464
- const marker = LEAK_MARKER_ANY_RE.exec(piece);
1465
- if (marker) {
1466
- const emit = piece.slice(0, marker.index);
1467
- if (emit.length > 0) {
1468
- yield {
1469
- type: "content_delta",
1470
- sequence_number: seq++,
1471
- content: { kind: "text", text: emit },
1472
- } as CanonicalEvent;
1473
- }
1474
- leakBuf = piece.slice(marker.index);
1475
- leakFrames = 1;
1476
- continue;
1477
- }
1478
- // No marker: hold only a possible partial-marker suffix.
1479
- const tail = piece.slice(-LEAK_HOLD_WINDOW);
1480
- let heldFrom = -1;
1481
- for (let n = tail.length; n >= 1; n -= 1) {
1482
- if (isLeakMarkerPrefix(tail.slice(-n))) {
1483
- heldFrom = piece.length - n;
1484
- break;
1485
- }
1486
- }
1487
- if (heldFrom >= 0) {
1488
- const emit = piece.slice(0, heldFrom);
1489
- if (emit.length > 0) {
1490
- yield {
1491
- type: "content_delta",
1492
- sequence_number: seq++,
1493
- content: { kind: "text", text: emit },
1494
- } as CanonicalEvent;
1495
- }
1496
- leakBuf = piece.slice(heldFrom);
1497
- leakFrames = 1;
1498
- continue;
1499
- }
1500
- yield {
1501
- type: "content_delta",
1502
- sequence_number: seq++,
1503
- content: { kind: "text", text: piece },
1504
- } as CanonicalEvent;
1505
- continue;
1506
- }
1507
- const reasoningText = readReasoningText(delta);
1508
- if (typeof reasoningText === "string" && reasoningText.length > 0) {
1509
- yield {
1510
- type: "content_delta",
1511
- sequence_number: seq++,
1512
- content: { kind: "reasoning", payload: null, summary: reasoningText },
1513
- } as CanonicalEvent;
1514
- continue;
1515
- }
1516
- const toolCalls = delta["tool_calls"] as Array<Record<string, unknown>> | undefined;
1517
- if (toolCalls) {
1518
- for (const tc of toolCalls) {
1519
- const fn = tc["function"] as Record<string, unknown> | undefined;
1520
- const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1521
- // `id` is only meaningful when non-empty: some Qoder slots echo
1522
- // `id: ""` on continuation chunks, and treating that as a fresh
1523
- // call id would orphan every argument fragment (the real call
1524
- // keeps its first-seen id, the fragments pile up under "").
1525
- const declaredId =
1526
- typeof tc["id"] === "string" && (tc["id"] as string).length > 0
1527
- ? (tc["id"] as string)
1528
- : undefined;
1529
- const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1530
- // OpenAI continuation chunks re-identify their call by wire
1531
- // `index` alone — the `id` appears only once, in the first chunk.
1532
- // Some bridges omit `index` too, so a fragment then falls back to
1533
- // the most recently seen call id (mirroring the generic chat
1534
- // parser's `lastSeenToolId`). Pinning the first id per index and
1535
- // reusing it for continuations is what keeps fragments attached
1536
- // to the right call; synthesizing a fresh `call_<index>` id per
1537
- // chunk instead made every continuation an unknown nameless call
1538
- // that encoders dropped, so the client saw a call whose arguments
1539
- // were always empty.
1540
- let callId: string;
1541
- if (declaredId !== undefined) {
1542
- callId = declaredId;
1543
- if (index !== undefined) indexIds.set(index, callId);
1544
- lastSeenToolId = callId;
1545
- if (fragName !== undefined) lastSeenToolName = fragName;
1546
- } else if (index !== undefined && indexIds.has(index)) {
1547
- callId = indexIds.get(index)!;
1548
- } else if (
1549
- lastSeenToolId !== undefined &&
1550
- (fragName === undefined || lastSeenToolName === fragName)
1551
- ) {
1552
- callId = lastSeenToolId;
1553
- } else {
1554
- callId = `call_${String(index ?? 0)}`;
1555
- }
1556
- // Buffer tool-call argument fragments per call id until the end of
1557
- // stream. Upstream streaming fragments occasionally drop or mangle
1558
- // a byte mid-arguments; forwarding each fragment as an
1559
- // `arguments_delta` would hand the client a malformed JSON blob to
1560
- // stitch. Holding and reconciling here yields one complete,
1561
- // parseable arguments string per call — flush happens at end of
1562
- // stream (or when a terminal frame arrives).
1563
- const existing = toolCallBuffer.get(callId);
1564
- if (existing) {
1565
- existing.index = index ?? existing.index;
1566
- if (fragName !== undefined) existing.name = fragName;
1567
- if (typeof fn?.["arguments"] === "string") {
1568
- existing.arguments += (fn["arguments"] as string);
1569
- }
1570
- } else {
1571
- toolCallBuffer.set(callId, {
1572
- index: index ?? 0,
1573
- name: fragName,
1574
- arguments: typeof fn?.["arguments"] === "string" ? (fn["arguments"] as string) : "",
1575
- });
1576
- }
1577
- }
1578
- }
1579
- }
1580
- const fr = choice["finish_reason"];
1581
- // The IDE surface sends an empty string (`""`) for the per-delta finish
1582
- // reason, which is not a real stop signal — only a value that maps to a
1583
- // canonical stop reason (or the terminal `[DONE]` frame) ends the turn.
1584
- if (fr !== null && fr !== undefined && fr !== "") finishReason = fr;
1585
- // Some providers send usage in choices[0].usage or top-level
1586
- if (choice["usage"] && typeof choice["usage"] === "object") rawUsage = choice["usage"] as Record<string, unknown>;
1587
- }
1588
- // Some Qoder chunks carry finish_reason at top level (empty string means
1589
- // no stop signal, same as the per-delta field).
1590
- if (json["finish_reason"] !== undefined && json["finish_reason"] !== "") finishReason = json["finish_reason"];
1591
- }
1592
- }
1593
- // End of stream: recover a held-back marker+JSON echo. A valid echo becomes
1594
- // tool calls; anything else is released as ordinary prose (or fails closed
1595
- // when it is unmistakably an echo that cannot be parsed).
1596
- if (leakBuf.length > 0) {
1597
- const leaked = parseQoderTextToolCalls(leakBuf);
1598
- if (leaked.calls.length > 0) {
1599
- for (const call of leaked.calls) {
1600
- yield {
1601
- type: "tool_call_delta",
1602
- sequence_number: seq++,
1603
- call_id: call.id,
1604
- name: call.name,
1605
- arguments_delta: call.arguments,
1606
- } as CanonicalEvent;
1607
- }
1608
- finishReason = "tool_calls";
1609
- } else {
1610
- // An echo that could not be recovered must fail the turn (502) so the
1611
- // harness retries — never surface protocol text as visible content.
1612
- if (isQoderEchoAttempt(leakBuf)) {
1613
- throw badEchoError();
1614
- }
1615
- const visible = leaked.clean.trim();
1616
- if (visible.length > 0) {
1617
- yield {
1618
- type: "content_delta",
1619
- sequence_number: seq++,
1620
- content: { kind: "text", text: visible },
1621
- } as CanonicalEvent;
1622
- }
1623
- }
1624
- }
1625
- // A `[DONE]` frame from the IDE surface may be the only terminal signal (the
1626
- // //-delta `finish_reason` is empty and `raw_usage` is not always sent), so
1627
- // a clean end-of-stream with no finish reason is a normal stop, not a
1628
- // truncation. If no terminal arrived at all, still synthesize one (prevents
1629
- // truncation error).
1630
- if (!started) {
1631
- yield { type: "response_start", sequence_number: seq++, model } as CanonicalEvent;
1632
- }
1633
- if (hasTerminal && finishReason === undefined) finishReason = "stop";
1634
- const stopReason = mapQoderFinishReason(finishReason);
1635
- const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1636
- void hasTerminal;
1637
- // Flush buffered tool-call fragments as complete, stitched calls — one
1638
- // `arguments_delta` per call with the full JSON string, so the client never
1639
- // has to repair upstream frame-loss.
1640
- for (const [callId, buffered] of toolCallBuffer) {
1641
- yield {
1642
- type: "tool_call_delta",
1643
- sequence_number: seq++,
1644
- call_id: callId,
1645
- index: buffered.index,
1646
- name: buffered.name,
1647
- arguments_delta: buffered.arguments,
1648
- } as CanonicalEvent;
1649
- }
1650
- yield {
1651
- type: "terminal",
1652
- sequence_number: seq++,
1653
- state: "complete",
1654
- ...(stopReason === undefined ? {} : { stop_reason: stopReason }),
1655
- ...(_providerStop === undefined ? {} : { provider_stop_reason: _providerStop }),
1656
- usage: usageFromProvider(rawUsage),
1657
- } as CanonicalEvent;
1658
- }
1659
-
1660
- // Model catalog — maps the realm's roster to Cartethyia ModelDefinition
1661
-
1662
- /**
1663
- * The intl catalog this provider publishes.
1664
- *
1665
- * The published catalog and the dispatched request both come from the realm's
1666
- * roster, which carries the upstream's own numbers.
1667
- */
1668
- export const QODER_MODELS: readonly ModelDefinition[] = QODER_INTL_MODELS;
1669
-
1670
- // ProviderAdapter — manual dispatch preserving COSY encryption + envelope logic
1671
-
1672
- interface QoderAdapterOptions {
1673
- readonly fetch?: typeof fetch;
1674
- /** Which deployment this adapter dispatches to. Defaults to intl. */
1675
- readonly realm?: QoderRealm;
1676
- /** Bundled provider id, so the adapter's own id matches the route's. */
1677
- readonly providerId?: string;
1678
- /**
1679
- * Which chat surface to dispatch on.
1680
- *
1681
- * `ide` (default) uses the modern model-server surface
1682
- * (`/model/v1/chat/completions`) — the wire the IDE/QoderWake actually
1683
- * speaks, echoes the real model key, and carries 1M context + xhigh
1684
- * reasoning. When that surface answers an entitlement refusal (HTTP 402 /
1685
- * `{"code":116,"error":"quota exceeded"}`, seen on free-tier accounts),
1686
- * dispatch falls back to the legacy COSY surface
1687
- * (`/algo/.../agent_chat_generation`) — but only for a free-tier model
1688
- * row (`is_free: true`). A billed model answering 402/116 is genuine quota
1689
- * exhaustion (Qoder's daily exhaustion / cooldown run on a 1-day cycle) and
1690
- * surfaces as-is, so the exhausted state is never masked by a fallback.
1691
- * Live verification (2026-11-02): the modern surface answers 116 for
1692
- * free-tier accounts that stream fine over COSY, while entitled accounts
1693
- * stream on the modern surface with the real model echoed.
1694
- *
1695
- * `cosy` forces only the legacy surface (the pre-fallback behavior).
1696
- */
1697
- readonly surface?: "ide" | "cosy";
1698
- }
1699
-
1700
- class QoderAdapter implements ProviderAdapter {
1701
- readonly provider_id: ProviderId;
1702
- private readonly realm: QoderRealm;
1703
- private readonly surface: "ide" | "cosy";
1704
- private readonly fetchFn: typeof fetch;
1705
-
1706
- constructor(options: QoderAdapterOptions = {}) {
1707
- this.provider_id = (options.providerId ?? QODER_PROVIDER_ID) as ProviderId;
1708
- this.realm = options.realm ?? "intl";
1709
- this.surface = options.surface ?? "ide";
1710
- this.fetchFn = options.fetch ?? globalThis.fetch;
1711
- }
1712
-
1713
- /**
1714
- * The static roster for this adapter's realm.
1715
- *
1716
- * A single merged table advertised CN-only keys to global accounts and vice
1717
- * versa, so a request was routed to a deployment that refuses it.
1718
- */
1719
- #modelConfigs(): Record<string, QoderModelConfig> {
1720
- return (this.realm === "cn" ? QODER_CN_MODEL_CONFIGS : QODER_INTL_MODEL_CONFIGS) as Record<
1721
- string,
1722
- QoderModelConfig
1723
- >;
1724
- }
1725
-
1726
- async *dispatch(
1727
- request: CanonicalRequest,
1728
- candidate: ProviderDispatchTarget,
1729
- context: ProviderDispatchContext,
1730
- ): AsyncIterable<CanonicalEvent> {
1731
- if (candidate.wire_family !== "chat") {
1732
- assertWireFamily(candidate, "chat", "qoder");
1733
- }
1734
- const secret = readCredentialSecret(context.credential, "Qoder credential is required");
1735
-
1736
- /**
1737
- * A caller-facing virtual name is translated to its roster key here.
1738
- *
1739
- * The translation has to happen before the config lookup and before the
1740
- * request body: the body carries the roster key, and looking up the
1741
- * virtual id in the config table would fall through to the synthetic
1742
- * default and silently dispatch under-declared capabilities.
1743
- */
1744
- const requestedModelId = candidate.model_id || request.model;
1745
- if (isQoderVirtualModel(requestedModelId) && !this.#modelConfigs()[qoderDispatchModelId(requestedModelId)]) {
1746
- throw new GatewayError(
1747
- "model_not_found",
1748
- 404,
1749
- `Qoder virtual model ${requestedModelId} resolves to a roster key this realm does not serve (request {requestId}). Try another model — this one's off the menu 📚`,
1750
- { virtual_model: requestedModelId, target: qoderDispatchModelId(requestedModelId) },
1751
- "upstream",
1752
- );
1753
- }
1754
- const modelId = qoderDispatchModelId(requestedModelId);
1755
- const modelConfig = this.#modelConfigs()[modelId] ?? {
1756
- id: modelId,
1757
- display_name: modelId,
1758
- max_input_tokens: 180_000,
1759
- is_vl: true,
1760
- is_reasoning: false,
1761
- };
1762
-
1763
- const lifecycle = createUpstreamDeadlineLifecycle(context);
1764
-
1765
- // Outbound fetch respects SSRF binding when provided
1766
- const outboundFetch: typeof fetch = (context.outbound_fetch as unknown as typeof fetch) ?? this.fetchFn;
1767
-
1768
- const fetcher: QoderFetcher = (url, init) =>
1769
- outboundFetch(url, { ...init, signal: lifecycle.signal } as RequestInit) as Promise<Response>;
1770
-
1771
- try {
1772
- const version = getQoderVersion();
1773
- // Dual mode like Cline: a pasted PAT is exchanged for a job token per
1774
- // request, while an OAuth device token is used directly with the
1775
- // identity the login persisted.
1776
- const auth = isQoderPat(secret)
1777
- ? await exchangeQoderPat(secret, lifecycle.signal, fetcher, version, this.realm)
1778
- : qoderOAuthAuth(secret, context.credential.auth_state, context.credential.account_id);
1779
- // A full URL from the route wins, but the fallback is this realm's
1780
- // gateway — not the intl host hardcoded into `MODERN_PROFILE`. Dispatching
1781
- // a CN credential to `*.qoder.sh` is refused outright. The host order
1782
- // follows the token kind: job tokens (`jt-...`) belong on `api2`, which
1783
- // is the only intl host that serves them (`api3` answers "Login expired").
1784
-
1785
- // The modern `ide` surface streams immediately, needs no COSY signing,
1786
- // and echoes the real model key — it is the default. Fall back to the
1787
- // legacy COSY surface only when BOTH hold: the modern surface refused on
1788
- // entitlement grounds (HTTP 402 / code 116) AND the dispatched model is a
1789
- // free-tier row (`is_free: true`). For a free-tier model this is the
1790
- // entitlement refusal the legacy surface serves with the same credential;
1791
- // for a billed model the same code is genuine quota exhaustion (Qoder's
1792
- // daily exhaustion / cooldown run on a 1-day cycle), so it must surface
1793
- // as-is — falling back would hide the exhausted state and starve the
1794
- // quota/cooldown signal. A 401/403/model error never triggers fallback.
1795
- const ideUrl = candidate.endpoint_path?.startsWith("http") ? candidate.endpoint_path : qoderIdeChatUrl(this.realm);
1796
- const cosyUrl = candidate.endpoint_path?.startsWith("http")
1797
- ? candidate.endpoint_path
1798
- : `${qoderInferenceCandidates(this.realm, auth.securityOauthToken)[0] ?? ""}${QODER_CHAT_PATH}`;
1799
- const ideRequest = () =>
1800
- callQoderIde(ideUrl, buildQoderIdeRequest(modelId, request, modelConfig, auth, this.realm, version), auth, lifecycle.signal, fetcher, version);
1801
- const cosyRequest = () =>
1802
- callQoder(cosyUrl, buildQoderRequest(modelId, request, modelConfig, auth, this.realm), modelId, auth, lifecycle.signal, fetcher, version);
1803
- let response =
1804
- this.surface === "ide"
1805
- ? await fetchQoderIdeWithEmptyRecovery(ideRequest, lifecycle.signal)
1806
- : await cosyRequest();
1807
- if (
1808
- !response.ok &&
1809
- this.surface === "ide" &&
1810
- qoderIdeEntitlementRefused(response) &&
1811
- modelConfig.is_free === true
1812
- ) {
1813
- response = await cosyRequest();
1814
- }
1815
- if (!response.ok) throw await mapUpstreamHttpError(response, "qoder");
1816
- if (!response.body) throw new GatewayError("platform_unavailable", 502, "Qoder returned empty body (request {requestId}). The upstream came back empty-handed — retry 🌙", {}, "upstream");
1817
- // Headers arrived: the pre-stream deadline has served its purpose.
1818
- // From here the gateway stall/first-chunk watchdog (propagated via
1819
- // context.abort_signal) governs the body. Keeping this timer armed
1820
- // would kill healthy long streams at the stale pre-stream deadline.
1821
- lifecycle.release();
1822
- yield* qoderBodyToCanonicalEvents(response.body as ReadableStream<Uint8Array>, lifecycle.signal, request.model);
1823
- } catch (err: unknown) {
1824
- const abortError = abortGatewayError(lifecycle, err, context.abort_signal);
1825
- if (abortError) throw abortError;
1826
- throw err;
1827
- } finally {
1828
- lifecycle.release();
1829
- }
1830
- }
1831
- }
1832
-
1833
- export function createQoderAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1834
- return new QoderAdapter(options);
1835
- }
1836
-
1837
- /**
1838
- * Adapter for the China deployment.
1839
- *
1840
- * Same wire, different gateway host and roster — see `qoder-realm.ts`.
1841
- */
1842
- export function createQoderCnAdapter(options: QoderAdapterOptions = {}): ProviderAdapter {
1843
- return new QoderAdapter({ ...options, realm: "cn", providerId: "qodercn" });
1844
- }
1845
-
1846
- /**
1847
- * A COSY signer for one already-resolved account identity.
1848
- *
1849
- * Discovery needs signed headers but lives in `qoder-catalog.ts`, which cannot
1850
- * import this module (the adapter imports its rosters — that would be a cycle).
1851
- * Exporting the signer keeps one signing implementation and lets discovery
1852
- * stay pure and injectable.
1853
- */
1854
- export function qoderSignerFor(
1855
- auth: QoderAuth,
1856
- version = getQoderVersion(),
1857
- ): (url: string, body: string) => Record<string, string> {
1858
- return (url: string, body: string): Record<string, string> =>
1859
- qoderBuildCosyHeaders(Buffer.from(body, "latin1"), url, auth, version);
1860
- }
1861
-
1862
- /** Qoder's own base64 alphabet transform, for a request body. */
1863
- export function qoderEncodeBody(plaintext: Uint8Array | string): string {
1864
- const text = typeof plaintext === "string" ? plaintext : new TextDecoder().decode(plaintext);
1865
- return Buffer.from(transformQoderBody(text)).toString("latin1");
1866
- }
1867
-
1868
- /**
1869
- * Resolves the dispatch identity for one Qoder credential.
1870
- *
1871
- * Shared by the adapter and by discovery: both need the same `QoderAuth` the
1872
- * COSY signature is built from, and duplicating the PAT-vs-OAuth branch would
1873
- * let the two drift.
1874
- */
1875
- export async function resolveQoderAuth(input: {
1876
- readonly secret: string;
1877
- readonly authState: unknown;
1878
- readonly signal: AbortSignal;
1879
- readonly fetcher: (url: string, init: RequestInit) => Promise<Response>;
1880
- /** Which deployment mints the job token; a PAT is realm-bound. */
1881
- readonly realm?: QoderRealm;
1882
- }): Promise<QoderAuth> {
1883
- const realm = input.realm ?? "intl";
1884
- if (isQoderPat(input.secret)) {
1885
- return await exchangeQoderPat(input.secret, input.signal, input.fetcher, getQoderVersion(), realm);
1886
- }
1887
- // Discovery is handed only the secret and the persisted state, so the
1888
- // account id is read from that state — the same place `qoderOAuthAuth` reads
1889
- // it — rather than being a separate field callers can pass wrongly.
1890
- const state = parseQoderOAuthState(input.authState);
1891
- return qoderOAuthAuth(input.secret, input.authState, state?.userId);
1892
- }
94
+ export type { QoderCatalogEntry, QoderSigner } from "./qoder-catalog";
95
+ export {
96
+ QODER_VIRTUAL_MODELS,
97
+ qoderDispatchModelId,
98
+ isQoderVirtualModel,
99
+ qoderVirtualIdFor,
100
+ } from "./qoder-virtual";
101
+ export type { QoderVirtualModel } from "./qoder-virtual";
102
+
103
+ // OAuth device flow
104
+ export {
105
+ QoderOAuthClient,
106
+ qoderOAuthClient,
107
+ qoderCnOAuthClient,
108
+ parseQoderOAuthState,
109
+ readQoderUserInfo,
110
+ qoderHardwareMachineId,
111
+ qoderOAuthUrls,
112
+ } from "./qoder-oauth";
113
+ export type { QoderUserInfo, QoderOAuthState } from "./qoder-oauth";
114
+
115
+ // Quota
116
+ export { fetchQoderQuota, fetchQoderCnQuota } from "./qoder-quota";
117
+
118
+ // UMID / native identity
119
+ export {
120
+ mintQoderNativeIdentity,
121
+ replayQoderNativeIdentity,
122
+ mintQoderGeneratedIdentity,
123
+ qoderMachineHeaders,
124
+ ensurePosixExecutable,
125
+ umidpackDir,
126
+ umidMiniTarball,
127
+ umidMiniDir,
128
+ ensureUmidMiniExtracted,
129
+ } from "./qoder-umid";
130
+ export type { QoderNativeIdentity } from "./qoder-umid";
131
+ export {
132
+ DrizzleQoderNativeIdentityStore,
133
+ allocateQoderNativeIdentity,
134
+ ensureQoderNativeIdentity,
135
+ } from "./qoder-native-identity-store";
136
+ export type { QoderNativeIdentityStore, NativeIdentityMinter, NativeIdentityReplayer } from "./qoder-native-identity-store";
137
+
138
+ // Check-in / claims
139
+ export {
140
+ fetchQoderDailyCheckin,
141
+ buildCampaignHeaders,
142
+ isQoderCheckinProvider,
143
+ QODER_CHECKIN_PROVIDER_IDS,
144
+ } from "./qoder-checkin";
145
+ export { claimQoderCredits } from "./claim-qoder-credits";
146
+ export type { QoderCreditClaimResult } from "./claim-qoder-credits";
147
+
148
+ // SSE decode
149
+ export { decodeQoderSseEvents } from "./qoder-sse";
150
+ export type { QoderSseEvent } from "./qoder-sse";