opencode-qoder-bridge 0.1.8 → 0.1.10

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 (70) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/README.md +112 -45
  3. package/bin/statusline.mjs +30 -6
  4. package/bin/usage.mjs +6 -5
  5. package/dist/async-utils.d.ts +8 -0
  6. package/dist/async-utils.d.ts.map +1 -0
  7. package/dist/async-utils.js +27 -0
  8. package/dist/async-utils.js.map +1 -0
  9. package/dist/auth.d.ts +2 -1
  10. package/dist/auth.d.ts.map +1 -1
  11. package/dist/auth.js +26 -7
  12. package/dist/auth.js.map +1 -1
  13. package/dist/cost.d.ts +2 -1
  14. package/dist/cost.d.ts.map +1 -1
  15. package/dist/cost.js +163 -63
  16. package/dist/cost.js.map +1 -1
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +5 -2
  19. package/dist/errors.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +187 -41
  23. package/dist/index.js.map +1 -1
  24. package/dist/language-model.d.ts +25 -0
  25. package/dist/language-model.d.ts.map +1 -1
  26. package/dist/language-model.js +938 -183
  27. package/dist/language-model.js.map +1 -1
  28. package/dist/logger.d.ts +1 -0
  29. package/dist/logger.d.ts.map +1 -1
  30. package/dist/logger.js +35 -5
  31. package/dist/logger.js.map +1 -1
  32. package/dist/mcp-bridge.d.ts.map +1 -1
  33. package/dist/mcp-bridge.js +71 -7
  34. package/dist/mcp-bridge.js.map +1 -1
  35. package/dist/models.d.ts +25 -6
  36. package/dist/models.d.ts.map +1 -1
  37. package/dist/models.js +444 -85
  38. package/dist/models.js.map +1 -1
  39. package/dist/prompt-builder.d.ts.map +1 -1
  40. package/dist/prompt-builder.js +241 -43
  41. package/dist/prompt-builder.js.map +1 -1
  42. package/dist/sdk-auth.d.ts +3 -3
  43. package/dist/sdk-auth.d.ts.map +1 -1
  44. package/dist/sdk-auth.js +13 -9
  45. package/dist/sdk-auth.js.map +1 -1
  46. package/dist/sdk-session.d.ts.map +1 -1
  47. package/dist/sdk-session.js +26 -2
  48. package/dist/sdk-session.js.map +1 -1
  49. package/dist/session-store.d.ts +14 -3
  50. package/dist/session-store.d.ts.map +1 -1
  51. package/dist/session-store.js +451 -46
  52. package/dist/session-store.js.map +1 -1
  53. package/dist/state-dir.d.ts.map +1 -1
  54. package/dist/state-dir.js +2 -1
  55. package/dist/state-dir.js.map +1 -1
  56. package/dist/tool-normalizer.d.ts.map +1 -1
  57. package/dist/tool-normalizer.js +11 -2
  58. package/dist/tool-normalizer.js.map +1 -1
  59. package/dist/tui-register.d.ts.map +1 -1
  60. package/dist/tui-register.js +88 -37
  61. package/dist/tui-register.js.map +1 -1
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +42 -17
  64. package/dist/tui.js.map +1 -1
  65. package/dist/types.d.ts +16 -4
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/usage.d.ts.map +1 -1
  68. package/dist/usage.js +54 -29
  69. package/dist/usage.js.map +1 -1
  70. package/package.json +12 -4
@@ -1,35 +1,303 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
2
4
  import { query } from "@qoder-ai/qoder-agent-sdk";
3
- import { getModel, DEFAULT_MODEL_ID } from "./models.js";
5
+ import { getModel, DEFAULT_MODEL_ID, applyLiveModelUpdates } from "./models.js";
4
6
  import { findQoderCLI } from "./auth.js";
5
7
  import { buildPromptString, buildPromptIterable, latestPrompt, promptHasImage } from "./prompt-builder.js";
6
8
  import { normalizeToolName, normalizeToolInputString } from "./tool-normalizer.js";
7
9
  import { recordTurn } from "./cost.js";
8
- import { ensureQoderSession, getQoderSession } from "./session-store.js";
10
+ import { deleteQoderSession, ensureQoderSession, getQoderSessionForCwd, getQoderSessionResetEpoch, withQoderSessionLease } from "./session-store.js";
9
11
  import { hasQoderCredential, qoderAuth } from "./sdk-auth.js";
10
12
  import { QoderAuthError, QoderSdkResultError } from "./errors.js";
