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
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Qoder gRPC transport — `model.chat.ChatService/ChatCompletionStream`.
3
+ *
4
+ * This is the only proven surface for real 1M-token context: the HTTP IDE
5
+ * surface caps at ~256 KiB (~60K tokens), while gRPC accepts bodies up to
6
+ * ~5.5M–6M chars (~1.3M–1.4M tokens) — verified live 2026-11-05, see
7
+ * `documentation/research/qoder/live/2026-11-05-qoder-payload-limits.md`.
8
+ *
9
+ * gRPC only serves billed models: a free-tier row (`lite`) answers
10
+ * `quota exceeded` (116) on this surface, so free-tier dispatch must use the
11
+ * legacy COSY surface instead (see `qoder-adapter.ts` transport selection).
12
+ *
13
+ * The proto is vendored at `vendor/qoder-proto/chat.proto` (from the unpacked
14
+ * desktop client, version 1.1.81, `qoder-worker-runtime.mjs`).
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import * as grpc from "@grpc/grpc-js";
21
+ import * as protoLoader from "@grpc/proto-loader";
22
+ import { GatewayError } from "../../../protocol/gateway-error";
23
+ import type { CanonicalEvent, CanonicalRequest, CanonicalStopReason } from "../../../protocol/canonical-model";
24
+ import { usageFromProvider } from "../../provider-usage";
25
+ import { getQoderVersion } from "../../operations/client-versions";
26
+ import { qoderMachineOs, qoderMachineHostname } from "../../operations/cli-platform";
27
+ import { qoderSessionId } from "./qoder-auth";
28
+ import { flattenCanonicalContent } from "./qoder-body";
29
+ import { qoderGrpcHost } from "./qoder-realm";
30
+ import type { QoderAuth, QoderRealm } from "./qoder-types";
31
+ // The proto is embedded as a generated TS source (see `chat-proto.ts`), so
32
+ // the gRPC surface works from source, dist, and the standalone binary alike —
33
+ // the proto loader only reads from a path, so the string is materialized once
34
+ // to a temp file per process.
35
+ import { CHAT_PROTO_SOURCE } from "./chat-proto";
36
+
37
+ const PROTO_PATH = materializeChatProto();
38
+
39
+ function materializeChatProto(): string {
40
+ const dir = mkdtempSync(join(tmpdir(), "cartethyia-qoder-grpc-"));
41
+ const path = join(dir, "chat.proto");
42
+ writeFileSync(path, CHAT_PROTO_SOURCE);
43
+ // Best-effort cleanup; a leftover temp file is harmless but avoid keeping it.
44
+ process.on("exit", () => {
45
+ try {
46
+ rmSync(dir, { recursive: true, force: true });
47
+ } catch {
48
+ // ignore
49
+ }
50
+ });
51
+ return path;
52
+ }
53
+
54
+ let packageDefinitionCache: ReturnType<typeof protoLoader.loadSync> | null = null;
55
+ let grpcClientCache: any = null;
56
+
57
+ function loadProtoDefinition() {
58
+ if (packageDefinitionCache === null) {
59
+ packageDefinitionCache = protoLoader.loadSync(PROTO_PATH, {
60
+ keepCase: true,
61
+ longs: String,
62
+ enums: String,
63
+ defaults: false,
64
+ oneofs: true,
65
+ });
66
+ }
67
+ return packageDefinitionCache;
68
+ }
69
+
70
+ /** Loads (once) the gRPC service client. */
71
+ export function qoderGrpcClient(): any {
72
+ if (grpcClientCache !== null) return grpcClientCache;
73
+ const proto = grpc.loadPackageDefinition(loadProtoDefinition()) as Record<string, any>;
74
+ const model = proto.model as Record<string, any> | undefined;
75
+ grpcClientCache = model?.chat?.ChatService;
76
+ if (grpcClientCache === undefined) {
77
+ throw new Error("Qoder gRPC proto did not resolve model.chat.ChatService");
78
+ }
79
+ return grpcClientCache;
80
+ }
81
+
82
+ /**
83
+ * Channel options for the Qoder gRPC surface — mirrors the desktop client
84
+ * (`NDl` in the unpacked worker): disable HTTP proxy, pin the authority /
85
+ * SSL target name to the model-server host, and raise the message length
86
+ * limits to 32 MiB. grpc-js defaults to a 4 MiB receive cap, which aborts
87
+ * large streams that the upstream serves fine.
88
+ */
89
+ export function qoderGrpcChannelOptions(host: string): Record<string, number | string> {
90
+ return {
91
+ "grpc.enable_http_proxy": 0,
92
+ "grpc.default_authority": host,
93
+ "grpc.ssl_target_name_override": host,
94
+ "grpc.max_receive_message_length": 32 * 1024 * 1024,
95
+ "grpc.max_send_message_length": 32 * 1024 * 1024,
96
+ };
97
+ }
98
+
99
+ /** Maps one canonical message onto the proto `ChatMessage` shape. */
100
+ function mapMessage(message: CanonicalRequest["messages"][number]): Record<string, unknown> {
101
+ const text = flattenCanonicalContent(message.content);
102
+ const out: Record<string, unknown> = { role: message.role };
103
+ if (text.length > 0) out.text_content = text;
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Maps one already-translated OpenAI-shaped message (from `buildQoderRequest`)
109
+ * onto the proto `ChatMessage` shape — including structured tool history.
110
+ *
111
+ * The gRPC surface is a protobuf mirror of the same OpenAI-shaped body the
112
+ * HTTP IDE surface sends, so the structured transcript (assistant
113
+ * `tool_calls`, `tool` result turns) is mapped field-for-field instead of
114
+ * being flattened to text.
115
+ */
116
+ function toProtoChatMessage(msg: Record<string, unknown>): Record<string, unknown> {
117
+ const out: Record<string, unknown> = { role: msg["role"] };
118
+ const content = msg["content"];
119
+ if (typeof content === "string" && content.length > 0) out.text_content = content;
120
+ const toolCalls = msg["tool_calls"];
121
+ if (Array.isArray(toolCalls) && toolCalls.length > 0) {
122
+ out.tool_calls = toolCalls.map((tc) => {
123
+ const fn = (tc?.["function"] ?? {}) as Record<string, unknown>;
124
+ const callId = tc?.["call_id"];
125
+ const call = {
126
+ id: typeof tc["id"] === "string" && (tc["id"] as string).length > 0
127
+ ? { value: tc["id"] }
128
+ : undefined,
129
+ // The desktop client's `jDl` also carries `call_id` (StringValue)
130
+ // when the source message has one — preserve it for full tool-turn
131
+ // fidelity.
132
+ call_id: typeof callId === "string" && (callId as string).length > 0
133
+ ? { value: callId }
134
+ : undefined,
135
+ // `arguments` is a `google.protobuf.Value`; a JSON string maps to
136
+ // `{ stringValue: … }` (camelCase), matching the client's `Qat()`.
137
+ type: { value: "function" },
138
+ function: {
139
+ name: typeof fn["name"] === "string" ? (fn["name"] as string) : "",
140
+ arguments: {
141
+ stringValue:
142
+ typeof fn["arguments"] === "string"
143
+ ? (fn["arguments"] as string)
144
+ : fn["arguments"] !== undefined && fn["arguments"] !== null
145
+ ? JSON.stringify(fn["arguments"])
146
+ : "",
147
+ },
148
+ },
149
+ };
150
+ const outCall: Record<string, unknown> = {};
151
+ if (call.id !== undefined) outCall.id = call.id;
152
+ if (call.call_id !== undefined) outCall.call_id = call.call_id;
153
+ outCall.type = call.type;
154
+ outCall.function = call.function;
155
+ return outCall;
156
+ });
157
+ }
158
+ const toolCallId = msg["tool_call_id"];
159
+ if (typeof toolCallId === "string" && toolCallId.length > 0) {
160
+ out.tool_call_id = { value: toolCallId };
161
+ }
162
+ const reasoning = msg["reasoning_content"];
163
+ if (typeof reasoning === "string" && reasoning.length > 0) {
164
+ out.reasoning_content = { value: reasoning };
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Converts a plain JSON value into a `google.protobuf.Value` wire shape —
171
+ * mirroring the desktop client's `Qat()` helper. A JSON object becomes a
172
+ * `Struct` (`{ fields: Map<string, Value> }`), arrays become `listValue`,
173
+ * scalars map to `stringValue`/`numberValue`/`boolValue`/`nullValue`.
174
+ */
175
+ function toProtoValue(value: unknown): Record<string, unknown> {
176
+ if (value === null || value === undefined) return { nullValue: 0 };
177
+ if (typeof value === "number") return { numberValue: value };
178
+ if (typeof value === "string") return { stringValue: value };
179
+ if (typeof value === "boolean") return { boolValue: value };
180
+ if (Array.isArray(value)) return { listValue: { values: value.map(toProtoValue) } };
181
+ if (typeof value === "object") {
182
+ const fields: Record<string, unknown> = {};
183
+ for (const [k, v] of Object.entries(value)) fields[k] = toProtoValue(v);
184
+ return { structValue: { fields } };
185
+ }
186
+ return { stringValue: String(value) };
187
+ }
188
+
189
+ /**
190
+ * Converts a plain JSON object into a `google.protobuf.Struct` wire shape —
191
+ * the desktop client's `oBA()` helper. Struct fields must be wrapped as
192
+ * `Value`s, not forwarded raw; a raw JSON object inside a Struct is rejected
193
+ * at serialization time ("object expected").
194
+ */
195
+ function toProtoStruct(obj: Record<string, unknown>): Record<string, unknown> {
196
+ const fields: Record<string, unknown> = {};
197
+ for (const [k, v] of Object.entries(obj)) fields[k] = toProtoValue(v);
198
+ return { fields };
199
+ }
200
+
201
+ /**
202
+ * Converts a decoded `google.protobuf.Value` back to a plain JS value — the
203
+ * inverse of {@link toProtoValue}. The tagged union key (`stringValue`,
204
+ * `numberValue`, `boolValue`, `nullValue`, `structValue`, `listValue`) picks
205
+ * the payload; `kind` is metadata and ignored.
206
+ */
207
+ function fromProtoValue(value: unknown): unknown {
208
+ if (typeof value !== "object" || value === null) return value;
209
+ const v = value as Record<string, unknown>;
210
+ if ("stringValue" in v) return v.stringValue;
211
+ if ("numberValue" in v) return v.numberValue;
212
+ if ("boolValue" in v) return v.boolValue;
213
+ if ("nullValue" in v) return null;
214
+ if ("structValue" in v) return fromProtoStruct(v.structValue);
215
+ if ("listValue" in v) {
216
+ const inner = v.listValue as Record<string, unknown> | undefined;
217
+ const values = inner?.values;
218
+ return Array.isArray(values) ? values.map(fromProtoValue) : [];
219
+ }
220
+ return undefined;
221
+ }
222
+
223
+ /**
224
+ * Converts a decoded `google.protobuf.Struct` back to a plain JS object — the
225
+ * inverse of {@link toProtoStruct}. The upstream gRPC response carries
226
+ * `delta.tool_calls` as repeated Structs, and each element's fields are
227
+ * `Value` tagged unions (not raw JSON).
228
+ */
229
+ export function fromProtoStruct(struct: unknown): Record<string, unknown> {
230
+ if (typeof struct !== "object" || struct === null) return {};
231
+ const fields = (struct as Record<string, unknown>).fields;
232
+ if (typeof fields !== "object" || fields === null) return {};
233
+ const out: Record<string, unknown> = {};
234
+ for (const [k, v] of Object.entries(fields as Record<string, unknown>)) {
235
+ const decoded = fromProtoValue(v);
236
+ if (decoded !== undefined) out[k] = decoded;
237
+ }
238
+ return out;
239
+ }
240
+
241
+ /** One decoded `delta.tool_calls` element, in the shape the mapper consumes. */
242
+ export interface QoderProtoToolFragment {
243
+ /** Trimmed declared call id, or undefined when the delta omits it. */
244
+ readonly id?: string;
245
+ /** Wire `index` (0-based), or undefined when omitted. */
246
+ readonly index?: number;
247
+ /** Trimmed tool name, or undefined on continuation chunks. */
248
+ readonly name?: string;
249
+ /** Raw argument fragment string, or undefined when the delta has none. */
250
+ readonly args?: string;
251
+ }
252
+
253
+ /** A tool-call delta ready to become a canonical event (donor `$Dl` mirror). */
254
+ export interface QoderDrainedToolCall {
255
+ readonly callId: string;
256
+ readonly index: number;
257
+ readonly name: string | undefined;
258
+ readonly arguments: string;
259
+ }
260
+
261
+ /**
262
+ * Maps a decoded `delta.tool_calls` element to a canonical `tool_call_delta`
263
+ * exactly like the donor's `$Dl` — pass-through: `id`, `index`, `name`, and
264
+ * the raw `arguments` string fragment are forwarded as received, with no
265
+ * buffering, splitting, or JSON repair. The client stitches fragments by
266
+ * `index`/`id` itself (mirroring the donor's StreamAdapter `vTe`, which only
267
+ * tracks args for state and forwards each `partial_json` fragment verbatim).
268
+ */
269
+ export class QoderToolCallBuffer {
270
+ /** Consume one decoded tool-call delta element and emit its canonical event. */
271
+ consume(frag: QoderProtoToolFragment): QoderDrainedToolCall {
272
+ const { id, index, name, args } = frag;
273
+ const callId = id ?? (index !== undefined ? `call_${String(index)}` : `call_anon`);
274
+ return {
275
+ callId,
276
+ index: index ?? 0,
277
+ name,
278
+ arguments: args ?? "",
279
+ };
280
+ }
281
+ }
282
+
283
+ /** Stitching state for continuation fragments (per-stream). */
284
+ export interface QoderToolFragmentState {
285
+ /**
286
+ * The call id of the most recently announced tool call. Continuation
287
+ * fragments (which echo `id: ""` and a non-distinct `index: 0`) attach to
288
+ * this call — verified live (2026-11-05): upstream announces one call at a
289
+ * time (`id` + empty args), then streams that call's arguments as several
290
+ * small fragments before announcing the next call. The index is always 0,
291
+ * so it cannot distinguish calls.
292
+ */
293
+ lastSeenToolId?: string;
294
+ }
295
+
296
+ /**
297
+ * Resolves the call id for one wire fragment.
298
+ *
299
+ * - A fragment carrying a real `id` announces a (new) tool call: it becomes
300
+ * the current active call and is returned.
301
+ * - A continuation fragment (`id: ""`) belongs to the current active call —
302
+ * the upstream streams arguments in fragments right after the announce, and
303
+ * each parallel call's argument fragments arrive before the next announce.
304
+ *
305
+ * Without this, each continuation gets a fresh fallback id and the client
306
+ * sees an empty-arguments call plus orphaned fragments (regression found live
307
+ * 2026-11-05 via opencode→CarteLocal).
308
+ */
309
+ export function resolveQoderToolFragmentId(
310
+ state: QoderToolFragmentState,
311
+ declaredId: string | undefined,
312
+ ): string | undefined {
313
+ if (declaredId !== undefined && declaredId.length > 0) {
314
+ state.lastSeenToolId = declaredId;
315
+ return declaredId;
316
+ }
317
+ return state.lastSeenToolId;
318
+ }
319
+
320
+ /**
321
+ * Maps the shared OpenAI-shaped `tools` array (from `qoderTools`) onto the
322
+ * proto `Tool` shape — field-for-field matching the desktop client's `YDl()`
323
+ * mapper.
324
+ *
325
+ * Wrapper-typed fields (`StringValue description`, `BoolValue strict`) are
326
+ * sent as `{ value: … }`; `parameters` is a `Struct` so each value is wrapped
327
+ * by `toProtoStruct`. Undefined fields are dropped entirely (client `X6`).
328
+ */
329
+ function toProtoTools(tools: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
330
+ return tools.map((tool) => {
331
+ const fn = (tool["function"] ?? {}) as Record<string, unknown>;
332
+ const out: Record<string, unknown> = {
333
+ type: typeof tool["type"] === "string" ? (tool["type"] as string) : "function",
334
+ function: {
335
+ name: typeof fn["name"] === "string" ? (fn["name"] as string) : "",
336
+ parameters: toProtoStruct((fn["parameters"] as Record<string, unknown> | undefined) ?? { type: "object", properties: {} }),
337
+ },
338
+ };
339
+ const description = out["function"] as Record<string, unknown>;
340
+ if (typeof fn["description"] === "string" && (fn["description"] as string).length > 0) {
341
+ description["description"] = { value: fn["description"] };
342
+ }
343
+ if (typeof fn["strict"] === "boolean") {
344
+ description["strict"] = { value: fn["strict"] };
345
+ }
346
+ return out;
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Builds the gRPC `ChatCompletionRequest` from a canonical request.
352
+ *
353
+ * The request mirrors the OpenAI-shaped body the HTTP IDE surface sends: the
354
+ * messages, tool declarations, and tool-choice knobs come from
355
+ * `buildQoderRequest` (passed as `legacy`) so both transports share the exact
356
+ * same translation — including the structured tool history and the `tools`
357
+ * array. Without `tools` the upstream sees no toolset and answers with a DSML
358
+ * text block (or claims no tool exists) instead of a structured `tool_calls`.
359
+ */
360
+ export function buildQoderGrpcRequest(
361
+ modelId: string,
362
+ request: CanonicalRequest,
363
+ userId: string,
364
+ _contextWindow = 1_000_000,
365
+ legacy?: {
366
+ readonly messages: Array<Record<string, unknown>>;
367
+ readonly tools?: Array<Record<string, unknown>>;
368
+ readonly parameters?: Record<string, unknown>;
369
+ },
370
+ ): Record<string, unknown> {
371
+ const sessionId = qoderSessionId(userId, modelId);
372
+ const messages = legacy === undefined ? request.messages.map(mapMessage) : legacy.messages.map(toProtoChatMessage);
373
+ const tools = legacy === undefined ? [] : toProtoTools(legacy.tools ?? []);
374
+ const toolChoice = legacy?.parameters?.["tool_choice"];
375
+ const parallel = legacy?.parameters?.["parallel_tool_calls"];
376
+ // Donor mirror (`QFn`): `max_tokens` is sent only when the caller set it —
377
+ // the donor wraps the caller's value in `{value: …}` and omits the field
378
+ // entirely otherwise. Hard-coding 65536 turned a caller request into a
379
+ // different model behavior than the reference desktop client.
380
+ const maxTokens = legacy?.parameters?.["max_tokens"];
381
+ const contextLength = legacy?.parameters?.["context_length"];
382
+ return {
383
+ model: modelId,
384
+ messages,
385
+ // `stream` is a plain `bool` in the proto (field 6) — not a wrapper.
386
+ stream: true,
387
+ stream_options: { include_usage: { value: true } },
388
+ ...(maxTokens === undefined ? {} : { max_tokens: { value: Number(maxTokens) } }),
389
+ // `tools` is repeated; an empty array serializes to nothing on the wire,
390
+ // so it is safe to always include. The upstream only ever sees a
391
+ // declaration when one exists.
392
+ tools,
393
+ // Mirror the donor's `QFn`: `parallel_tool_calls` is sent only when the
394
+ // caller set it (the donor wraps the caller's parameter in `{value: …}`).
395
+ ...(parallel === undefined ? {} : { parallel_tool_calls: { value: Boolean(parallel) } }),
396
+ // `tool_choice` is a `google.protobuf.Value`; a string choice maps to
397
+ // `{ stringValue: … }` (camelCase), matching the desktop client's `Qat()`.
398
+ // gRPC rejects `tool_choice: "required"` outright (verified live
399
+ // 2026-11-05 model matrix: 400/406/422 on most models — dmodel/dfmodel/
400
+ // qmodel_38max/mmodel/cmodel). Omit it and let the neutral prompt carry the
401
+ // intent; `auto`/named choices are still sent.
402
+ ...(toolChoice !== undefined && toolChoice !== "required" ? { tool_choice: { stringValue: String(toolChoice) } } : {}),
403
+ metadata: {
404
+ context: {
405
+ request_id: randomUUID(),
406
+ session_id: sessionId,
407
+ task_id: "common",
408
+ request_set_id: randomUUID(),
409
+ client_type: "5",
410
+ source_session_id: "",
411
+ ...(contextLength === undefined ? {} : { context_length: String(contextLength) }),
412
+ },
413
+ business: {
414
+ product: "cli",
415
+ type: "agent",
416
+ version: getQoderVersion(),
417
+ },
418
+ },
419
+ };
420
+ }
421
+
422
+ /** gRPC metadata headers mirroring the desktop client's identity headers. */
423
+ export function qoderGrpcMetadata(auth: QoderAuth, modelId = ""): grpc.Metadata {
424
+ const metadata = new grpc.Metadata();
425
+ metadata.set("authorization", `Bearer ${auth.securityOauthToken}`);
426
+ metadata.set("x-request-id", randomUUID());
427
+ metadata.set("x-session-id", qoderSessionId(auth.userId, modelId));
428
+ // The desktop client's gRPC surface sends exactly this set (bundle
429
+ // `bDl`/`patchCompletionStream`): version, client type, real machine OS,
430
+ // and the sanitized hostname. It does NOT send `cosy-machinetype` /
431
+ // `cosy-machinetoken` on gRPC (those are HTTP-only identity headers), and
432
+ // sending a bogus device token here is a candidate for a server-side
433
+ // mid-stream reset — see documentation/proposals/qoder-grpc-wire-alignment.md.
434
+ const version = getQoderVersion();
435
+ metadata.set("cosy-version", version);
436
+ metadata.set("cosy-clienttype", "5");
437
+ metadata.set("cosy-machineos", qoderMachineOs());
438
+ const hostname = qoderMachineHostname();
439
+ if (hostname.length > 0) metadata.set("cosy-machinehostname", hostname);
440
+ return metadata;
441
+ }
442
+
443
+ /** Maps a proto `finish_reason` to the canonical stop reason. */
444
+ function mapFinishReason(raw: string | undefined): CanonicalStopReason | undefined {
445
+ switch (raw) {
446
+ case "stop":
447
+ return "stop";
448
+ case "tool_calls":
449
+ case "function_call":
450
+ return "tool_use";
451
+ case "length":
452
+ return "length";
453
+ default:
454
+ return undefined;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Streams one gRPC `ChatCompletionStream` call into canonical events.
460
+ *
461
+ * `signal` aborts the call; the gRPC status is mapped to a GatewayError when
462
+ * the call fails before any chunk.
463
+ */
464
+ export async function* qoderGrpcToCanonicalEvents(
465
+ request: Record<string, unknown>,
466
+ metadata: grpc.Metadata,
467
+ realm: QoderRealm,
468
+ signal: AbortSignal,
469
+ ): AsyncGenerator<CanonicalEvent> {
470
+ const ChatService = qoderGrpcClient();
471
+ const host = qoderGrpcHost(realm);
472
+ const client = new ChatService(`${host}:443`, grpc.credentials.createSsl(), qoderGrpcChannelOptions(host));
473
+ let sequence = 0;
474
+ const nextSeq = () => {
475
+ sequence += 1;
476
+ return sequence;
477
+ };
478
+ try {
479
+ const stream = client.ChatCompletionStream(request, metadata);
480
+ let aborted = false;
481
+ // Once a terminal event is emitted the turn is over: any later content,
482
+ // reasoning, or tool-call fragments must be dropped — strict OpenAI
483
+ // clients reject content after the finish reason. A second terminal is
484
+ // skipped too (the upstream can re-emit the finish frame).
485
+ let finished = false;
486
+
487
+ const abortHandler = () => {
488
+ aborted = true;
489
+ stream.cancel();
490
+ };
491
+ signal.addEventListener("abort", abortHandler, { once: true });
492
+
493
+ try {
494
+ let echoedModel: string | undefined;
495
+ // Tool-call deltas arrive as fragments on `delta.tool_calls` (same
496
+ // OpenAI shape as the HTTP surfaces). Each decoded element maps to one
497
+ // canonical `tool_call_delta` immediately, pass-through — the donor
498
+ // forwards fragments verbatim and the client stitches them by
499
+ // `index`/`id` itself.
500
+ const toolCallBuffer = new QoderToolCallBuffer();
501
+ /** Stitching state for continuation fragments. */
502
+ const fragmentState: QoderToolFragmentState = {};
503
+ for await (const chunk of stream) {
504
+ if (aborted || signal.aborted) break;
505
+ // The upstream names the model it actually ran in the chunk's `model`
506
+ // field. Surface it once as `response_start` so probes and telemetry
507
+ // can report the real echoed model (the gRPC surface has no HTTP body
508
+ // for `readReportedModel` to inspect).
509
+ if (echoedModel === undefined) {
510
+ if (typeof chunk.model === "string" && chunk.model.trim().length > 0) {
511
+ echoedModel = chunk.model.trim();
512
+ }
513
+ yield {
514
+ type: "response_start",
515
+ sequence_number: nextSeq(),
516
+ ...(echoedModel === undefined ? {} : { model: echoedModel }),
517
+ } satisfies CanonicalEvent as CanonicalEvent;
518
+ }
519
+ const choices = chunk.choices ?? [];
520
+ for (const choice of choices) {
521
+ const delta = choice.delta ?? {};
522
+ if (!finished && typeof delta.content === "string" && delta.content.length > 0) {
523
+ yield {
524
+ type: "content_delta",
525
+ sequence_number: nextSeq(),
526
+ content: { kind: "text", text: delta.content },
527
+ };
528
+ }
529
+ if (!finished && typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
530
+ yield {
531
+ type: "content_delta",
532
+ sequence_number: nextSeq(),
533
+ content: { kind: "reasoning", payload: delta.reasoning_content },
534
+ };
535
+ }
536
+ // `delta.tool_calls` is a repeated `google.protobuf.Struct` — each
537
+ // element arrives as a Struct-wrapped Value union (`{fields: {k:
538
+ // {stringValue/numberValue/...}}}`), NOT the plain OpenAI shape.
539
+ // Convert each element back to `{id, index, type, function}` before
540
+ // applying the same empty-id/continuation semantics as the HTTP
541
+ // surfaces: `id` only appears on the first chunk, so fragments
542
+ // rejoin the first-seen id per wire `index`.
543
+ const toolCalls = delta.tool_calls;
544
+ if (!finished && Array.isArray(toolCalls) && toolCalls.length > 0) {
545
+ for (const rawTc of toolCalls) {
546
+ const tc = fromProtoStruct(rawTc);
547
+ const fn = (tc["function"] ?? {}) as Record<string, unknown>;
548
+ const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
549
+ const declaredId =
550
+ typeof tc["id"] === "string" && (tc["id"] as string).trim().length > 0
551
+ ? (tc["id"] as string).trim()
552
+ : undefined;
553
+ const fragName =
554
+ typeof fn["name"] === "string" && fn["name"].trim().length > 0
555
+ ? (fn["name"] as string).trim()
556
+ : undefined;
557
+ const fragArgs =
558
+ typeof fn["arguments"] === "string"
559
+ ? (fn["arguments"] as string)
560
+ : fn["arguments"] !== undefined && fn["arguments"] !== null
561
+ ? JSON.stringify(fn["arguments"])
562
+ : undefined;
563
+ // Continuation fragments (`id: ""`) attach to the current active
564
+ // call — the upstream announces one call at a time (id + empty
565
+ // args) and then streams that call's arguments as several small
566
+ // fragments, with the index always 0. Without this, each
567
+ // fragment gets a fresh fallback id and the client sees an
568
+ // empty-arguments call plus orphaned fragments.
569
+ const pinnedId = resolveQoderToolFragmentId(fragmentState, declaredId);
570
+ const call = toolCallBuffer.consume({
571
+ ...(pinnedId !== undefined ? { id: pinnedId } : {}),
572
+ ...(index !== undefined ? { index } : {}),
573
+ ...(fragName !== undefined ? { name: fragName } : {}),
574
+ ...(fragArgs !== undefined && fragArgs.length > 0 ? { args: fragArgs } : {}),
575
+ });
576
+ yield {
577
+ type: "tool_call_delta",
578
+ sequence_number: nextSeq(),
579
+ call_id: call.callId,
580
+ index: call.index,
581
+ ...(call.name === undefined ? {} : { name: call.name }),
582
+ arguments_delta: call.arguments,
583
+ } satisfies CanonicalEvent as CanonicalEvent;
584
+ }
585
+ }
586
+ if (!finished && typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
587
+ const stop = mapFinishReason(choice.finish_reason);
588
+ if (stop !== undefined) {
589
+ // The terminal verdict follows the tool-call deltas, which were
590
+ // already yielded as they arrived — the client must see the
591
+ // calls before the finish reason, or it records
592
+ // `finish_reason=tool_calls` with zero tool calls.
593
+ finished = true;
594
+ // `tool_use` is a *successful* terminal verdict — the model
595
+ // decided to call a tool — so the terminal state is `complete`
596
+ // (same as `stop`), never `failed`. A `failed` state here made
597
+ // attempt-finalize treat a healthy tool turn as an upstream
598
+ // failure ("empty-handed") and replay it.
599
+ yield {
600
+ type: "terminal",
601
+ sequence_number: nextSeq(),
602
+ state: stop === "stop" || stop === "tool_use" ? "complete" : "failed",
603
+ stop_reason: stop,
604
+ provider_stop_reason: choice.finish_reason,
605
+ };
606
+ }
607
+ }
608
+ }
609
+ if (chunk.usage) {
610
+ const usage = usageFromProvider(chunk.usage);
611
+ if (usage !== undefined) {
612
+ yield { type: "usage", sequence_number: nextSeq(), usage };
613
+ }
614
+ }
615
+ }
616
+ // Tool-call deltas are emitted as they arrive — there is no end-of-stream
617
+ // buffer to flush; the terminal event is yielded by the finish-reason
618
+ // branch above (or synthesized by the caller if the stream ends without
619
+ // one).
620
+ } catch (err: any) {
621
+ const status = err?.code;
622
+ const details = err?.details ?? err?.message ?? "";
623
+ if (typeof status === "number" && status !== 0) {
624
+ if (status === 16) {
625
+ throw new GatewayError(
626
+ "authentication_failed",
627
+ 401,
628
+ "Qoder gRPC rejected the credential (request {requestId}). Wrong key, right door — check the credential 🔑",
629
+ {},
630
+ "upstream",
631
+ );
632
+ }
633
+ if (details.includes("413")) {
634
+ throw new GatewayError(
635
+ "context_length_exceeded",
636
+ 413,
637
+ "The request exceeds the upstream context cap (request {requestId}). Trim the conversation and retry ✂️",
638
+ {},
639
+ "upstream",
640
+ );
641
+ }
642
+ if (status === 13 && details.includes("quota")) {
643
+ throw new GatewayError(
644
+ "quota_exceeded",
645
+ 429,
646
+ "Qoder quota exhausted on the gRPC surface (request {requestId})",
647
+ {},
648
+ "upstream",
649
+ );
650
+ }
651
+ throw new GatewayError(
652
+ "platform_unavailable",
653
+ 502,
654
+ `Qoder gRPC stream failed (${details}) (request {requestId})`,
655
+ {},
656
+ "upstream",
657
+ );
658
+ }
659
+ throw err;
660
+ } finally {
661
+ signal.removeEventListener("abort", abortHandler);
662
+ stream.destroy();
663
+ }
664
+ } finally {
665
+ client.close();
666
+ }
667
+ }