harness-dispatch 0.4.0

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +582 -0
  3. package/config.default.yaml +397 -0
  4. package/data/coding_benchmarks.json +431 -0
  5. package/dist/auth.d.ts +9 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +73 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/billing.d.ts +7 -0
  10. package/dist/billing.d.ts.map +1 -0
  11. package/dist/billing.js +201 -0
  12. package/dist/billing.js.map +1 -0
  13. package/dist/bin.d.ts +6 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +488 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/breaker-store.d.ts +31 -0
  18. package/dist/breaker-store.d.ts.map +1 -0
  19. package/dist/breaker-store.js +103 -0
  20. package/dist/breaker-store.js.map +1 -0
  21. package/dist/circuit-breaker.d.ts +47 -0
  22. package/dist/circuit-breaker.d.ts.map +1 -0
  23. package/dist/circuit-breaker.js +102 -0
  24. package/dist/circuit-breaker.js.map +1 -0
  25. package/dist/config.d.ts +48 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +1248 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/dashboard/live.d.ts +49 -0
  30. package/dist/dashboard/live.d.ts.map +1 -0
  31. package/dist/dashboard/live.js +149 -0
  32. package/dist/dashboard/live.js.map +1 -0
  33. package/dist/dispatch-log.d.ts +46 -0
  34. package/dist/dispatch-log.d.ts.map +1 -0
  35. package/dist/dispatch-log.js +89 -0
  36. package/dist/dispatch-log.js.map +1 -0
  37. package/dist/dispatchers/base.d.ts +55 -0
  38. package/dist/dispatchers/base.d.ts.map +1 -0
  39. package/dist/dispatchers/base.js +80 -0
  40. package/dist/dispatchers/base.js.map +1 -0
  41. package/dist/dispatchers/generic-cli.d.ts +30 -0
  42. package/dist/dispatchers/generic-cli.d.ts.map +1 -0
  43. package/dist/dispatchers/generic-cli.js +471 -0
  44. package/dist/dispatchers/generic-cli.js.map +1 -0
  45. package/dist/dispatchers/openai-compatible.d.ts +57 -0
  46. package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
  47. package/dist/dispatchers/openai-compatible.js +662 -0
  48. package/dist/dispatchers/openai-compatible.js.map +1 -0
  49. package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
  50. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
  51. package/dist/dispatchers/shared/kill-tree.js +35 -0
  52. package/dist/dispatchers/shared/kill-tree.js.map +1 -0
  53. package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
  54. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
  55. package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
  57. package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
  58. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
  59. package/dist/dispatchers/shared/stream-subprocess.js +274 -0
  60. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
  61. package/dist/dispatchers/shared/subprocess.d.ts +25 -0
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
  63. package/dist/dispatchers/shared/subprocess.js +110 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -0
  65. package/dist/dispatchers/shared/which-available.d.ts +2 -0
  66. package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
  67. package/dist/dispatchers/shared/which-available.js +8 -0
  68. package/dist/dispatchers/shared/which-available.js.map +1 -0
  69. package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
  70. package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
  71. package/dist/dispatchers/shared/windows-cmd.js +91 -0
  72. package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
  73. package/dist/http/server.d.ts +15 -0
  74. package/dist/http/server.d.ts.map +1 -0
  75. package/dist/http/server.js +495 -0
  76. package/dist/http/server.js.map +1 -0
  77. package/dist/index.d.ts +27 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +29 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/job-runner.d.ts +19 -0
  82. package/dist/job-runner.d.ts.map +1 -0
  83. package/dist/job-runner.js +42 -0
  84. package/dist/job-runner.js.map +1 -0
  85. package/dist/jobs.d.ts +116 -0
  86. package/dist/jobs.d.ts.map +1 -0
  87. package/dist/jobs.js +542 -0
  88. package/dist/jobs.js.map +1 -0
  89. package/dist/leaderboard.d.ts +62 -0
  90. package/dist/leaderboard.d.ts.map +1 -0
  91. package/dist/leaderboard.js +287 -0
  92. package/dist/leaderboard.js.map +1 -0
  93. package/dist/mcp/config-hot-reload.d.ts +64 -0
  94. package/dist/mcp/config-hot-reload.d.ts.map +1 -0
  95. package/dist/mcp/config-hot-reload.js +144 -0
  96. package/dist/mcp/config-hot-reload.js.map +1 -0
  97. package/dist/mcp/dispatcher-factory.d.ts +20 -0
  98. package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
  99. package/dist/mcp/dispatcher-factory.js +30 -0
  100. package/dist/mcp/dispatcher-factory.js.map +1 -0
  101. package/dist/mcp/resources.d.ts +8 -0
  102. package/dist/mcp/resources.d.ts.map +1 -0
  103. package/dist/mcp/resources.js +45 -0
  104. package/dist/mcp/resources.js.map +1 -0
  105. package/dist/mcp/server.d.ts +36 -0
  106. package/dist/mcp/server.d.ts.map +1 -0
  107. package/dist/mcp/server.js +80 -0
  108. package/dist/mcp/server.js.map +1 -0
  109. package/dist/mcp/tools.d.ts +171 -0
  110. package/dist/mcp/tools.d.ts.map +1 -0
  111. package/dist/mcp/tools.js +658 -0
  112. package/dist/mcp/tools.js.map +1 -0
  113. package/dist/observability/index.d.ts +52 -0
  114. package/dist/observability/index.d.ts.map +1 -0
  115. package/dist/observability/index.js +111 -0
  116. package/dist/observability/index.js.map +1 -0
  117. package/dist/observability/spans.d.ts +47 -0
  118. package/dist/observability/spans.d.ts.map +1 -0
  119. package/dist/observability/spans.js +72 -0
  120. package/dist/observability/spans.js.map +1 -0
  121. package/dist/quota.d.ts +91 -0
  122. package/dist/quota.d.ts.map +1 -0
  123. package/dist/quota.js +331 -0
  124. package/dist/quota.js.map +1 -0
  125. package/dist/route-policy.d.ts +24 -0
  126. package/dist/route-policy.d.ts.map +1 -0
  127. package/dist/route-policy.js +92 -0
  128. package/dist/route-policy.js.map +1 -0
  129. package/dist/router.d.ts +162 -0
  130. package/dist/router.d.ts.map +1 -0
  131. package/dist/router.js +878 -0
  132. package/dist/router.js.map +1 -0
  133. package/dist/safety.d.ts +7 -0
  134. package/dist/safety.d.ts.map +1 -0
  135. package/dist/safety.js +33 -0
  136. package/dist/safety.js.map +1 -0
  137. package/dist/status.d.ts +100 -0
  138. package/dist/status.d.ts.map +1 -0
  139. package/dist/status.js +228 -0
  140. package/dist/status.js.map +1 -0
  141. package/dist/types.d.ts +402 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +9 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/version.d.ts +2 -0
  146. package/dist/version.d.ts.map +1 -0
  147. package/dist/version.js +13 -0
  148. package/dist/version.js.map +1 -0
  149. package/dist/working-dir.d.ts +15 -0
  150. package/dist/working-dir.d.ts.map +1 -0
  151. package/dist/working-dir.js +22 -0
  152. package/dist/working-dir.js.map +1 -0
  153. package/dist/workspaces.d.ts +19 -0
  154. package/dist/workspaces.d.ts.map +1 -0
  155. package/dist/workspaces.js +352 -0
  156. package/dist/workspaces.js.map +1 -0
  157. package/package.json +96 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * HTTP endpoint dispatcher for harness-dispatch — handles both wire protocols
