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.
- package/LICENSE +21 -0
- package/README.md +582 -0
- package/config.default.yaml +397 -0
- package/data/coding_benchmarks.json +431 -0
- package/dist/auth.d.ts +9 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +73 -0
- package/dist/auth.js.map +1 -0
- package/dist/billing.d.ts +7 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +201 -0
- package/dist/billing.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +488 -0
- package/dist/bin.js.map +1 -0
- package/dist/breaker-store.d.ts +31 -0
- package/dist/breaker-store.d.ts.map +1 -0
- package/dist/breaker-store.js +103 -0
- package/dist/breaker-store.js.map +1 -0
- package/dist/circuit-breaker.d.ts +47 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +102 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +1248 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/live.d.ts +49 -0
- package/dist/dashboard/live.d.ts.map +1 -0
- package/dist/dashboard/live.js +149 -0
- package/dist/dashboard/live.js.map +1 -0
- package/dist/dispatch-log.d.ts +46 -0
- package/dist/dispatch-log.d.ts.map +1 -0
- package/dist/dispatch-log.js +89 -0
- package/dist/dispatch-log.js.map +1 -0
- package/dist/dispatchers/base.d.ts +55 -0
- package/dist/dispatchers/base.d.ts.map +1 -0
- package/dist/dispatchers/base.js +80 -0
- package/dist/dispatchers/base.js.map +1 -0
- package/dist/dispatchers/generic-cli.d.ts +30 -0
- package/dist/dispatchers/generic-cli.d.ts.map +1 -0
- package/dist/dispatchers/generic-cli.js +471 -0
- package/dist/dispatchers/generic-cli.js.map +1 -0
- package/dist/dispatchers/openai-compatible.d.ts +57 -0
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
- package/dist/dispatchers/openai-compatible.js +662 -0
- package/dist/dispatchers/openai-compatible.js.map +1 -0
- package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
- package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
- package/dist/dispatchers/shared/kill-tree.js +35 -0
- package/dist/dispatchers/shared/kill-tree.js.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
- package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
- package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
- package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/stream-subprocess.js +274 -0
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
- package/dist/dispatchers/shared/subprocess.d.ts +25 -0
- package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
- package/dist/dispatchers/shared/subprocess.js +110 -0
- package/dist/dispatchers/shared/subprocess.js.map +1 -0
- package/dist/dispatchers/shared/which-available.d.ts +2 -0
- package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
- package/dist/dispatchers/shared/which-available.js +8 -0
- package/dist/dispatchers/shared/which-available.js.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
- package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
- package/dist/dispatchers/shared/windows-cmd.js +91 -0
- package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
- package/dist/http/server.d.ts +15 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +495 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/job-runner.d.ts +19 -0
- package/dist/job-runner.d.ts.map +1 -0
- package/dist/job-runner.js +42 -0
- package/dist/job-runner.js.map +1 -0
- package/dist/jobs.d.ts +116 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +542 -0
- package/dist/jobs.js.map +1 -0
- package/dist/leaderboard.d.ts +62 -0
- package/dist/leaderboard.d.ts.map +1 -0
- package/dist/leaderboard.js +287 -0
- package/dist/leaderboard.js.map +1 -0
- package/dist/mcp/config-hot-reload.d.ts +64 -0
- package/dist/mcp/config-hot-reload.d.ts.map +1 -0
- package/dist/mcp/config-hot-reload.js +144 -0
- package/dist/mcp/config-hot-reload.js.map +1 -0
- package/dist/mcp/dispatcher-factory.d.ts +20 -0
- package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
- package/dist/mcp/dispatcher-factory.js +30 -0
- package/dist/mcp/dispatcher-factory.js.map +1 -0
- package/dist/mcp/resources.d.ts +8 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +45 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +36 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +80 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +171 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +658 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/observability/index.d.ts +52 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +111 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/spans.d.ts +47 -0
- package/dist/observability/spans.d.ts.map +1 -0
- package/dist/observability/spans.js +72 -0
- package/dist/observability/spans.js.map +1 -0
- package/dist/quota.d.ts +91 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +331 -0
- package/dist/quota.js.map +1 -0
- package/dist/route-policy.d.ts +24 -0
- package/dist/route-policy.d.ts.map +1 -0
- package/dist/route-policy.js +92 -0
- package/dist/route-policy.js.map +1 -0
- package/dist/router.d.ts +162 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +878 -0
- package/dist/router.js.map +1 -0
- package/dist/safety.d.ts +7 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +33 -0
- package/dist/safety.js.map +1 -0
- package/dist/status.d.ts +100 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +228 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +402 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +13 -0
- package/dist/version.js.map +1 -0
- package/dist/working-dir.d.ts +15 -0
- package/dist/working-dir.d.ts.map +1 -0
- package/dist/working-dir.js +22 -0
- package/dist/working-dir.js.map +1 -0
- package/dist/workspaces.d.ts +19 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +352 -0
- package/dist/workspaces.js.map +1 -0
- 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
|