11
- import { debug, describeError } from "./logger.js";
13
+ import { debug, describeError, redactSensitiveText } from "./logger.js";
14
+ const UNSAFE_METADATA_KEYS = new Set(["__proto__", "prototype", "constructor"]);
15
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30 * 60 * 1000;
16
+ const MAX_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000;
17
+ const CLEANUP_GRACE_MS = 5_000;
18
+ const MAX_SEEN_MESSAGE_IDS = 100_000;
19
+ const MAX_TOOL_INPUT_CHARS = 4_000_000;
20
+ const MAX_OUTPUT_CHARS = 8_000_000;
21
+ const MAX_METADATA_NODES = 2_000;
22
+ const MAX_METADATA_STRING = 4_096;
23
+ const MAX_STOP_REASON_LENGTH = 256;
24
+ const QODER_BUILTIN_NAMES = {
25
+ read: "Read",
26
+ write: "Write",
27
+ edit: "Edit",
28
+ delete: "Delete",
29
+ view: "View",
30
+ bash: "Bash",
31
+ glob: "Glob",
32
+ grep: "Grep",
33
+ task: "Agent",
34
+ task_create: "TaskCreate",
35
+ taskcreate: "TaskCreate",
36
+ task_get: "TaskGet",
37
+ taskget: "TaskGet",
38
+ task_update: "TaskUpdate",
39
+ taskupdate: "TaskUpdate",
40
+ task_list: "TaskList",
41
+ tasklist: "TaskList",
42
+ question: "AskUserQuestion",
43
+ ask_user_question: "AskUserQuestion",
44
+ plan_exit: "ExitPlanMode",
45
+ exit_plan_mode: "ExitPlanMode",
46
+ skill: "Skill",
47
+ todo_write: "TodoWrite",
48
+ todowrite: "TodoWrite",
49
+ update_goal: "UpdateGoal",
50
+ updategoal: "UpdateGoal",
51
+ web_fetch: "WebFetch",
52
+ webfetch: "WebFetch",
53
+ web_search: "WebSearch",
54
+ websearch: "WebSearch",
55
+ image_gen: "ImageGen",
56
+ imagegen: "ImageGen",
57
+ image_search: "ImageSearch",
58
+ imagesearch: "ImageSearch",
59
+ notebook_edit: "NotebookEdit",
60
+ notebookedit: "NotebookEdit",
61
+ };
62
+ function toJsonValue(value, depth = 0, budget = { remaining: MAX_METADATA_NODES }) {
63
+ if (depth > 8)
64
+ return undefined;
65
+ if (budget.remaining-- <= 0)
66
+ return undefined;
67
+ if (value === null || typeof value === "boolean")
68
+ return value;
69
+ if (typeof value === "string")
70
+ return value.slice(0, MAX_METADATA_STRING);
71
+ if (typeof value === "number")
72
+ return Number.isFinite(value) ? value : undefined;
73
+ if (Array.isArray(value)) {
74
+ const out = [];
75
+ for (const item of value) {
76
+ const normalized = toJsonValue(item, depth + 1, budget);
77
+ if (normalized !== undefined)
78
+ out.push(normalized);
79
+ if (budget.remaining <= 0)
80
+ break;
81
+ }
82
+ return out;
83
+ }
84
+ if (!isRecord(value))
85
+ return undefined;
86
+ const out = {};
87
+ for (const [key, item] of Object.entries(value)) {
88
+ if (UNSAFE_METADATA_KEYS.has(key))
89
+ continue;
90
+ const normalized = toJsonValue(item, depth + 1, budget);
91
+ if (normalized !== undefined) {
92
+ Object.defineProperty(out, key.slice(0, 256), {
93
+ configurable: true,
94
+ enumerable: true,
95
+ value: normalized,
96
+ writable: true,
97
+ });
98
+ }
99
+ if (budget.remaining <= 0)
100
+ break;
101
+ }
102
+ return out;
103
+ }
104
+ function safeEnqueue(controller, part) {
105
+ try {
106
+ controller.enqueue(part);
107
+ return true;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ function safeClose(controller) {
114
+ try {
115
+ controller.close();
116
+ }
117
+ catch {
118
+ // Controller already closed or cancelled
119
+ }
120
+ }
12
121
  function isRecord(v) {
13
122
  return typeof v === "object" && v !== null && !Array.isArray(v);
14
123
  }
15
124
  function makeFinishReason(unified, raw) {
16
- return { unified, raw };
125
+ return {
126
+ unified,
127
+ raw: raw === undefined ? undefined : redactSensitiveText(raw).slice(0, MAX_STOP_REASON_LENGTH),
128
+ };
129
+ }
130
+ function safeStopReason(value) {
131
+ return typeof value === "string"
132
+ ? redactSensitiveText(value).slice(0, MAX_STOP_REASON_LENGTH)
133
+ : null;
17
134
  }
18
135
  function makeUsage(input, output, cacheRead, cacheWrite) {
136
+ const safeInput = finiteNonNegative(input);
137
+ const safeOutput = finiteNonNegative(output);
138
+ const safeCacheRead = Math.min(safeInput, finiteNonNegative(cacheRead));
139
+ const safeCacheWrite = Math.min(safeInput - safeCacheRead, finiteNonNegative(cacheWrite));
19
140
  return {
20
141
  inputTokens: {
21
- total: input,
22
- noCache: Math.max(0, input - cacheRead - cacheWrite),
23
- cacheRead,
24
- cacheWrite,
142
+ total: safeInput,
143
+ noCache: Math.max(0, safeInput - safeCacheRead - safeCacheWrite),
144
+ cacheRead: safeCacheRead,
145
+ cacheWrite: safeCacheWrite,
25
146
  },
26
147
  outputTokens: {
27
- total: output,
28
- text: output,
148
+ total: safeOutput,
149
+ text: safeOutput,
29
150
  reasoning: undefined,
30
151
  },
31
152
  };
32
153
  }
154
+ function finiteNonNegative(value, fallback = 0) {
155
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
156
+ }
157
+ function requestTimeoutMs(value) {
158
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
159
+ return DEFAULT_REQUEST_TIMEOUT_MS;
160
+ return Math.min(MAX_REQUEST_TIMEOUT_MS, Math.max(1, Math.floor(value)));
161
+ }
162
+ function abortError() {
163
+ const error = new Error("Qoder request aborted");
164
+ error.name = "AbortError";
165
+ return error;
166
+ }
167
+ function safePublicError(error) {
168
+ if (error instanceof QoderAuthError || error instanceof QoderSdkResultError)
169
+ return error;
170
+ return new Error(describeError(error) || "Qoder request failed");
171
+ }
172
+ function tokenCount(value) {
173
+ return Math.floor(finiteNonNegative(value));
174
+ }
175
+ function ratio(value) {
176
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
177
+ ? Math.min(1, value)
178
+ : undefined;
179
+ }
180
+ function safeJsonStringify(value) {
181
+ try {
182
+ return JSON.stringify(value);
183
+ }
184
+ catch {
185
+ return undefined;
186
+ }
187
+ }
188
+ function messageDedupeKey(message) {
189
+ const uuid = typeof message.uuid === "string" && message.uuid.trim() ? message.uuid : undefined;
190
+ if (!uuid)
191
+ return undefined;
192
+ // Some SDK versions reuse an outer UUID for several stream frames, while
193
+ // replayed frames repeat the complete payload. Hashing the payload keeps
194
+ // the latter idempotent without dropping legitimate same-UUID frames.
195
+ const payload = safeJsonStringify(message) ?? `${message.type ?? ""}:${message.event ?? ""}`;
196
+ return `${uuid}\u0000${createHash("sha256").update(payload).digest("hex")}`;
197
+ }
198
+ function rememberId(seen, id, maxSize) {
199
+ if (seen.has(id))
200
+ return false;
201
+ if (seen.size >= maxSize) {
202
+ const oldest = seen.values().next().value;
203
+ if (typeof oldest === "string")
204
+ seen.delete(oldest);
205
+ }
206
+ seen.add(id);
207
+ return true;
208
+ }
209
+ function isAuthenticationError(value) {
210
+ if (typeof value !== "string")
211
+ return false;
212
+ return /auth|credential|token|unauthori|forbidden/i.test(value);
213
+ }
214
+ function resolveCwd(value) {
215
+ if (typeof value !== "string" || !value.trim())
216
+ return process.cwd();
217
+ try {
218
+ const resolved = resolve(value);
219
+ try {
220
+ return realpathSync(resolved);
221
+ }
222
+ catch {
223
+ return resolved;
224
+ }
225
+ }
226
+ catch {
227
+ return process.cwd();
228
+ }
229
+ }
230
+ function qoderEnvironment(environment) {
231
+ return environment ? { ...process.env, ...environment } : process.env;
232
+ }
233
+ const sessionTails = new Map();
234
+ function waitForTurn(previous, signal) {
235
+ if (!signal)
236
+ return previous.then(() => true, () => true);
237
+ if (signal.aborted)
238
+ return Promise.resolve(false);
239
+ return new Promise((resolveResult) => {
240
+ let settled = false;
241
+ const finish = (ready) => {
242
+ if (settled)
243
+ return;
244
+ settled = true;
245
+ signal.removeEventListener("abort", onAbort);
246
+ resolveResult(ready);
247
+ };
248
+ const onAbort = () => finish(false);
249
+ signal.addEventListener("abort", onAbort, { once: true });
250
+ previous.then(() => finish(true), () => finish(true));
251
+ if (signal.aborted)
252
+ finish(false);
253
+ });
254
+ }
255
+ async function nextWithAbort(iterator, signal) {
256
+ if (signal.aborted)
257
+ return undefined;
258
+ let onAbort;
259
+ const aborted = new Promise((resolveAbort) => {
260
+ onAbort = () => resolveAbort(undefined);
261
+ signal.addEventListener("abort", onAbort, { once: true });
262
+ });
263
+ try {
264
+ return await Promise.race([iterator.next(), aborted]);
265
+ }
266
+ finally {
267
+ signal.removeEventListener("abort", onAbort);
268
+ }
269
+ }
270
+ async function withSessionLock(key, signal, fn) {
271
+ if (!key) {
272
+ if (signal?.aborted)
273
+ return undefined;
274
+ return fn();
275
+ }
276
+ const previous = sessionTails.get(key) ?? Promise.resolve();
277
+ let release;
278
+ const current = new Promise((resolveRelease) => { release = resolveRelease; });
279
+ sessionTails.set(key, current);
280
+ const acquired = await waitForTurn(previous, signal);
281
+ const releaseAndClean = () => {
282
+ release();
283
+ if (sessionTails.get(key) === current)
284
+ sessionTails.delete(key);
285
+ };
286
+ if (!acquired) {
287
+ // Keep the queue closed behind the still-running request, even when this
288
+ // waiter is canceled before it reaches the SDK.
289
+ void previous.then(releaseAndClean, releaseAndClean);
290
+ return undefined;
291
+ }
292
+ try {
293
+ if (signal?.aborted)
294
+ return undefined;
295
+ return await fn();
296
+ }
297
+ finally {
298
+ releaseAndClean();
299
+ }
300
+ }
33
301
  function mapStopReason(stopReason, hasToolCalls) {
34
302
  if (hasToolCalls)
35
303
  return makeFinishReason("tool-calls", stopReason ?? undefined);
@@ -42,9 +310,33 @@ function mapStopReason(stopReason, hasToolCalls) {
42
310
  return makeFinishReason("stop", stopReason ?? undefined);
43
311
  }
44
312
  }
313
+ function trackOpenBlock(state, block) {
314
+ if (!state.openBlocks.some((item) => item.index === block.index))
315
+ state.openBlocks.push(block);
316
+ }
317
+ function untrackOpenBlock(state, index) {
318
+ const position = state.openBlocks.findIndex((item) => item.index === index);
319
+ if (position >= 0)
320
+ state.openBlocks.splice(position, 1);
321
+ }
45
322
  export function isProviderExecutedTool(name, functionToolNames) {
46
323
  return !functionToolNames.has(name);
47
324
  }
325
+ function isProviderOwnedTool(rawName, normalizedName, functionToolNames) {
326
+ // Bridged MCP servers are executed by Qoder. Do not normalize an MCP name
327
+ // into an unrelated OpenCode function and execute the same call twice.
328
+ if (rawName.trim().toLowerCase().startsWith("mcp__"))
329
+ return true;
330
+ return isProviderExecutedTool(normalizedName, functionToolNames);
331
+ }
332
+ function qoderToolNameForHost(rawName) {
333
+ const trimmed = rawName.trim();
334
+ const normalized = normalizeToolName(trimmed);
335
+ return QODER_BUILTIN_NAMES[normalized] ?? trimmed;
336
+ }
337
+ function isProviderOwnedToolName(name) {
338
+ return name.trim().toLowerCase().startsWith("mcp__");
339
+ }
48
340
  export class QoderLanguageModel {
49
341
  specificationVersion = "v3";
50
342
  provider = "qoder";
@@ -56,89 +348,155 @@ export class QoderLanguageModel {
56
348
  this.bridgeOptions = bridgeOptions;
57
349
  }
58
350
  async doGenerate(options) {
351
+ if (options.abortSignal?.aborted)
352
+ throw abortError();
59
353
  const { stream } = await this.doStream(options);
60
354
  const reader = stream.getReader();
61
355
  let text = "";
62
356
  let reasoning = "";
63
357
  let finishReason = makeFinishReason("stop");
64
358
  let usage = makeUsage(0, 0, 0, 0);
359
+ let providerMetadata;
360
+ let sawFinish = false;
65
361
  const toolCalls = [];
66
- for (;;) {
67
- const { value, done } = await reader.read();
68
- if (done)
69
- break;
70
- switch (value.type) {
71
- case "text-delta":
72
- text += value.delta;
362
+ try {
363
+ for (;;) {
364
+ const { value, done } = await reader.read();
365
+ if (done)
73
366
  break;
74
- case "reasoning-delta":
75
- reasoning += value.delta;
76
- break;
77
- case "tool-call":
78
- toolCalls.push({
79
- type: "tool-call",
80
- toolCallId: value.toolCallId,
81
- toolName: value.toolName,
82
- input: value.input,
83
- });
84
- break;
85
- case "finish":
86
- finishReason = value.finishReason;
87
- usage = value.usage;
88
- break;
89
- case "error":
90
- throw value.error instanceof Error ? value.error : new Error(String(value.error));
367
+ switch (value.type) {
368
+ case "text-delta":
369
+ text += value.delta;
370
+ break;
371
+ case "reasoning-delta":
372
+ reasoning += value.delta;
373
+ break;
374
+ case "tool-call":
375
+ toolCalls.push({
376
+ type: "tool-call",
377
+ toolCallId: value.toolCallId,
378
+ toolName: value.toolName,
379
+ input: value.input,
380
+ });
381
+ break;
382
+ case "finish":
383
+ sawFinish = true;
384
+ finishReason = value.finishReason;
385
+ usage = value.usage;
386
+ providerMetadata = value.providerMetadata;
387
+ break;
388
+ case "error":
389
+ throw safePublicError(value.error);
390
+ }
91
391
  }
92
392
  }
393
+ catch (error) {
394
+ try {
395
+ await reader.cancel();
396
+ }
397
+ catch { /* best-effort stream cleanup */ }
398
+ throw error;
399
+ }
400
+ finally {
401
+ try {
402
+ reader.releaseLock();
403
+ }
404
+ catch { /* reader may already be released */ }
405
+ }
406
+ if (!sawFinish) {
407
+ if (options.abortSignal?.aborted)
408
+ throw abortError();
409
+ throw new QoderSdkResultError("incomplete_stream", "Qoder stream closed before sending a finish message");
410
+ }
93
411
  const content = [];
94
412
  if (reasoning)
95
413
  content.push({ type: "reasoning", text: reasoning });
96
414
  if (text)
97
415
  content.push({ type: "text", text });
98
416
  content.push(...toolCalls);
99
- return { content, finishReason, usage, warnings: [] };
417
+ return { content, finishReason, usage, providerMetadata, warnings: [] };
100
418
  }
101
419
  async doStream(options) {
102
420
  const cli = findQoderCLI();
103
- if (!hasQoderCredential()) {
421
+ const childEnvironment = qoderEnvironment(this.bridgeOptions.env);
422
+ if (!hasQoderCredential(childEnvironment)) {
104
423
  throw new QoderAuthError("No Qoder credentials found. Run `qoder login` or set QODER_PERSONAL_ACCESS_TOKEN.");
105
424
  }
106
- const resolved = getModel(this.modelId) ?? getModel(DEFAULT_MODEL_ID);
107
- if (!getModel(this.modelId)) {
108
- debug(`Unknown model id "${this.modelId}"; falling back to "${resolved.id}"`);
425
+ const cwd = resolveCwd(this.bridgeOptions.cwd);
426
+ const modelDiscoveryOptions = { cwd };
427
+ if (this.bridgeOptions.proxy)
428
+ modelDiscoveryOptions.proxy = this.bridgeOptions.proxy;
429
+ if (this.bridgeOptions.vpcEndpoint)
430
+ modelDiscoveryOptions.vpcEndpoint = this.bridgeOptions.vpcEndpoint;
431
+ const resolved = getModel(this.modelId, childEnvironment, modelDiscoveryOptions);
432
+ const model = resolved ?? getModel(DEFAULT_MODEL_ID, childEnvironment, modelDiscoveryOptions);
433
+ if (!resolved) {
434
+ // Use the default catalog entry only for conservative prompt limits.
435
+ // Preserve the requested ID on the SDK call so an unknown model is not
436
+ // silently replaced by a different model.
437
+ debug(`Unknown model id "${this.modelId}"; forwarding it with default prompt limits`);
109
438
  }
110
- const model = resolved;
111
439
  const sessionKey = this.bridgeOptions.sessionKey ?? this.bridgeOptions.sessionId;
112
- const persisted = this.bridgeOptions.sessionPersistence && sessionKey
113
- ? await getQoderSession(sessionKey)
114
- : null;
115
- const sessionId = this.bridgeOptions.sessionId ?? persisted?.qoderSessionId ?? randomUUID();
116
- const shouldResume = Boolean(this.bridgeOptions.sessionId || persisted);
117
- debug(`doStream model=${model.id} sessionId=${sessionId} resume=${shouldResume}`);
118
- const promptMessages = options.prompt;
119
- const promptInput = shouldResume
120
- ? latestPrompt(promptMessages)
121
- : promptMessages;
122
- const prompt = promptHasImage(promptInput)
123
- ? buildPromptIterable(promptInput, model.limit.context, sessionId)
124
- : buildPromptString(promptInput, model.limit.context);
125
440
  const functionToolNames = new Set((options.tools ?? [])
126
441
  .filter((t) => t.type === "function")
127
442
  .map((t) => normalizeToolName(t.name)));
443
+ const hostToolNames = (options.tools ?? [])
444
+ .filter((tool) => tool.type === "function")
445
+ // MCP tools configured through mcpServers are executed by Qoder. They
446
+ // are provider-owned and must not be added to the native denylist.
447
+ .filter((tool) => !isProviderOwnedToolName(tool.name))
448
+ .map((tool) => qoderToolNameForHost(tool.name));
128
449
  const abortController = new AbortController();
129
450
  let qoderQuery = null;
130
- let cleaned = false;
131
451
  let externallyAborted = false;
452
+ let timedOut = false;
453
+ let requestTimer;
454
+ let cleanupPromise;
455
+ const timeoutMs = requestTimeoutMs(this.bridgeOptions.timeoutMs);
456
+ const timeoutError = () => new QoderSdkResultError("timeout", `Qoder request exceeded ${timeoutMs}ms`);
132
457
  const cleanup = () => {
133
- if (cleaned)
134
- return;
135
- cleaned = true;
136
- abortController.abort();
137
- void qoderQuery?.return(undefined).catch(() => { });
458
+ if (cleanupPromise)
459
+ return cleanupPromise;
460
+ if (requestTimer)
461
+ clearTimeout(requestTimer);
462
+ if (options.abortSignal) {
463
+ try {
464
+ options.abortSignal.removeEventListener("abort", markExternal);
465
+ }
466
+ catch { /* ignore */ }
467
+ }
468
+ try {
469
+ abortController.abort();
470
+ }
471
+ catch { /* best-effort cancellation */ }
472
+ const activeQuery = qoderQuery;
473
+ cleanupPromise = (async () => {
474
+ if (!activeQuery)
475
+ return;
476
+ let graceTimer;
477
+ try {
478
+ await Promise.race([
479
+ activeQuery.return(undefined).then(() => undefined, () => undefined),
480
+ new Promise((resolveCleanup) => {
481
+ graceTimer = setTimeout(resolveCleanup, CLEANUP_GRACE_MS);
482
+ if (typeof graceTimer.unref === "function")
483
+ graceTimer.unref();
484
+ }),
485
+ ]);
486
+ }
487
+ catch {
488
+ // A transport may reject/throw while it is being torn down.
489
+ }
490
+ finally {
491
+ if (graceTimer)
492
+ clearTimeout(graceTimer);
493
+ }
494
+ })();
495
+ return cleanupPromise;
138
496
  };
139
497
  const markExternal = () => {
140
498
  externallyAborted = true;
141
- cleanup();
499
+ void cleanup();
142
500
  };
143
501
  if (options.abortSignal) {
144
502
  if (options.abortSignal.aborted)
@@ -146,7 +504,6 @@ export class QoderLanguageModel {
146
504
  else
147
505
  options.abortSignal.addEventListener("abort", markExternal, { once: true });
148
506
  }
149
- const qoderOptions = this.buildQueryOptions(cli, sessionId, abortController, shouldResume);
150
507
  const stream = new ReadableStream({
151
508
  cancel: markExternal,
152
509
  start: async (controller) => {
@@ -157,6 +514,7 @@ export class QoderLanguageModel {
157
514
  activeText: new Set(),
158
515
  activeReasoning: new Set(),
159
516
  toolBlocks: new Map(),
517
+ openBlocks: [],
160
518
  sawStreamText: false,
161
519
  sawStreamTool: false,
162
520
  sawStreamReasoning: false,
@@ -164,82 +522,237 @@ export class QoderLanguageModel {
164
522
  pendingToolCalls: new Map(),
165
523
  lastStopReason: null,
166
524
  blockCounter: 0,
525
+ outputChars: 0,
167
526
  finished: false,
527
+ resultReceived: false,
528
+ seenToolCallIds: new Set(),
529
+ seenMessageIds: new Set(),
530
+ artifacts: [],
531
+ planMode: undefined,
532
+ skillEvolution: undefined,
533
+ modelEnvironment: childEnvironment,
534
+ modelDiscoveryOptions,
168
535
  };
169
- controller.enqueue({ type: "stream-start", warnings: [] });
536
+ safeEnqueue(controller, { type: "stream-start", warnings: [] });
170
537
  try {
171
- qoderQuery = query({ prompt, options: qoderOptions });
172
- if (this.bridgeOptions.sessionPersistence && sessionKey) {
173
- await ensureQoderSession(sessionKey, sessionId, process.cwd());
538
+ if (externallyAborted) {
539
+ safeClose(controller);
540
+ return;
174
541
  }
175
- for await (const msg of qoderQuery) {
176
- handleSdkMessage(msg, state);
177
- if (state.finished)
178
- break;
542
+ requestTimer = setTimeout(() => {
543
+ timedOut = true;
544
+ debug(`Qoder request timed out after ${timeoutMs}ms`);
545
+ void cleanup();
546
+ }, timeoutMs);
547
+ if (typeof requestTimer.unref === "function")
548
+ requestTimer.unref();
549
+ const lockKey = sessionKey ? `${cwd}\u0000${sessionKey}` : undefined;
550
+ const leaseKey = [this.bridgeOptions.sessionId, sessionKey]
551
+ .find((value) => typeof value === "string" && value.trim().length > 0);
552
+ const runTurn = () => withSessionLock(lockKey, abortController.signal, async () => {
553
+ let persisted = null;
554
+ let resetEpoch;
555
+ try {
556
+ if (externallyAborted)
557
+ return;
558
+ if (this.bridgeOptions.sessionPersistence && sessionKey) {
559
+ resetEpoch = await getQoderSessionResetEpoch();
560
+ try {
561
+ persisted = await getQoderSessionForCwd(sessionKey, cwd);
562
+ }
563
+ catch (error) {
564
+ // Persistence contention or corruption must not make a fresh
565
+ // chat turn unavailable; the mapping can be repaired later.
566
+ debug("Could not read Qoder session mapping; starting fresh:", describeError(error));
567
+ }
568
+ }
569
+ const sessionId = this.bridgeOptions.sessionId ?? persisted?.qoderSessionId ?? randomUUID();
570
+ const shouldResume = Boolean(this.bridgeOptions.sessionId || persisted);
571
+ debug(`doStream model=${model.id} sessionId=${sessionId} cwd=${cwd} resume=${shouldResume}`);
572
+ const promptMessages = options.prompt;
573
+ const promptInput = shouldResume ? latestPrompt(promptMessages) : promptMessages;
574
+ const prompt = promptHasImage(promptInput)
575
+ ? buildPromptIterable(promptInput, model.limit.context, sessionId)
576
+ : buildPromptString(promptInput, model.limit.context);
577
+ const qoderOptions = this.buildQueryOptions(cli, sessionId, abortController, shouldResume, this.modelId, cwd, hostToolNames, () => {
578
+ state.authExpired = true;
579
+ try {
580
+ abortController.abort();
581
+ }
582
+ catch { /* best-effort cancellation */ }
583
+ });
584
+ if (externallyAborted || abortController.signal.aborted)
585
+ return;
586
+ qoderQuery = query({ prompt, options: qoderOptions });
587
+ const activeQuery = qoderQuery;
588
+ if (externallyAborted || abortController.signal.aborted)
589
+ return;
590
+ const iterator = activeQuery[Symbol.asyncIterator]();
591
+ for (;;) {
592
+ const next = await nextWithAbort(iterator, abortController.signal);
593
+ if (!next || next.done)
594
+ break;
595
+ if (externallyAborted || timedOut)
596
+ break;
597
+ handleSdkMessage(next.value, state);
598
+ if (state.authExpired || state.finished)
599
+ break;
600
+ }
601
+ if (state.authExpired && !state.finished) {
602
+ throw new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.");
603
+ }
604
+ if (timedOut)
605
+ throw timeoutError();
606
+ if (!externallyAborted && !state.resultReceived && !state.finished) {
607
+ throw new QoderSdkResultError("incomplete_stream", "Qoder ended the stream before sending a result message");
608
+ }
609
+ if (!externallyAborted && !state.failed && !abortController.signal.aborted && this.bridgeOptions.sessionPersistence && sessionKey) {
610
+ try {
611
+ await ensureQoderSession(sessionKey, sessionId, cwd, resetEpoch);
612
+ }
613
+ catch (error) {
614
+ debug("Could not persist Qoder session mapping:", describeError(error));
615
+ }
616
+ }
617
+ if (state.invalidSession && persisted && sessionKey) {
618
+ try {
619
+ await deleteQoderSession(sessionKey, cwd);
620
+ }
621
+ catch (error) {
622
+ debug("Could not clear invalid Qoder session mapping:", describeError(error));
623
+ }
624
+ }
625
+ }
626
+ catch (error) {
627
+ // A transport/query failure may arrive before Qoder can emit a
628
+ // structured result. Clear a persisted session when its error
629
+ // still identifies the resume target as invalid, so the next
630
+ // request can recover with a fresh Qoder session.
631
+ if (persisted && sessionKey && this.bridgeOptions.sessionPersistence && isLikelyInvalidSessionError(error)) {
632
+ try {
633
+ await deleteQoderSession(sessionKey, cwd);
634
+ }
635
+ catch (deleteError) {
636
+ debug("Could not clear invalid Qoder session mapping after transport failure:", describeError(deleteError));
637
+ }
638
+ }
639
+ throw error;
640
+ }
641
+ finally {
642
+ await cleanup();
643
+ }
644
+ });
645
+ if (leaseKey) {
646
+ await withQoderSessionLease(leaseKey, cwd, abortController.signal, runTurn);
179
647
  }
180
- if (!state.finished) {
648
+ else {
649
+ await runTurn();
650
+ }
651
+ if (!state.finished && !externallyAborted) {
652
+ if (timedOut)
653
+ throw timeoutError();
654
+ if (!state.resultReceived) {
655
+ throw new QoderSdkResultError("incomplete_stream", "Qoder ended the stream before sending a result message");
656
+ }
181
657
  emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("stop"), undefined);
182
658
  }
183
- cleanup();
184
- controller.close();
659
+ await cleanup();
660
+ safeClose(controller);
185
661
  }
186
662
  catch (err) {
187
- cleanup();
188
- if (externallyAborted && !state.finished) {
663
+ await cleanup();
664
+ if (externallyAborted) {
189
665
  debug("Stream aborted by caller; closing without finish");
190
- controller.close();
666
+ safeClose(controller);
191
667
  return;
192
668
  }
193
669
  debug("Stream failed:", describeError(err));
194
670
  if (!state.finished) {
195
- controller.enqueue({ type: "error", error: err instanceof Error ? err : new Error(String(err)) });
671
+ const streamError = state.authExpired
672
+ ? new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.")
673
+ : timedOut ? timeoutError()
674
+ : safePublicError(err);
675
+ closeOpenBlocks(state);
676
+ safeEnqueue(controller, { type: "error", error: streamError });
196
677
  emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error"), undefined);
197
678
  }
198
- controller.close();
679
+ safeClose(controller);
199
680
  }
200
681
  },
201
682
  });
202
683
  return { stream };
203
684
  }
204
- buildQueryOptions(cli, sessionId, abortController, shouldResume) {
685
+ buildQueryOptions(cli, sessionId, abortController, shouldResume, modelId = this.modelId, cwd = resolveCwd(this.bridgeOptions.cwd), hostToolNames = [], onAuthExpired) {
205
686
  const sessionKey = this.bridgeOptions.sessionKey ?? this.bridgeOptions.sessionId;
206
687
  const permissionMode = this.bridgeOptions.permissionMode ?? "default";
207
688
  const opts = {
208
- auth: qoderAuth(),
209
- model: this.modelId,
689
+ auth: qoderAuth(qoderEnvironment(this.bridgeOptions.env)),
690
+ model: modelId,
210
691
  allowDangerouslySkipPermissions: this.bridgeOptions.allowDangerouslySkipPermissions
211
692
  ?? (permissionMode === "bypassPermissions" ? true : undefined),
212
693
  permissionMode,
213
694
  includePartialMessages: true,
214
695
  sessionId,
215
- cwd: process.cwd(),
696
+ cwd,
216
697
  abortController,
217
698
  };
699
+ if (onAuthExpired)
700
+ opts.onAuthExpired = onAuthExpired;
218
701
  if (cli)
219
702
  opts.pathToQoderCLIExecutable = cli;
220
703
  if (this.bridgeOptions.env)
221
- opts.env = this.bridgeOptions.env;
222
- if ((this.bridgeOptions.sessionId || (this.bridgeOptions.sessionPersistence && sessionKey)) && shouldResume) {
704
+ opts.env = qoderEnvironment(this.bridgeOptions.env);
705
+ if (this.bridgeOptions.planMode !== undefined)
706
+ opts.planMode = this.bridgeOptions.planMode;
707
+ const proxy = this.bridgeOptions.proxy ?? process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
708
+ if (proxy)
709
+ opts.proxy = proxy;
710
+ if (this.bridgeOptions.evolution)
711
+ opts.evolution = this.bridgeOptions.evolution;
712
+ const persistSession = Boolean(this.bridgeOptions.sessionId || (this.bridgeOptions.sessionPersistence && sessionKey));
713
+ opts.persistSession = persistSession;
714
+ if (persistSession && shouldResume)
223
715
  opts.resume = sessionId;
224
- opts.persistSession = true;
225
- }
226
716
  if (this.bridgeOptions.allowedTools)
227
717
  opts.allowedTools = this.bridgeOptions.allowedTools;
228
- if (this.bridgeOptions.disallowedTools)
229
- opts.disallowedTools = this.bridgeOptions.disallowedTools;
718
+ const disallowedTools = [
719
+ ...(this.bridgeOptions.disallowedTools ?? []),
720
+ ...hostToolNames
721
+ .filter((name) => !isProviderOwnedToolName(name))
722
+ .map(qoderToolNameForHost),
723
+ ]
724
+ .filter((name, index, all) => typeof name === "string" && name.trim() && all.indexOf(name) === index);
725
+ if (disallowedTools.length > 0)
726
+ opts.disallowedTools = disallowedTools;
230
727
  const mcpServers = this.bridgeOptions.mcpServers;
231
728
  if (mcpServers && Object.keys(mcpServers).length > 0) {
232
729
  opts.mcpServers = mcpServers;
233
730
  }
234
731
  if (this.bridgeOptions.extraArgs && Object.keys(this.bridgeOptions.extraArgs).length > 0) {
235
- opts.extraArgs = this.bridgeOptions.extraArgs;
732
+ opts.extraArgs = Object.fromEntries(Object.entries(this.bridgeOptions.extraArgs).map(([key, value]) => [key.replace(/^--/, ""), value]));
236
733
  }
237
734
  return opts;
238
735
  }
239
736
  }
240
737
  export function handleSdkMessage(m, state) {
241
- const type = m.type;
738
+ if (state.finished || !isRecord(m))
739
+ return;
740
+ const messageId = messageDedupeKey(m);
741
+ if (messageId) {
742
+ const seenMessageIds = state.seenMessageIds ??= new Set();
743
+ if (!rememberId(seenMessageIds, messageId, MAX_SEEN_MESSAGE_IDS))
744
+ return;
745
+ }
746
+ const type = typeof m.type === "string" ? m.type : "";
747
+ if (!type.trim()) {
748
+ failStream(state, "malformed_stream", "Qoder sent a message without a type");
749
+ return;
750
+ }
242
751
  if (type === "stream_event") {
752
+ if (!isRecord(m.event)) {
753
+ failStream(state, "malformed_stream", "Qoder sent a stream message without an event");
754
+ return;
755
+ }
243
756
  handleStreamEvent(m.event, state);
244
757
  }
245
758
  else if (type === "assistant") {
@@ -248,151 +761,333 @@ export function handleSdkMessage(m, state) {
248
761
  else if (type === "result") {
249
762
  handleResult(m, state);
250
763
  }
764
+ else if (type === "system") {
765
+ handleSystem(m, state);
766
+ }
767
+ }
768
+ function handleSystem(m, state) {
769
+ const subtype = typeof m.subtype === "string" ? m.subtype : "";
770
+ if (subtype === "plan_mode_changed" && isRecord(m.plan_mode)) {
771
+ if (typeof m.plan_mode.active !== "boolean")
772
+ return;
773
+ state.planMode = m.plan_mode;
774
+ debug(`Plan mode changed: active=${state.planMode.active}`);
775
+ }
776
+ else if (subtype === "available_models_update" && Array.isArray(m.models)) {
777
+ debug(`Received live available_models_update with ${m.models.length} models`);
778
+ applyLiveModelUpdates(m.models, state.modelEnvironment, state.modelDiscoveryOptions);
779
+ }
780
+ else if (subtype === "artifacts_update" && Array.isArray(m.artifacts)) {
781
+ const incoming = m.artifacts.slice(0, 1_000);
782
+ for (const artifact of incoming) {
783
+ if (!isRecord(artifact))
784
+ continue;
785
+ const safeArtifact = toJsonValue(artifact);
786
+ if (!isRecord(safeArtifact))
787
+ continue;
788
+ const path = typeof safeArtifact.path === "string" ? safeArtifact.path : "";
789
+ const index = path ? state.artifacts.findIndex((item) => item.path === path) : -1;
790
+ if (index >= 0)
791
+ state.artifacts[index] = safeArtifact;
792
+ else if (state.artifacts.length < 1000)
793
+ state.artifacts.push(safeArtifact);
794
+ }
795
+ debug(`Artifacts updated: ${incoming.length} artifact(s)`);
796
+ }
797
+ else if (subtype === "skill_evolution" && isRecord(m.result)) {
798
+ state.skillEvolution = m.result;
799
+ debug(`Skill evolution result: status=${m.result.status}`);
800
+ }
251
801
  }
252
802
  function handleStreamEvent(ev, state) {
253
- if (!ev)
803
+ if (!isRecord(ev)) {
804
+ failStream(state, "malformed_stream", "Qoder sent a stream event that was not an object");
254
805
  return;
806
+ }
255
807
  const { controller } = state;
256
- const evType = ev.type;
257
- const idx = typeof ev.index === "number" ? ev.index : 0;
808
+ const evType = typeof ev.type === "string" ? ev.type : "";
809
+ if (!evType.trim()) {
810
+ failStream(state, "malformed_stream", "Qoder sent a stream event without a type");
811
+ return;
812
+ }
813
+ const isContentBlockEvent = evType === "content_block_start"
814
+ || evType === "content_block_delta"
815
+ || evType === "content_block_stop";
816
+ const idx = typeof ev.index === "number" && Number.isInteger(ev.index) && ev.index >= 0 && ev.index < 100_000
817
+ ? ev.index
818
+ : -1;
819
+ if (isContentBlockEvent && idx < 0) {
820
+ failStream(state, "malformed_stream", "Qoder sent a content block event without a valid index");
821
+ return;
822
+ }
823
+ if (evType === "content_block_start" && !isRecord(ev.content_block)) {
824
+ failStream(state, "malformed_stream", "Qoder sent a content block start without a block");
825
+ return;
826
+ }
258
827
  if (evType === "content_block_start" && isRecord(ev.content_block)) {
828
+ if (state.toolBlocks.has(idx)) {
829
+ const existing = state.toolBlocks.get(idx);
830
+ if (existing && ev.content_block.id === existing.id)
831
+ return;
832
+ failStream(state, "malformed_stream", "Qoder started a different tool block at an open index");
833
+ return;
834
+ }
835
+ if (state.activeReasoning.has(idx) || state.activeText.has(idx)) {
836
+ failStream(state, "malformed_stream", "Qoder started a content block that was already open");
837
+ return;
838
+ }
259
839
  const block = ev.content_block;
260
- const blockType = block.type;
261
- if (blockType === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
840
+ const blockType = typeof block.type === "string" ? block.type : "";
841
+ if (blockType === "tool_use" && typeof block.id === "string" && block.id.trim() && typeof block.name === "string" && block.name.trim()) {
842
+ const seenToolCallIds = state.seenToolCallIds ??= new Set();
843
+ if (!rememberId(seenToolCallIds, block.id, MAX_SEEN_MESSAGE_IDS) || state.toolBlocks.has(idx))
844
+ return;
262
845
  state.sawStreamTool = true;
263
846
  const name = normalizeToolName(block.name);
264
- const providerExecuted = isProviderExecutedTool(name, state.functionToolNames);
265
- state.toolBlocks.set(idx, { id: block.id, name, input: "", providerExecuted });
847
+ const providerExecuted = isProviderOwnedTool(block.name, name, state.functionToolNames);
848
+ const hasInput = Object.hasOwn(block, "input");
849
+ const initialInput = hasInput ? safeJsonStringify(block.input) : "";
850
+ if (hasInput && (initialInput === undefined || initialInput.length > MAX_TOOL_INPUT_CHARS)) {
851
+ failStream(state, "malformed_tool_input", `Qoder sent unserializable input for tool ${name}`);
852
+ return;
853
+ }
854
+ state.toolBlocks.set(idx, { id: block.id, name, input: initialInput ?? "", providerExecuted, hasInput });
855
+ trackOpenBlock(state, { kind: "tool", index: idx, id: block.id, providerExecuted });
266
856
  if (!providerExecuted) {
267
- controller.enqueue({ type: "tool-input-start", id: block.id, toolName: name });
857
+ safeEnqueue(controller, { type: "tool-input-start", id: block.id, toolName: name });
268
858
  }
269
859
  }
860
+ else if (blockType === "tool_use") {
861
+ failStream(state, "malformed_tool_call", "Qoder sent a tool call without a valid id or name");
862
+ }
270
863
  else if (blockType === "thinking") {
271
864
  state.activeReasoning.add(idx);
272
- controller.enqueue({ type: "reasoning-start", id: String(idx) });
865
+ trackOpenBlock(state, { kind: "reasoning", index: idx });
866
+ safeEnqueue(controller, { type: "reasoning-start", id: String(idx) });
273
867
  }
274
868
  else if (blockType === "text") {
275
869
  state.activeText.add(idx);
276
- controller.enqueue({ type: "text-start", id: String(idx) });
870
+ trackOpenBlock(state, { kind: "text", index: idx });
871
+ safeEnqueue(controller, { type: "text-start", id: String(idx) });
872
+ }
873
+ else {
874
+ failStream(state, "malformed_stream", `Qoder sent an unsupported content block type: ${blockType || "missing"}`);
277
875
  }
278
876
  return;
279
877
  }
280
878
  if (evType === "content_block_delta" && isRecord(ev.delta)) {
281
879
  const delta = ev.delta;
282
880
  const deltaType = delta.type;
283
- if (deltaType === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking) {
881
+ if (deltaType === "thinking_delta" && typeof delta.thinking === "string") {
284
882
  state.sawStreamReasoning = true;
285
883
  if (!state.activeReasoning.has(idx)) {
286
884
  state.activeReasoning.add(idx);
287
- controller.enqueue({ type: "reasoning-start", id: String(idx) });
885
+ trackOpenBlock(state, { kind: "reasoning", index: idx });
886
+ safeEnqueue(controller, { type: "reasoning-start", id: String(idx) });
887
+ }
888
+ if (delta.thinking && appendOutput(state, delta.thinking)) {
889
+ safeEnqueue(controller, { type: "reasoning-delta", id: String(idx), delta: delta.thinking });
288
890
  }
289
- controller.enqueue({ type: "reasoning-delta", id: String(idx), delta: delta.thinking });
290
891
  }
291
- else if (deltaType === "text_delta" && typeof delta.text === "string" && delta.text) {
892
+ else if (deltaType === "text_delta" && typeof delta.text === "string") {
292
893
  state.sawStreamText = true;
293
894
  if (!state.activeText.has(idx)) {
294
895
  state.activeText.add(idx);
295
- controller.enqueue({ type: "text-start", id: String(idx) });
896
+ trackOpenBlock(state, { kind: "text", index: idx });
897
+ safeEnqueue(controller, { type: "text-start", id: String(idx) });
898
+ }
899
+ if (delta.text && appendOutput(state, delta.text)) {
900
+ safeEnqueue(controller, { type: "text-delta", id: String(idx), delta: delta.text });
296
901
  }
297
- controller.enqueue({ type: "text-delta", id: String(idx), delta: delta.text });
298
902
  }
299
903
  else if (deltaType === "input_json_delta" && typeof delta.partial_json === "string") {
300
904
  const tb = state.toolBlocks.get(idx);
301
- if (tb) {
302
- tb.input += delta.partial_json;
303
- if (!tb.providerExecuted) {
304
- controller.enqueue({ type: "tool-input-delta", id: tb.id, delta: delta.partial_json });
305
- }
905
+ if (!tb) {
906
+ failStream(state, "malformed_tool_input", "Qoder sent tool input for a block that was not started");
907
+ return;
306
908
  }
909
+ if (tb.input.length + delta.partial_json.length > MAX_TOOL_INPUT_CHARS) {
910
+ failStream(state, "tool_input_too_large", "Qoder sent a tool input larger than the bridge limit");
911
+ return;
912
+ }
913
+ tb.hasInput = true;
914
+ tb.input += delta.partial_json;
915
+ if (!tb.providerExecuted) {
916
+ safeEnqueue(controller, { type: "tool-input-delta", id: tb.id, delta: delta.partial_json });
917
+ }
918
+ }
919
+ else if (deltaType === "input_json_delta") {
920
+ failStream(state, "malformed_tool_input", "Qoder sent a tool input delta without JSON text");
307
921
  }
922
+ else {
923
+ failStream(state, "malformed_stream", "Qoder sent an unsupported content block delta");
924
+ }
925
+ return;
926
+ }
927
+ if (evType === "content_block_delta") {
928
+ failStream(state, "malformed_stream", "Qoder sent a content block delta without a delta object");
308
929
  return;
309
930
  }
310
931
  if (evType === "content_block_stop") {
311
932
  const tb = state.toolBlocks.get(idx);
312
933
  if (tb) {
313
- if (!tb.providerExecuted) {
314
- const input = normalizeToolInputString(tb.name, tb.input);
315
- controller.enqueue({ type: "tool-input-end", id: tb.id });
316
- controller.enqueue({ type: "tool-call", toolCallId: tb.id, toolName: tb.name, input });
934
+ if (!tb.hasInput) {
935
+ failStream(state, "malformed_tool_input", `Qoder closed tool ${tb.name} without input JSON`);
936
+ return;
937
+ }
938
+ if (!state.pendingToolCalls.has(tb.id) && !tb.providerExecuted) {
939
+ const input = normalizedToolInput(tb.name, tb.input);
940
+ if (input === null) {
941
+ failStream(state, "invalid_tool_input", `Qoder sent invalid JSON for tool ${tb.name}`);
942
+ return;
943
+ }
944
+ safeEnqueue(controller, { type: "tool-input-end", id: tb.id });
945
+ safeEnqueue(controller, { type: "tool-call", toolCallId: tb.id, toolName: tb.name, input });
317
946
  state.emittedToolCall = true;
318
947
  }
319
- state.pendingToolCalls.set(tb.id, { name: tb.name, providerExecuted: tb.providerExecuted });
948
+ if (!state.pendingToolCalls.has(tb.id)) {
949
+ state.pendingToolCalls.set(tb.id, { name: tb.name, providerExecuted: tb.providerExecuted });
950
+ }
320
951
  state.toolBlocks.delete(idx);
952
+ untrackOpenBlock(state, idx);
321
953
  }
322
954
  else if (state.activeReasoning.has(idx)) {
323
- controller.enqueue({ type: "reasoning-end", id: String(idx) });
955
+ safeEnqueue(controller, { type: "reasoning-end", id: String(idx) });
324
956
  state.activeReasoning.delete(idx);
957
+ untrackOpenBlock(state, idx);
325
958
  }
326
959
  else if (state.activeText.has(idx)) {
327
- controller.enqueue({ type: "text-end", id: String(idx) });
960
+ safeEnqueue(controller, { type: "text-end", id: String(idx) });
328
961
  state.activeText.delete(idx);
962
+ untrackOpenBlock(state, idx);
963
+ }
964
+ else {
965
+ failStream(state, "malformed_stream", "Qoder stopped a content block that was not started");
329
966
  }
330
967
  return;
331
968
  }
332
969
  if (evType === "message_delta" && isRecord(ev.delta) && typeof ev.delta.stop_reason === "string") {
333
- state.lastStopReason = ev.delta.stop_reason;
970
+ state.lastStopReason = safeStopReason(ev.delta.stop_reason);
334
971
  }
335
972
  }
336
973
  function handleAssistant(m, state) {
974
+ if (isAuthenticationError(m.error)) {
975
+ state.authExpired = true;
976
+ return;
977
+ }
337
978
  const message = m.message;
979
+ if (Object.hasOwn(message ?? {}, "stop_reason"))
980
+ state.lastStopReason = safeStopReason(message?.stop_reason);
338
981
  const content = message?.content;
339
- if (!Array.isArray(content))
982
+ if (!Array.isArray(content)) {
983
+ failStream(state, "malformed_stream", "Qoder sent an assistant message without content");
340
984
  return;
985
+ }
341
986
  const { controller } = state;
342
987
  for (const raw of content) {
343
- if (!isRecord(raw))
344
- continue;
988
+ if (!isRecord(raw)) {
989
+ failStream(state, "malformed_stream", "Qoder sent an assistant content block that was not an object");
990
+ break;
991
+ }
345
992
  const blockType = raw.type;
346
993
  if (blockType === "text" && typeof raw.text === "string" && raw.text && !state.sawStreamText) {
994
+ if (!appendOutput(state, raw.text))
995
+ break;
347
996
  const id = String(state.blockCounter++);
348
- controller.enqueue({ type: "text-start", id });
349
- controller.enqueue({ type: "text-delta", id, delta: raw.text });
350
- controller.enqueue({ type: "text-end", id });
997
+ safeEnqueue(controller, { type: "text-start", id });
998
+ safeEnqueue(controller, { type: "text-delta", id, delta: raw.text });
999
+ safeEnqueue(controller, { type: "text-end", id });
351
1000
  }
352
1001
  else if (blockType === "thinking" && typeof raw.thinking === "string" && raw.thinking && !state.sawStreamReasoning) {
1002
+ if (!appendOutput(state, raw.thinking))
1003
+ break;
353
1004
  const id = String(state.blockCounter++);
354
- controller.enqueue({ type: "reasoning-start", id });
355
- controller.enqueue({ type: "reasoning-delta", id, delta: raw.thinking });
356
- controller.enqueue({ type: "reasoning-end", id });
1005
+ safeEnqueue(controller, { type: "reasoning-start", id });
1006
+ safeEnqueue(controller, { type: "reasoning-delta", id, delta: raw.thinking });
1007
+ safeEnqueue(controller, { type: "reasoning-end", id });
357
1008
  }
358
- else if (blockType === "tool_use" && typeof raw.id === "string" && typeof raw.name === "string" && !state.sawStreamTool) {
1009
+ else if (blockType === "tool_use" && typeof raw.id === "string" && raw.id.trim() && typeof raw.name === "string" && raw.name.trim()) {
1010
+ const seenToolCallIds = state.seenToolCallIds ??= new Set();
1011
+ if (!rememberId(seenToolCallIds, raw.id, MAX_SEEN_MESSAGE_IDS))
1012
+ continue;
359
1013
  const name = normalizeToolName(raw.name);
360
- const providerExecuted = isProviderExecutedTool(name, state.functionToolNames);
1014
+ const providerExecuted = isProviderOwnedTool(raw.name, name, state.functionToolNames);
361
1015
  state.pendingToolCalls.set(raw.id, { name, providerExecuted });
362
1016
  if (!providerExecuted) {
363
- const rawInput = typeof raw.input === "string" ? raw.input : JSON.stringify(raw.input ?? {});
364
- const input = normalizeToolInputString(name, rawInput);
365
- controller.enqueue({ type: "tool-input-start", id: raw.id, toolName: name });
366
- controller.enqueue({ type: "tool-input-delta", id: raw.id, delta: input });
367
- controller.enqueue({ type: "tool-input-end", id: raw.id });
368
- controller.enqueue({ type: "tool-call", toolCallId: raw.id, toolName: name, input });
1017
+ const input = normalizedToolInput(name, raw.input);
1018
+ if (input === null) {
1019
+ failStream(state, "invalid_tool_input", `Qoder sent invalid JSON for tool ${name}`);
1020
+ break;
1021
+ }
1022
+ safeEnqueue(controller, { type: "tool-input-start", id: raw.id, toolName: name });
1023
+ safeEnqueue(controller, { type: "tool-input-delta", id: raw.id, delta: input });
1024
+ safeEnqueue(controller, { type: "tool-input-end", id: raw.id });
1025
+ safeEnqueue(controller, { type: "tool-call", toolCallId: raw.id, toolName: name, input });
369
1026
  state.emittedToolCall = true;
370
1027
  }
371
1028
  }
1029
+ else if (blockType === "tool_use") {
1030
+ failStream(state, "malformed_tool_call", "Qoder sent a tool call without a valid id or name");
1031
+ break;
1032
+ }
1033
+ }
1034
+ }
1035
+ function normalizedToolInput(toolName, raw) {
1036
+ if (raw === undefined)
1037
+ return null;
1038
+ const serialized = typeof raw === "string" ? raw : safeJsonStringify(raw);
1039
+ if (serialized === undefined || serialized.length > MAX_TOOL_INPUT_CHARS)
1040
+ return null;
1041
+ try {
1042
+ const parsed = JSON.parse(serialized.trim() || "{}");
1043
+ if (!isRecord(parsed))
1044
+ return null;
1045
+ return normalizeToolInputString(toolName, serialized);
372
1046
  }
1047
+ catch {
1048
+ return null;
1049
+ }
1050
+ }
1051
+ function appendOutput(state, text) {
1052
+ const current = Number.isFinite(state.outputChars) ? state.outputChars : 0;
1053
+ if (current + text.length > MAX_OUTPUT_CHARS) {
1054
+ failStream(state, "output_too_large", "Qoder sent more output than the bridge limit");
1055
+ return false;
1056
+ }
1057
+ state.outputChars = current + text.length;
1058
+ return true;
1059
+ }
1060
+ function failStream(state, subtype, detail) {
1061
+ if (state.finished)
1062
+ return;
1063
+ state.failed = true;
1064
+ closeOpenBlocks(state);
1065
+ safeEnqueue(state.controller, { type: "error", error: new QoderSdkResultError(subtype, detail) });
1066
+ emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", subtype), undefined);
373
1067
  }
374
1068
  function handleResult(m, state) {
1069
+ state.resultReceived = true;
375
1070
  const { controller } = state;
376
- for (const idx of state.activeReasoning)
377
- controller.enqueue({ type: "reasoning-end", id: String(idx) });
378
- state.activeReasoning.clear();
379
- for (const idx of state.activeText)
380
- controller.enqueue({ type: "text-end", id: String(idx) });
381
- state.activeText.clear();
382
- const isError = m.is_error === true || (typeof m.subtype === "string" && m.subtype !== "success");
383
- if (isError) {
384
- const subtype = typeof m.subtype === "string" ? m.subtype : "error_during_execution";
385
- const errors = Array.isArray(m.errors) ? JSON.stringify(m.errors) : "";
386
- controller.enqueue({ type: "error", error: new QoderSdkResultError(subtype, errors) });
387
- emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", subtype), undefined);
1071
+ if (Object.hasOwn(m, "stop_reason")) {
1072
+ state.lastStopReason = safeStopReason(m.stop_reason);
1073
+ }
1074
+ if (state.activeReasoning.size > 0 || state.activeText.size > 0 || state.toolBlocks.size > 0) {
1075
+ state.failed = true;
1076
+ closeOpenBlocks(state);
1077
+ safeEnqueue(controller, {
1078
+ type: "error",
1079
+ error: new QoderSdkResultError("incomplete_stream", "Qoder sent a result before closing all content blocks"),
1080
+ });
1081
+ emitFinish(state, makeUsage(0, 0, 0, 0), makeFinishReason("error", "incomplete_stream"), undefined);
388
1082
  return;
389
1083
  }
390
- const usage = (m.usage ?? {});
391
- let inputTokens = usage.input_tokens ?? 0;
392
- let outputTokens = usage.output_tokens ?? 0;
393
- const cachedInputTokens = usage.cache_read_input_tokens ?? 0;
394
- const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
395
- const contextUsageRatio = usage.context_usage_ratio;
1084
+ closeOpenBlocks(state);
1085
+ const usage = isRecord(m.usage) ? m.usage : {};
1086
+ let inputTokens = tokenCount(usage.input_tokens);
1087
+ let outputTokens = tokenCount(usage.output_tokens);
1088
+ const cachedInputTokens = tokenCount(usage.cache_read_input_tokens);
1089
+ const cacheWriteTokens = tokenCount(usage.cache_creation_input_tokens);
1090
+ const contextUsageRatio = ratio(usage.context_usage_ratio);
396
1091
  let usageEstimated = false;
397
1092
  // Qoder's first-party backend currently reports zero token counters, but it
398
1093
  // does report the fraction of the context window used. Convert that ratio
@@ -400,10 +1095,9 @@ function handleResult(m, state) {
400
1095
  // panel instead of permanently displaying 0 tokens / 0%.
401
1096
  if (inputTokens === 0
402
1097
  && outputTokens === 0
403
- && typeof contextUsageRatio === "number"
404
- && Number.isFinite(contextUsageRatio)
1098
+ && contextUsageRatio !== undefined
405
1099
  && contextUsageRatio > 0) {
406
- const totalTokens = Math.max(1, Math.round(Math.min(contextUsageRatio, 1) * state.contextWindow));
1100
+ const totalTokens = Math.max(1, Math.round(contextUsageRatio * state.contextWindow));
407
1101
  const resultText = typeof m.result === "string" ? m.result : "";
408
1102
  outputTokens = resultText ? Math.max(1, Math.ceil(Buffer.byteLength(resultText, "utf8") / 4)) : 0;
409
1103
  outputTokens = Math.min(outputTokens, totalTokens);
@@ -411,45 +1105,106 @@ function handleResult(m, state) {
411
1105
  usageEstimated = true;
412
1106
  debug(`Token counters absent; estimated ${inputTokens} in / ${outputTokens} out from context ratio`);
413
1107
  }
414
- const costUsd = typeof m.total_cost_usd === "number" ? m.total_cost_usd : 0;
1108
+ const costUsd = finiteNonNegative(m.total_cost_usd);
1109
+ const model = typeof m.model === "string" && m.model.trim() ? m.model : "unknown";
1110
+ const durationMs = finiteNonNegative(m.duration_ms);
1111
+ const turns = finiteNonNegative(m.num_turns, 1);
1112
+ const record = () => {
1113
+ try {
1114
+ recordTurn({
1115
+ model,
1116
+ usage: {
1117
+ input_tokens: inputTokens,
1118
+ output_tokens: outputTokens,
1119
+ cache_read_input_tokens: cachedInputTokens,
1120
+ cache_creation_input_tokens: cacheWriteTokens,
1121
+ },
1122
+ costUsd,
1123
+ durationMs,
1124
+ turns,
1125
+ });
1126
+ }
1127
+ catch (err) {
1128
+ debug("Cost ledger write skipped:", describeError(err));
1129
+ }
1130
+ };
1131
+ const isAuthError = state.authExpired;
1132
+ const subtype = isAuthError
1133
+ ? "authentication_failed"
1134
+ : typeof m.subtype === "string" ? m.subtype : "error_during_execution";
1135
+ const isError = isAuthError || m.is_error === true || m.subtype !== "success";
1136
+ if (isError) {
1137
+ state.failed = true;
1138
+ const detail = Array.isArray(m.errors)
1139
+ ? redactSensitiveText((safeJsonStringify(m.errors) ?? "").slice(0, 4096))
1140
+ : "";
1141
+ state.invalidSession = isInvalidSessionError(subtype, detail);
1142
+ const error = state.authExpired
1143
+ ? new QoderAuthError("Qoder authentication expired during the request. Re-authenticate with `qoder login` or refresh QODER_PERSONAL_ACCESS_TOKEN.")
1144
+ : new QoderSdkResultError(subtype, detail);
1145
+ record();
1146
+ safeEnqueue(controller, { type: "error", error });
1147
+ emitFinish(state, makeUsage(inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens), makeFinishReason("error", subtype), undefined);
1148
+ return;
1149
+ }
415
1150
  const hasToolCalls = state.emittedToolCall && state.pendingToolCalls.size > 0;
416
1151
  const finishReason = mapStopReason(state.lastStopReason, hasToolCalls);
417
- try {
418
- recordTurn({
419
- model: m.model ?? "unknown",
420
- usage: {
421
- input_tokens: inputTokens,
422
- output_tokens: outputTokens,
423
- cache_read_input_tokens: cachedInputTokens,
424
- cache_creation_input_tokens: cacheWriteTokens,
425
- },
426
- costUsd,
427
- durationMs: typeof m.duration_ms === "number" ? m.duration_ms : 0,
428
- turns: typeof m.num_turns === "number" ? m.num_turns : 1,
429
- modelUsage: m.modelUsage,
430
- });
431
- }
432
- catch (err) {
433
- debug("Cost ledger write skipped:", describeError(err));
434
- }
1152
+ record();
435
1153
  const qoderMeta = {};
436
- if (typeof m.total_cost_usd === "number")
437
- qoderMeta.totalCostUSD = m.total_cost_usd;
438
- if (isRecord(m.modelUsage))
439
- qoderMeta.modelUsage = m.modelUsage;
440
- if (typeof contextUsageRatio === "number")
1154
+ if (Number.isFinite(costUsd))
1155
+ qoderMeta.totalCostUSD = costUsd;
1156
+ const modelUsage = toJsonValue(m.modelUsage);
1157
+ if (modelUsage !== undefined)
1158
+ qoderMeta.modelUsage = modelUsage;
1159
+ if (contextUsageRatio !== undefined)
441
1160
  qoderMeta.contextUsageRatio = contextUsageRatio;
442
1161
  if (usageEstimated)
443
1162
  qoderMeta.usageEstimated = true;
1163
+ const planMode = toJsonValue(state.planMode);
1164
+ if (planMode !== undefined)
1165
+ qoderMeta.planMode = planMode;
1166
+ const artifacts = toJsonValue(state.artifacts);
1167
+ if (artifacts !== undefined && state.artifacts.length > 0)
1168
+ qoderMeta.artifacts = artifacts;
1169
+ const skillEvolution = toJsonValue(state.skillEvolution);
1170
+ if (skillEvolution !== undefined)
1171
+ qoderMeta.skillEvolution = skillEvolution;
444
1172
  emitFinish(state, makeUsage(inputTokens, outputTokens, cachedInputTokens, cacheWriteTokens), finishReason, Object.keys(qoderMeta).length > 0 ? qoderMeta : undefined);
445
1173
  }
1174
+ function isInvalidSessionError(subtype, detail) {
1175
+ return /(?:session.*(?:invalid|not[_ ]found|missing|expired)|(?:invalid|unknown|missing).*session)/i.test(`${subtype} ${detail}`);
1176
+ }
1177
+ function isLikelyInvalidSessionError(error) {
1178
+ const detail = error instanceof Error ? `${error.name} ${error.message}` : String(error);
1179
+ return isInvalidSessionError("", detail);
1180
+ }
446
1181
  function emitFinish(state, usage, finishReason, qoderMeta) {
447
- state.controller.enqueue({
1182
+ if (state.finished)
1183
+ return;
1184
+ state.finished = true;
1185
+ closeOpenBlocks(state);
1186
+ safeEnqueue(state.controller, {
448
1187
  type: "finish",
449
1188
  finishReason,
450
1189
  usage,
451
1190
  ...(qoderMeta ? { providerMetadata: { qoder: qoderMeta } } : {}),
452
1191
  });
453
- state.finished = true;
1192
+ }
1193
+ function closeOpenBlocks(state) {
1194
+ for (const block of state.openBlocks ?? []) {
1195
+ if (block.kind === "reasoning") {
1196
+ safeEnqueue(state.controller, { type: "reasoning-end", id: String(block.index) });
1197
+ }
1198
+ else if (block.kind === "text") {
1199
+ safeEnqueue(state.controller, { type: "text-end", id: String(block.index) });
1200
+ }
1201
+ else if (block.kind === "tool" && !block.providerExecuted) {
1202
+ safeEnqueue(state.controller, { type: "tool-input-end", id: block.id });
1203
+ }
1204
+ }
1205
+ state.openBlocks.length = 0;
1206
+ state.activeReasoning.clear();
1207
+ state.activeText.clear();
1208
+ state.toolBlocks.clear();
454
1209
  }
455
1210
  //# sourceMappingURL=language-model.js.map