3
+ * a `type: openai_compatible` route can speak, selected by `wire_protocol:`
4
+ * in config (default `openai_chat_completions`):
5
+ *
6
+ * openai_chat_completions POST /chat/completions — Ollama, LM Studio,
7
+ * OpenRouter, OpenAI API, NVIDIA NIM, and any
8
+ * other OpenAI-compatible endpoint.
9
+ * anthropic_messages POST /messages — Anthropic's API directly, or
10
+ * any third-party host that mirrors its Messages
11
+ * API shape (different auth headers, request/
12
+ * response body, and SSE event framing from
13
+ * OpenAI's — see the wire-protocol-specific
14
+ * helpers below).
15
+ *
16
+ * Transport: global `fetch` (Node 24+). No subprocess, no extra deps.
17
+ * Quota: reactive — parses x-ratelimit- and anthropic-ratelimit- headers
18
+ * on every response. Local endpoints (Ollama, LM Studio) have no
19
+ * rate limits.
20
+ *
21
+ * R3: `dispatch()` retains the buffered POST for simplicity + compatibility
22
+ * with tests that mock `fetch`. `stream()` switches to SSE streaming by
23
+ * setting `stream: true` in the request body and parsing wire-protocol-
24
+ * specific SSE frames as they arrive. The `completion` event is built from
25
+ * the summed delta content across all events.
26
+ */
27
+ import { BaseDispatcher } from "./base.js";
28
+ import { parseRetryAfter } from "./shared/rate-limit-headers.js";
29
+ const CHAT_PATH = "/chat/completions";
30
+ const MESSAGES_PATH = "/messages";
31
+ const ANTHROPIC_VERSION = "2023-06-01";
32
+ const DEFAULT_TIMEOUT_MS = 120_000;
33
+ /** SSE frames are separated by a blank line — spec-legal as \n\n or \r\n\r\n. */
34
+ const SSE_FRAME_BOUNDARY = /\r?\n\r?\n/;
35
+ /**
36
+ * Append `path` onto `baseUrl`, inserting `/v1` unless it's already there.
37
+ * Exported so callers other than this dispatcher (e.g. the `usage` tool's
38
+ * listModels, which hits GET {baseUrl}/models on the same endpoint) build
39
+ * URLs the same way — a baseUrl configured without a /v1 suffix must not
40
+ * 404 on one code path while working on the other.
41
+ */
42
+ export function endpointUrl(baseUrl, path) {
43
+ const trimmed = baseUrl.replace(/\/+$/, "");
44
+ return trimmed.endsWith("/v1") ? `${trimmed}${path}` : `${trimmed}/v1${path}`;
45
+ }
46
+ const DEFAULT_MAX_TOKENS = 8192;
47
+ const DEFAULT_SYSTEM_PROMPT = "You are an expert software engineer. " +
48
+ "Respond with clear, working code and concise explanations.";
49
+ const _MAX_FILE_BYTES = 512 * 1024; // 512 KB per file
50
+ export class OpenAICompatibleDispatcher extends BaseDispatcher {
51
+ id;
52
+ baseUrl;
53
+ model;
54
+ apiKey;
55
+ thinkingLevel;
56
+ wireProtocol;
57
+ maxTokens;
58
+ constructor(svc) {
59
+ super();
60
+ this.id = svc.name;
61
+ const base = svc.baseUrl ?? "";
62
+ this.baseUrl = base.replace(/\/+$/, "");
63
+ this.model = svc.model ?? "";
64
+ this.apiKey = svc.apiKey ?? "";
65
+ if (svc.thinkingLevel)
66
+ this.thinkingLevel = svc.thinkingLevel;
67
+ this.wireProtocol = svc.wireProtocol ?? "openai_chat_completions";
68
+ this.maxTokens = svc.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
69
+ }
70
+ isAvailable() {
71
+ return this.baseUrl.length > 0 && this.model.length > 0;
72
+ }
73
+ async checkQuota() {
74
+ return { service: this.id, source: "unknown" };
75
+ }
76
+ // ---------------------------------------------------------------------
77
+ // Wire-protocol-specific request building
78
+ // ---------------------------------------------------------------------
79
+ #url() {
80
+ const path = this.wireProtocol === "anthropic_messages" ? MESSAGES_PATH : CHAT_PATH;
81
+ return endpointUrl(this.baseUrl, path);
82
+ }
83
+ #headers(accept) {
84
+ const headers = {
85
+ "Content-Type": "application/json",
86
+ Accept: accept,
87
+ };
88
+ if (!this.apiKey)
89
+ return headers;
90
+ if (this.wireProtocol === "anthropic_messages") {
91
+ headers["x-api-key"] = this.apiKey;
92
+ headers["anthropic-version"] = ANTHROPIC_VERSION;
93
+ }
94
+ else {
95
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
96
+ }
97
+ return headers;
98
+ }
99
+ #body(model, fullPrompt, stream) {
100
+ if (this.wireProtocol === "anthropic_messages") {
101
+ // Anthropic requires max_tokens and puts the system prompt at the
102
+ // top level, not as a messages[] entry. thinking_level isn't
103
+ // translated to Anthropic's extended-thinking `thinking:` param yet
104
+ // (different shape: {type, budget_tokens} with its own max_tokens
105
+ // interplay) — omitted rather than sent wrong.
106
+ return {
107
+ model,
108
+ max_tokens: this.maxTokens,
109
+ system: DEFAULT_SYSTEM_PROMPT,
110
+ messages: [{ role: "user", content: fullPrompt }],
111
+ stream,
112
+ };
113
+ }
114
+ const body = {
115
+ model,
116
+ messages: [
117
+ { role: "system", content: DEFAULT_SYSTEM_PROMPT },
118
+ { role: "user", content: fullPrompt },
119
+ ],
120
+ stream,
121
+ };
122
+ if (this.thinkingLevel)
123
+ body["reasoning_effort"] = this.thinkingLevel.toLowerCase();
124
+ // Without this, most OpenAI-compatible servers omit the usage frame
125
+ // during streaming — tokensUsed would silently be missing for every
126
+ // `job` and progress-token `code` call (both always use stream()),
127
+ // while the buffered dispatch() path got it for free.
128
+ if (stream)
129
+ body["stream_options"] = { include_usage: true };
130
+ return body;
131
+ }
132
+ // ---------------------------------------------------------------------
133
+ // Wire-protocol-specific response parsing
134
+ // ---------------------------------------------------------------------
135
+ #extractContent(body) {
136
+ if (this.wireProtocol === "anthropic_messages") {
137
+ const blocks = body.content;
138
+ if (!Array.isArray(blocks))
139
+ return null;
140
+ const text = blocks
141
+ .filter((b) => b !== null && typeof b === "object" && b.type === "text" && typeof b.text === "string")
142
+ .map((b) => b.text)
143
+ .join("");
144
+ return text.length > 0 ? text : null;
145
+ }
146
+ const choices = body.choices;
147
+ if (!Array.isArray(choices) || choices.length === 0)
148
+ return null;
149
+ const first = choices[0];
150
+ if (!first || typeof first !== "object")
151
+ return null;
152
+ const msg = first.message;
153
+ if (!msg || typeof msg !== "object")
154
+ return null;
155
+ const content = msg.content;
156
+ return typeof content === "string" ? content : null;
157
+ }
158
+ #extractUsage(body) {
159
+ if (this.wireProtocol === "anthropic_messages") {
160
+ const usage = body.usage;
161
+ if (usage && typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number") {
162
+ return { input: usage.input_tokens, output: usage.output_tokens };
163
+ }
164
+ return undefined;
165
+ }
166
+ const usage = body.usage;
167
+ if (usage && typeof usage.prompt_tokens === "number" && typeof usage.completion_tokens === "number") {
168
+ return { input: usage.prompt_tokens, output: usage.completion_tokens };
169
+ }
170
+ return undefined;
171
+ }
172
+ #extractErrorMessage(body, rawBody) {
173
+ if (this.wireProtocol === "anthropic_messages") {
174
+ const message = body?.error?.message;
175
+ if (typeof message === "string")
176
+ return message;
177
+ }
178
+ else {
179
+ const message = body?.error?.message;
180
+ if (typeof message === "string")
181
+ return message;
182
+ }
183
+ return rawBody.slice(0, 200) || "(empty body)";
184
+ }
185
+ #parseBody(rawBody) {
186
+ if (!rawBody)
187
+ return null;
188
+ try {
189
+ return JSON.parse(rawBody);
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ /**
196
+ * Parse one SSE frame (`event:`/`data:` lines separated by a blank line)
197
+ * into DispatcherEvents. Anthropic frames carry a named `event:` line;
198
+ * OpenAI frames don't (only `data:`, with a `[DONE]` sentinel) — both use
199
+ * the same blank-line frame boundary, so the caller's chunking is shared.
200
+ */
201
+ /**
202
+ * `usage` is PARTIAL per frame on purpose — Anthropic splits input_tokens
203
+ * (message_start) and output_tokens (message_delta) across two different
204
+ * frames, unlike OpenAI which sends both together in its one usage-
205
+ * bearing frame. The caller merges partial updates across the whole
206
+ * stream rather than overwriting on each frame.
207
+ *
208
+ * `error`, when set, means the upstream sent a mid-stream error event
209
+ * AFTER already returning 200 and streaming some content — a case the
210
+ * HTTP-status checks earlier in #runStream never see. The caller must
211
+ * treat this as a failed completion (using whatever partial output
212
+ * accumulated so far), not silently report success.
213
+ */
214
+ #parseSseFrame(frame) {
215
+ return this.wireProtocol === "anthropic_messages"
216
+ ? this.#parseAnthropicSseFrame(frame)
217
+ : this.#parseOpenAiSseFrame(frame);
218
+ }
219
+ #parseOpenAiSseFrame(frame) {
220
+ const out = [];
221
+ let usage = null;
222
+ let error;
223
+ for (const rawLine of frame.split(/\r?\n/)) {
224
+ const line = rawLine.trim();
225
+ if (!line || !line.startsWith("data:"))
226
+ continue;
227
+ const payload = line.slice("data:".length).trim();
228
+ if (payload === "[DONE]")
229
+ continue;
230
+ let obj;
231
+ try {
232
+ obj = JSON.parse(payload);
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ if (obj.error) {
238
+ error = typeof obj.error.message === "string" ? obj.error.message : "upstream error mid-stream";
239
+ continue;
240
+ }
241
+ const choices = obj.choices;
242
+ if (Array.isArray(choices)) {
243
+ for (const c of choices) {
244
+ const delta = c.delta ?? c.message;
245
+ const content = delta?.content;
246
+ if (typeof content === "string" && content.length > 0) {
247
+ out.push({ type: "stdout", chunk: content });
248
+ }
249
+ }
250
+ }
251
+ if (obj.usage) {
252
+ const input = obj.usage.prompt_tokens;
253
+ const output = obj.usage.completion_tokens;
254
+ if (typeof input === "number" && typeof output === "number") {
255
+ usage = { input, output };
256
+ }
257
+ }
258
+ }
259
+ return error !== undefined ? { events: out, usage, error } : { events: out, usage };
260
+ }
261
+ /**
262
+ * Anthropic streams message_start (initial input_tokens),
263
+ * content_block_delta (text_delta chunks), message_delta (final
264
+ * output_tokens), and (on failure) a named error event — accumulate
265
+ * across every `data:` line in the frame rather than keeping only the
266
+ * last one. That matters beyond just "a frame with two events": if frame
267
+ * boundaries ever get miscounted upstream (e.g. a proxy that coalesces
268
+ * writes), multiple real SSE events can land in what we treat as one
269
+ * frame — dropping all but the last would silently lose content or usage.
270
+ */
271
+ #parseAnthropicSseFrame(frame) {
272
+ const out = [];
273
+ let inputTokens;
274
+ let outputTokens;
275
+ let error;
276
+ const dataLines = [];
277
+ for (const rawLine of frame.split(/\r?\n/)) {
278
+ const line = rawLine.trim();
279
+ if (line.startsWith("data:"))
280
+ dataLines.push(line.slice("data:".length).trim());
281
+ }
282
+ for (const dataLine of dataLines) {
283
+ let obj;
284
+ try {
285
+ obj = JSON.parse(dataLine);
286
+ }
287
+ catch {
288
+ continue;
289
+ }
290
+ if (obj.type === "message_start") {
291
+ const v = obj.message?.usage?.input_tokens;
292
+ if (typeof v === "number")
293
+ inputTokens = v;
294
+ }
295
+ else if (obj.type === "content_block_delta") {
296
+ const text = obj.delta?.type === "text_delta" ? obj.delta.text : undefined;
297
+ if (typeof text === "string" && text.length > 0)
298
+ out.push({ type: "stdout", chunk: text });
299
+ }
300
+ else if (obj.type === "message_delta") {
301
+ const v = obj.usage?.output_tokens;
302
+ if (typeof v === "number")
303
+ outputTokens = v;
304
+ }
305
+ else if (obj.type === "error") {
306
+ error = typeof obj.error?.message === "string" ? obj.error.message : "upstream error mid-stream";
307
+ }
308
+ }
309
+ const usage = inputTokens !== undefined || outputTokens !== undefined
310
+ ? {
311
+ ...(inputTokens !== undefined ? { input: inputTokens } : {}),
312
+ ...(outputTokens !== undefined ? { output: outputTokens } : {}),
313
+ }
314
+ : null;
315
+ return error !== undefined ? { events: out, usage, error } : { events: out, usage };
316
+ }
317
+ // ---------------------------------------------------------------------
318
+ // Dispatch
319
+ // ---------------------------------------------------------------------
320
+ /**
321
+ * Buffered one-shot: POST with stream=false, parse a single JSON body.
322
+ * Kept as a fast-path (no incremental parsing overhead) and to preserve
323
+ * existing mocked-fetch tests.
324
+ */
325
+ async dispatch(prompt, files, _workingDir, opts = {}) {
326
+ const start = Date.now();
327
+ const fullPrompt = await buildPromptWithFiles(prompt, files);
328
+ const url = this.#url();
329
+ const model = opts.modelOverride ?? this.model;
330
+ const body = this.#body(model, fullPrompt, false);
331
+ const headers = this.#headers("application/json");
332
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
333
+ const controller = new AbortController();
334
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
335
+ timer.unref?.();
336
+ let res;
337
+ try {
338
+ res = await fetch(url, {
339
+ method: "POST",
340
+ headers,
341
+ body: JSON.stringify(body),
342
+ signal: controller.signal,
343
+ });
344
+ }
345
+ catch (err) {
346
+ clearTimeout(timer);
347
+ const errMsg = err instanceof Error ? err.message : String(err);
348
+ const aborted = err?.name === "AbortError";
349
+ return {
350
+ output: "",
351
+ service: this.id,
352
+ success: false,
353
+ error: aborted ? `Timed out after ${timeoutMs}ms` : errMsg,
354
+ durationMs: Date.now() - start,
355
+ };
356
+ }
357
+ clearTimeout(timer);
358
+ const responseHeaders = headersToObject(res.headers);
359
+ const durationMs = Date.now() - start;
360
+ let rawBody = "";
361
+ try {
362
+ rawBody = await res.text();
363
+ }
364
+ catch {
365
+ // Body read failed — treat as empty.
366
+ }
367
+ const parsedBody = this.#parseBody(rawBody);
368
+ if (res.status === 429) {
369
+ const retryAfter = parseRetryAfter(responseHeaders);
370
+ const result = {
371
+ output: "",
372
+ service: this.id,
373
+ success: false,
374
+ error: `Rate limited by ${this.id}`,
375
+ rateLimited: true,
376
+ rateLimitHeaders: responseHeaders,
377
+ durationMs,
378
+ };
379
+ if (retryAfter !== null)
380
+ result.retryAfter = retryAfter;
381
+ return result;
382
+ }
383
+ if (res.status >= 400) {
384
+ const errMessage = this.#extractErrorMessage(parsedBody, rawBody);
385
+ return {
386
+ output: "",
387
+ service: this.id,
388
+ success: false,
389
+ error: `HTTP ${res.status}: ${errMessage}`,
390
+ durationMs,
391
+ rateLimitHeaders: responseHeaders,
392
+ };
393
+ }
394
+ const content = parsedBody ? this.#extractContent(parsedBody) : null;
395
+ if (content === null) {
396
+ return {
397
+ output: "",
398
+ service: this.id,
399
+ success: false,
400
+ error: `Unexpected response shape: ${rawBody.slice(0, 300)}`,
401
+ durationMs,
402
+ rateLimitHeaders: responseHeaders,
403
+ };
404
+ }
405
+ const result = {
406
+ output: content,
407
+ service: this.id,
408
+ success: true,
409
+ durationMs,
410
+ rateLimitHeaders: responseHeaders,
411
+ };
412
+ if (parsedBody) {
413
+ const tokensUsed = this.#extractUsage(parsedBody);
414
+ if (tokensUsed)
415
+ result.tokensUsed = tokensUsed;
416
+ }
417
+ return result;
418
+ }
419
+ stream(prompt, files, workingDir, opts = {}) {
420
+ return this.#runStream(prompt, files, workingDir, opts);
421
+ }
422
+ async *#runStream(prompt, files, _workingDir, opts) {
423
+ const start = Date.now();
424
+ const fullPrompt = await buildPromptWithFiles(prompt, files);
425
+ const url = this.#url();
426
+ const model = opts.modelOverride ?? this.model;
427
+ const body = this.#body(model, fullPrompt, true);
428
+ const headers = this.#headers("text/event-stream");
429
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
430
+ const controller = new AbortController();
431
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
432
+ timer.unref?.();
433
+ let res;
434
+ try {
435
+ res = await fetch(url, {
436
+ method: "POST",
437
+ headers,
438
+ body: JSON.stringify(body),
439
+ signal: controller.signal,
440
+ });
441
+ }
442
+ catch (err) {
443
+ clearTimeout(timer);
444
+ const errMsg = err instanceof Error ? err.message : String(err);
445
+ const aborted = err?.name === "AbortError";
446
+ yield {
447
+ type: "completion",
448
+ result: {
449
+ output: "",
450
+ service: this.id,
451
+ success: false,
452
+ error: aborted ? `Timed out after ${timeoutMs}ms` : errMsg,
453
+ durationMs: Date.now() - start,
454
+ },
455
+ };
456
+ return;
457
+ }
458
+ const responseHeaders = headersToObject(res.headers);
459
+ if (res.status === 429) {
460
+ clearTimeout(timer);
461
+ const retryAfter = parseRetryAfter(responseHeaders);
462
+ const result = {
463
+ output: "",
464
+ service: this.id,
465
+ success: false,
466
+ error: `Rate limited by ${this.id}`,
467
+ rateLimited: true,
468
+ rateLimitHeaders: responseHeaders,
469
+ durationMs: Date.now() - start,
470
+ };
471
+ if (retryAfter !== null)
472
+ result.retryAfter = retryAfter;
473
+ yield { type: "completion", result };
474
+ return;
475
+ }
476
+ if (res.status >= 400) {
477
+ clearTimeout(timer);
478
+ const rawBody = await res.text().catch(() => "");
479
+ const parsedBody = this.#parseBody(rawBody);
480
+ const errMessage = this.#extractErrorMessage(parsedBody, rawBody);
481
+ yield {
482
+ type: "completion",
483
+ result: {
484
+ output: "",
485
+ service: this.id,
486
+ success: false,
487
+ error: `HTTP ${res.status}: ${errMessage}`,
488
+ durationMs: Date.now() - start,
489
+ rateLimitHeaders: responseHeaders,
490
+ },
491
+ };
492
+ return;
493
+ }
494
+ // Stream body — SSE frames are separated by a blank line, which the
495
+ // spec allows as \n\n OR \r\n\r\n. A literal indexOf("\n\n") never
496
+ // matches CRLF-framed streams, so the whole body silently piles up in
497
+ // the trailing flush as one "frame" — use a regex boundary instead.
498
+ const chunks = [];
499
+ let buffer = "";
500
+ // Merged across frames, not overwritten — Anthropic's input/output
501
+ // token counts arrive on two DIFFERENT frames (see #parseSseFrame).
502
+ let inputTokens;
503
+ let outputTokens;
504
+ let streamError;
505
+ const mergeUsage = (u) => {
506
+ if (!u)
507
+ return;
508
+ if (u.input !== undefined)
509
+ inputTokens = u.input;
510
+ if (u.output !== undefined)
511
+ outputTokens = u.output;
512
+ };
513
+ if (!res.body) {
514
+ clearTimeout(timer);
515
+ yield {
516
+ type: "completion",
517
+ result: {
518
+ output: "",
519
+ service: this.id,
520
+ success: false,
521
+ error: "No response body",
522
+ durationMs: Date.now() - start,
523
+ rateLimitHeaders: responseHeaders,
524
+ },
525
+ };
526
+ return;
527
+ }
528
+ const reader = res.body.getReader();
529
+ const decoder = new TextDecoder();
530
+ try {
531
+ outer: while (true) {
532
+ const { value, done } = await reader.read();
533
+ if (done)
534
+ break;
535
+ buffer += decoder.decode(value, { stream: true });
536
+ let boundary = SSE_FRAME_BOUNDARY.exec(buffer);
537
+ while (boundary !== null) {
538
+ const frame = buffer.slice(0, boundary.index);
539
+ buffer = buffer.slice(boundary.index + boundary[0].length);
540
+ const evts = this.#parseSseFrame(frame);
541
+ for (const e of evts.events) {
542
+ yield e;
543
+ if (e.type === "stdout")
544
+ chunks.push(e.chunk);
545
+ }
546
+ mergeUsage(evts.usage);
547
+ if (evts.error !== undefined) {
548
+ streamError = evts.error;
549
+ break outer; // Upstream sent a mid-stream error — stop reading.
550
+ }
551
+ boundary = SSE_FRAME_BOUNDARY.exec(buffer);
552
+ }
553
+ }
554
+ }
555
+ catch (err) {
556
+ clearTimeout(timer);
557
+ const errMsg = err instanceof Error ? err.message : String(err);
558
+ yield {
559
+ type: "completion",
560
+ result: {
561
+ output: chunks.join(""),
562
+ service: this.id,
563
+ success: false,
564
+ error: errMsg,
565
+ durationMs: Date.now() - start,
566
+ rateLimitHeaders: responseHeaders,
567
+ },
568
+ };
569
+ return;
570
+ }
571
+ clearTimeout(timer);
572
+ // A mid-stream error broke out of the read loop with the response body
573
+ // still open — cancel it so the connection is released now instead of
574
+ // whenever the server or GC gets around to it.
575
+ if (streamError !== undefined) {
576
+ try {
577
+ await reader.cancel();
578
+ }
579
+ catch {
580
+ // Releasing a broken stream is best-effort.
581
+ }
582
+ }
583
+ // Flush trailing frame if any (skipped if a mid-stream error already
584
+ // ended the read loop early — nothing meaningful left to parse).
585
+ if (streamError === undefined && buffer.trim()) {
586
+ const evts = this.#parseSseFrame(buffer);
587
+ for (const e of evts.events) {
588
+ yield e;
589
+ if (e.type === "stdout")
590
+ chunks.push(e.chunk);
591
+ }
592
+ mergeUsage(evts.usage);
593
+ if (evts.error !== undefined)
594
+ streamError = evts.error;
595
+ }
596
+ const output = chunks.join("");
597
+ const result = streamError !== undefined
598
+ ? {
599
+ output,
600
+ service: this.id,
601
+ success: false,
602
+ error: streamError,
603
+ durationMs: Date.now() - start,
604
+ rateLimitHeaders: responseHeaders,
605
+ }
606
+ : {
607
+ output,
608
+ service: this.id,
609
+ success: true,
610
+ durationMs: Date.now() - start,
611
+ rateLimitHeaders: responseHeaders,
612
+ };
613
+ if (inputTokens !== undefined && outputTokens !== undefined) {
614
+ result.tokensUsed = { input: inputTokens, output: outputTokens };
615
+ }
616
+ yield { type: "completion", result };
617
+ }
618
+ }
619
+ // ---------------------------------------------------------------------------
620
+ // Helpers
621
+ // ---------------------------------------------------------------------------
622
+ function headersToObject(h) {
623
+ const out = {};
624
+ h.forEach((value, key) => {
625
+ out[key] = value;
626
+ });
627
+ return out;
628
+ }
629
+ async function buildPromptWithFiles(prompt, files) {
630
+ if (files.length === 0)
631
+ return prompt;
632
+ const parts = [prompt];
633
+ const { stat, readFile } = await import("node:fs/promises");
634
+ const { extname } = await import("node:path");
635
+ for (const filePath of files) {
636
+ try {
637
+ const info = await stat(filePath);
638
+ if (!info.isFile()) {
639
+ parts.push(`\n# Not a file: ${filePath}`);
640
+ continue;
641
+ }
642
+ if (info.size > _MAX_FILE_BYTES) {
643
+ parts.push(`\n# Skipped ${filePath}: file too large (${Math.floor(info.size / 1024)} KB > ${_MAX_FILE_BYTES / 1024} KB limit)`);
644
+ continue;
645
+ }
646
+ const content = await readFile(filePath, "utf8");
647
+ const ext = extname(filePath).replace(/^\./, "");
648
+ parts.push(`\n\n\`\`\`${ext}\n# ${filePath}\n${content}\n\`\`\``);
649
+ }
650
+ catch (err) {
651
+ if (err?.code === "ENOENT") {
652
+ parts.push(`\n# File not found: ${filePath}`);
653
+ }
654
+ else {
655
+ const msg = err instanceof Error ? err.message : String(err);
656
+ parts.push(`\n# Could not read ${filePath}: ${msg}`);
657
+ }
658
+ }
659
+ }
660
+ return parts.join("\n");
661
+ }
662
+ //# sourceMappingURL=openai-compatible.js.map