castle-web-cli 0.4.78 → 0.4.80
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/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +690 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-D3unT7do.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { NativeUsage } from "./types.js";
|
|
2
|
+
export interface ORToolCall {
|
|
3
|
+
id: string;
|
|
4
|
+
type: "function";
|
|
5
|
+
function: {
|
|
6
|
+
name: string;
|
|
7
|
+
arguments: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export type ORContentPart = {
|
|
11
|
+
type: "text";
|
|
12
|
+
text: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "image_url";
|
|
15
|
+
image_url: {
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export interface ORMessage {
|
|
20
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
21
|
+
content?: string | null | ORContentPart[];
|
|
22
|
+
tool_calls?: ORToolCall[];
|
|
23
|
+
tool_call_id?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ORAssistantMessage {
|
|
26
|
+
role: "assistant";
|
|
27
|
+
content: string;
|
|
28
|
+
tool_calls?: ORToolCall[];
|
|
29
|
+
}
|
|
30
|
+
export type ORReasoningEffort = "low" | "medium";
|
|
31
|
+
export interface StreamChatOpts {
|
|
32
|
+
apiKey: string;
|
|
33
|
+
model: string;
|
|
34
|
+
messages: ORMessage[];
|
|
35
|
+
tools?: unknown[];
|
|
36
|
+
reasoningEffort?: ORReasoningEffort;
|
|
37
|
+
maxTokens?: number;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
onDelta?: (delta: string) => void;
|
|
40
|
+
onThinking?: (delta: string) => void;
|
|
41
|
+
onRetry?: (info: {
|
|
42
|
+
attempt: number;
|
|
43
|
+
status?: number;
|
|
44
|
+
delayMs: number;
|
|
45
|
+
}) => void;
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface StreamChatResult {
|
|
49
|
+
message: ORAssistantMessage | null;
|
|
50
|
+
usage?: NativeUsage;
|
|
51
|
+
reasoningTokens?: number;
|
|
52
|
+
crashed: boolean;
|
|
53
|
+
error?: string;
|
|
54
|
+
}
|
|
55
|
+
export declare function streamChatCompletion(opts: StreamChatOpts): Promise<StreamChatResult>;
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Streaming client for OpenRouter's OpenAI-shaped chat completions endpoint.
|
|
2
|
+
// One call = one assistant turn: POST with stream:true, manually parse the
|
|
3
|
+
// SSE body (no SDK), and resolve once the stream reports a finish_reason (or
|
|
4
|
+
// dies trying). See native/loop.ts for the multi-turn tool loop built on top
|
|
5
|
+
// of this, and native/types.ts for how the result maps back to agent.ts's
|
|
6
|
+
// CliRunResult shape.
|
|
7
|
+
//
|
|
8
|
+
// Retry policy: only the CONNECT step (the initial fetch -- before any bytes
|
|
9
|
+
// of the response body have been read) retries on 429/5xx, jittered, up to a
|
|
10
|
+
// small cap. Once streaming has started, a dropped connection is a crash
|
|
11
|
+
// (surfaced via `crashed: true`), not something this client silently retries
|
|
12
|
+
// mid-turn -- replaying a partial generation would require re-deriving what
|
|
13
|
+
// the model already said, which the caller (native/loop.ts) is better placed
|
|
14
|
+
// to decide (or simply not do, matching today's crash-then-relaunch policy at
|
|
15
|
+
// the task-attempt level).
|
|
16
|
+
// Overridable via CASTLE_OPENROUTER_URL so the QA harness (see
|
|
17
|
+
// scripts/tests/agent-qa/native/fake-openrouter.mjs) can point this client at
|
|
18
|
+
// a local fake server instead of the real API -- read per-call (not hoisted
|
|
19
|
+
// to a module-level const) so a test process that imports this module once
|
|
20
|
+
// can still point successive runAgentNative calls at different fake servers.
|
|
21
|
+
function openrouterUrl() {
|
|
22
|
+
return process.env.CASTLE_OPENROUTER_URL || "https://openrouter.ai/api/v1/chat/completions";
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_MAX_RETRIES = 2; // -> 3 total connect attempts
|
|
25
|
+
const RETRY_BASE_MS = 500;
|
|
26
|
+
const RETRY_MAX_MS = 4_000;
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
function backoffMs(attempt) {
|
|
31
|
+
const exp = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
|
|
32
|
+
return exp + Math.random() * 250;
|
|
33
|
+
}
|
|
34
|
+
function isRetryableStatus(status) {
|
|
35
|
+
return status === 429 || status >= 500;
|
|
36
|
+
}
|
|
37
|
+
async function safeReadText(res) {
|
|
38
|
+
try {
|
|
39
|
+
return (await res.text()).trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Connect with retry-on-429/5xx. Resolves to an ok Response or throws. A
|
|
46
|
+
// non-retryable HTTP error (4xx other than 429) throws immediately with the
|
|
47
|
+
// response body folded into the message, same as a network-level failure --
|
|
48
|
+
// both are "the request never got a stream going" and get the same
|
|
49
|
+
// "could not run openrouter: ..." wrapper one level up (see loop.ts), which
|
|
50
|
+
// mirrors the wording classifyRouterFailure's "spawn" bucket in agent.ts
|
|
51
|
+
// looks for (agent.ts: `error?.startsWith("could not run")`).
|
|
52
|
+
async function connectWithRetry(init, opts) {
|
|
53
|
+
const maxAttempts = 1 + (opts.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
54
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
55
|
+
if (opts.signal?.aborted) {
|
|
56
|
+
throw new DOMException("aborted", "AbortError");
|
|
57
|
+
}
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await fetch(openrouterUrl(), init);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
64
|
+
throw err;
|
|
65
|
+
if (attempt < maxAttempts - 1) {
|
|
66
|
+
const delayMs = backoffMs(attempt);
|
|
67
|
+
opts.onRetry?.({ attempt: attempt + 1, delayMs });
|
|
68
|
+
await sleep(delayMs);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
72
|
+
}
|
|
73
|
+
if (res.ok)
|
|
74
|
+
return res;
|
|
75
|
+
if (isRetryableStatus(res.status) && attempt < maxAttempts - 1) {
|
|
76
|
+
const delayMs = backoffMs(attempt);
|
|
77
|
+
opts.onRetry?.({ attempt: attempt + 1, status: res.status, delayMs });
|
|
78
|
+
await sleep(delayMs);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const bodyText = await safeReadText(res);
|
|
82
|
+
throw new Error(`HTTP ${res.status}${bodyText ? `: ${bodyText.slice(0, 300)}` : ""}`);
|
|
83
|
+
}
|
|
84
|
+
// Unreachable (the loop above always returns or throws), but keeps the
|
|
85
|
+
// return type honest for TS.
|
|
86
|
+
throw new Error("connectWithRetry: exhausted attempts without resolving");
|
|
87
|
+
}
|
|
88
|
+
// Yields one JSON payload per complete SSE event ("data: ..." line(s) up to a
|
|
89
|
+
// blank line), skipping ":"-prefixed comment/keep-alive lines (OpenRouter
|
|
90
|
+
// sends these periodically while a slow upstream provider is thinking) and
|
|
91
|
+
// terminating on the literal "[DONE]" sentinel.
|
|
92
|
+
async function* iterateSseData(body) {
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
let buffer = "";
|
|
95
|
+
let dataLines = [];
|
|
96
|
+
const reader = body.getReader();
|
|
97
|
+
try {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const { value, done } = await reader.read();
|
|
100
|
+
if (done)
|
|
101
|
+
break;
|
|
102
|
+
buffer += decoder.decode(value, { stream: true });
|
|
103
|
+
let nl = buffer.indexOf("\n");
|
|
104
|
+
while (nl >= 0) {
|
|
105
|
+
const rawLine = buffer.slice(0, nl);
|
|
106
|
+
buffer = buffer.slice(nl + 1);
|
|
107
|
+
nl = buffer.indexOf("\n");
|
|
108
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
109
|
+
if (line === "") {
|
|
110
|
+
if (dataLines.length > 0) {
|
|
111
|
+
const payload = dataLines.join("\n");
|
|
112
|
+
dataLines = [];
|
|
113
|
+
if (payload === "[DONE]")
|
|
114
|
+
return;
|
|
115
|
+
yield payload;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (line.startsWith(":")) {
|
|
119
|
+
// comment / keep-alive -- ignore
|
|
120
|
+
}
|
|
121
|
+
else if (line.startsWith("data:")) {
|
|
122
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (dataLines.length > 0) {
|
|
127
|
+
const payload = dataLines.join("\n");
|
|
128
|
+
if (payload !== "[DONE]")
|
|
129
|
+
yield payload;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
try {
|
|
134
|
+
reader.releaseLock();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* already released */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function mapUsage(raw) {
|
|
142
|
+
if (!raw || typeof raw !== "object")
|
|
143
|
+
return {};
|
|
144
|
+
const src = raw;
|
|
145
|
+
const usage = {};
|
|
146
|
+
if (typeof src.prompt_tokens === "number")
|
|
147
|
+
usage.input_tokens = src.prompt_tokens;
|
|
148
|
+
if (typeof src.completion_tokens === "number")
|
|
149
|
+
usage.output_tokens = src.completion_tokens;
|
|
150
|
+
const promptDetails = src.prompt_tokens_details;
|
|
151
|
+
if (promptDetails && typeof promptDetails === "object") {
|
|
152
|
+
const d = promptDetails;
|
|
153
|
+
if (typeof d.cached_tokens === "number")
|
|
154
|
+
usage.cache_read_input_tokens = d.cached_tokens;
|
|
155
|
+
if (typeof d.cache_write_tokens === "number")
|
|
156
|
+
usage.cache_creation_input_tokens = d.cache_write_tokens;
|
|
157
|
+
}
|
|
158
|
+
// OpenAI-shaped completion_tokens_details.reasoning_tokens -- the count of
|
|
159
|
+
// hidden "thinking" tokens billed as output for this call. This is the
|
|
160
|
+
// number that would have caught the incident motivating this file's
|
|
161
|
+
// per-iteration usage logging (loop.ts): ~60K reasoning tokens on one
|
|
162
|
+
// multimodal iteration, invisible until forensically summed after the
|
|
163
|
+
// fact because usage was only ever logged once per whole run.
|
|
164
|
+
let reasoningTokens;
|
|
165
|
+
const completionDetails = src.completion_tokens_details;
|
|
166
|
+
if (completionDetails && typeof completionDetails === "object") {
|
|
167
|
+
const d = completionDetails;
|
|
168
|
+
if (typeof d.reasoning_tokens === "number")
|
|
169
|
+
reasoningTokens = d.reasoning_tokens;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
usage: Object.keys(usage).length > 0 ? usage : undefined,
|
|
173
|
+
reasoningTokens,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function finalizeToolCalls(pending) {
|
|
177
|
+
if (pending.size === 0)
|
|
178
|
+
return undefined;
|
|
179
|
+
const indices = [...pending.keys()].sort((a, b) => a - b);
|
|
180
|
+
return indices.map((i) => {
|
|
181
|
+
const p = pending.get(i);
|
|
182
|
+
return {
|
|
183
|
+
id: p.id ?? `call_${i}`,
|
|
184
|
+
type: "function",
|
|
185
|
+
function: { name: p.name ?? "", arguments: p.arguments },
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export async function streamChatCompletion(opts) {
|
|
190
|
+
const body = {
|
|
191
|
+
model: opts.model,
|
|
192
|
+
messages: opts.messages,
|
|
193
|
+
stream: true,
|
|
194
|
+
// Deprecated on OpenRouter's side (usage is always included now) but
|
|
195
|
+
// harmless to send -- keeps this client correct against providers that
|
|
196
|
+
// still gate usage on it.
|
|
197
|
+
stream_options: { include_usage: true },
|
|
198
|
+
...(opts.tools && opts.tools.length > 0 ? { tools: opts.tools } : {}),
|
|
199
|
+
// See ORReasoningEffort / StreamChatOpts.reasoningEffort above for the
|
|
200
|
+
// doc reference and the graceful-degradation guarantee this relies on.
|
|
201
|
+
...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
|
|
202
|
+
// See StreamChatOpts.maxTokens above -- bounds one completion call's
|
|
203
|
+
// total output (content + tool-call arguments + reasoning).
|
|
204
|
+
...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
|
|
205
|
+
};
|
|
206
|
+
let res;
|
|
207
|
+
try {
|
|
208
|
+
res = await connectWithRetry({
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
signal: opts.signal,
|
|
216
|
+
}, { signal: opts.signal, maxRetries: opts.maxRetries, onRetry: opts.onRetry });
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
220
|
+
return { message: null, crashed: false };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
message: null,
|
|
224
|
+
crashed: false,
|
|
225
|
+
error: `could not run openrouter: ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (!res.body) {
|
|
229
|
+
return { message: null, crashed: false, error: "could not run openrouter: empty response body" };
|
|
230
|
+
}
|
|
231
|
+
let content = "";
|
|
232
|
+
// Observed live (2026-07): some providers' MID-stream usage passthrough is
|
|
233
|
+
// junk (constant cache_read=128 / cache_created=0 on every event) while the
|
|
234
|
+
// FINAL usage chunk -- the one OpenRouter emits at/after finish_reason --
|
|
235
|
+
// carries the real cumulative numbers. So usage from a chunk that arrives
|
|
236
|
+
// once finish_reason is known is authoritative (finalUsage); earlier
|
|
237
|
+
// mid-stream usage is kept only as a fallback for streams that die before
|
|
238
|
+
// reaching that point.
|
|
239
|
+
let usage;
|
|
240
|
+
let finalUsage;
|
|
241
|
+
// Tracked in parallel with usage/finalUsage above, same final-over-mid-
|
|
242
|
+
// stream preference (see mapUsage's comment for why this is a real,
|
|
243
|
+
// observed-live distinction and not theoretical).
|
|
244
|
+
let reasoningTokens;
|
|
245
|
+
let finalReasoningTokens;
|
|
246
|
+
let finishReason = null;
|
|
247
|
+
let midStreamError;
|
|
248
|
+
const pendingToolCalls = new Map();
|
|
249
|
+
try {
|
|
250
|
+
for await (const raw of iterateSseData(res.body)) {
|
|
251
|
+
let obj;
|
|
252
|
+
try {
|
|
253
|
+
obj = JSON.parse(raw);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
continue; // non-JSON keep-alive noise -- ignore
|
|
257
|
+
}
|
|
258
|
+
const choices = obj.choices;
|
|
259
|
+
const choice = choices?.[0];
|
|
260
|
+
if (obj.usage) {
|
|
261
|
+
const mapped = mapUsage(obj.usage);
|
|
262
|
+
if (mapped.usage)
|
|
263
|
+
usage = mapped.usage;
|
|
264
|
+
if (mapped.reasoningTokens !== undefined)
|
|
265
|
+
reasoningTokens = mapped.reasoningTokens;
|
|
266
|
+
// Final if finish_reason already streamed OR rides on this very
|
|
267
|
+
// chunk (providers differ on whether usage shares the finish chunk
|
|
268
|
+
// or follows it as a choices-less trailer).
|
|
269
|
+
if (finishReason !== null || (typeof choice?.finish_reason === "string" && choice.finish_reason)) {
|
|
270
|
+
if (mapped.usage)
|
|
271
|
+
finalUsage = mapped.usage;
|
|
272
|
+
if (mapped.reasoningTokens !== undefined)
|
|
273
|
+
finalReasoningTokens = mapped.reasoningTokens;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!choice)
|
|
277
|
+
continue;
|
|
278
|
+
const delta = choice.delta ?? {};
|
|
279
|
+
if (typeof delta.content === "string" && delta.content) {
|
|
280
|
+
content += delta.content;
|
|
281
|
+
opts.onDelta?.(delta.content);
|
|
282
|
+
}
|
|
283
|
+
if (typeof delta.reasoning === "string" && delta.reasoning) {
|
|
284
|
+
opts.onThinking?.(delta.reasoning);
|
|
285
|
+
}
|
|
286
|
+
const toolCallDeltas = delta.tool_calls;
|
|
287
|
+
if (Array.isArray(toolCallDeltas)) {
|
|
288
|
+
for (const tc of toolCallDeltas) {
|
|
289
|
+
const index = typeof tc.index === "number" ? tc.index : 0;
|
|
290
|
+
const entry = pendingToolCalls.get(index) ?? { arguments: "" };
|
|
291
|
+
if (typeof tc.id === "string" && tc.id)
|
|
292
|
+
entry.id = tc.id;
|
|
293
|
+
const fn = tc.function;
|
|
294
|
+
if (fn) {
|
|
295
|
+
if (typeof fn.name === "string" && fn.name)
|
|
296
|
+
entry.name = fn.name;
|
|
297
|
+
if (typeof fn.arguments === "string")
|
|
298
|
+
entry.arguments += fn.arguments;
|
|
299
|
+
}
|
|
300
|
+
pendingToolCalls.set(index, entry);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
304
|
+
finishReason = choice.finish_reason;
|
|
305
|
+
if (finishReason === "error") {
|
|
306
|
+
const errObj = obj.error;
|
|
307
|
+
midStreamError =
|
|
308
|
+
(typeof errObj?.message === "string" && errObj.message) ||
|
|
309
|
+
"openrouter reported a mid-stream error";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
316
|
+
return { message: null, usage, reasoningTokens, crashed: false };
|
|
317
|
+
}
|
|
318
|
+
// The connection died while we were mid-read -- a genuine crash, not a
|
|
319
|
+
// clean finish_reason termination.
|
|
320
|
+
return {
|
|
321
|
+
message: null,
|
|
322
|
+
usage,
|
|
323
|
+
reasoningTokens,
|
|
324
|
+
crashed: true,
|
|
325
|
+
error: `openrouter stream failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (finishReason === null) {
|
|
329
|
+
// Stream closed (or hit [DONE]) without ever reporting a finish_reason --
|
|
330
|
+
// treat the same as a mid-read failure.
|
|
331
|
+
return {
|
|
332
|
+
message: null,
|
|
333
|
+
usage,
|
|
334
|
+
reasoningTokens,
|
|
335
|
+
crashed: true,
|
|
336
|
+
error: "openrouter stream ended without a finish reason",
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (finishReason === "error") {
|
|
340
|
+
return {
|
|
341
|
+
message: null,
|
|
342
|
+
usage: finalUsage ?? usage,
|
|
343
|
+
reasoningTokens: finalReasoningTokens ?? reasoningTokens,
|
|
344
|
+
crashed: false,
|
|
345
|
+
error: midStreamError,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
message: { role: "assistant", content, tool_calls: finalizeToolCalls(pendingToolCalls) },
|
|
350
|
+
usage: finalUsage ?? usage,
|
|
351
|
+
reasoningTokens: finalReasoningTokens ?? reasoningTokens,
|
|
352
|
+
crashed: false,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Browser } from "playwright-core";
|
|
2
|
+
import type { PlaytestInstallEvent } from "./playtest.js";
|
|
3
|
+
export declare const INSTALL_START_LABEL = "Downloading playtest browser (one-time, ~250MB)\u2026";
|
|
4
|
+
export declare const INSTALL_WAIT_LABEL = "Waiting for browser download (shared)\u2026";
|
|
5
|
+
export interface PlaywrightChromiumLike {
|
|
6
|
+
executablePath(): string;
|
|
7
|
+
launch(options?: {
|
|
8
|
+
args?: string[];
|
|
9
|
+
}): Promise<Browser>;
|
|
10
|
+
}
|
|
11
|
+
export interface PlaywrightModuleLike {
|
|
12
|
+
chromium: PlaywrightChromiumLike;
|
|
13
|
+
}
|
|
14
|
+
export interface PlaytestBrowserSeams {
|
|
15
|
+
loadPlaywright?: () => Promise<PlaywrightModuleLike>;
|
|
16
|
+
runInstall?: (onLine: (line: string) => void) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export interface BrowserInstallHooks {
|
|
19
|
+
onProgress?: (label: string) => void;
|
|
20
|
+
onInstallEvent?: (evt: PlaytestInstallEvent) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface PlaytestBrowserManager {
|
|
23
|
+
withBrowser<T>(fn: (browser: Browser) => Promise<T>, hooks?: BrowserInstallHooks): Promise<{
|
|
24
|
+
ok: true;
|
|
25
|
+
value: T;
|
|
26
|
+
installedMs?: number;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
}>;
|
|
31
|
+
prewarm(): void;
|
|
32
|
+
shutdown(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare function createPlaytestBrowserManager(seams?: PlaytestBrowserSeams): PlaytestBrowserManager;
|