clauderipple 0.9.4 → 0.10.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/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
|
@@ -5,6 +5,14 @@ import crypto from "node:crypto";
|
|
|
5
5
|
import { clampEffort } from "../../compat.js";
|
|
6
6
|
import { conversationKey, estimateTokens, normalizeSchema, serverToolNames, systemWithDroppedToolNote, toolNameForResponses } from "../chatgpt/translate.js";
|
|
7
7
|
import { identityPrefix, instructionsSuffix } from "../../identity.js";
|
|
8
|
+
import { encodeEnvelope, envelopeFor, replayableReasoning } from "../../reasoning.js";
|
|
9
|
+
/** Counts a replay outcome, creating the tally the adapter reports from when it is the first. */
|
|
10
|
+
function bump(opts, kind, by = 1) {
|
|
11
|
+
if (!opts)
|
|
12
|
+
return;
|
|
13
|
+
const carried = opts.carried ?? (opts.carried = { replayed: 0, undecodable: 0 });
|
|
14
|
+
carried[kind] += by;
|
|
15
|
+
}
|
|
8
16
|
function textOf(content) {
|
|
9
17
|
if (typeof content === "string")
|
|
10
18
|
return content;
|
|
@@ -104,6 +112,14 @@ export function toChatMessages(req, opts) {
|
|
|
104
112
|
if (role === "assistant") {
|
|
105
113
|
const text = [];
|
|
106
114
|
const calls = [];
|
|
115
|
+
// The reasoning this turn came back with, rejoined into one string and sent as
|
|
116
|
+
// `reasoning_content` on the same assistant message — the shape DeepSeek's own example builds
|
|
117
|
+
// by appending the whole response message, and the only shape its API documents. The field
|
|
118
|
+
// name is the one the vendor sent it under, carried in the envelope: a model heard on
|
|
119
|
+
// `reasoning` is answered on `reasoning` (reasoning.ts).
|
|
120
|
+
const reasoning = [];
|
|
121
|
+
let field = "reasoning_content";
|
|
122
|
+
let carriedHere = 0;
|
|
107
123
|
for (const block of message.content) {
|
|
108
124
|
if (block.type === "text")
|
|
109
125
|
text.push(String(block.text ?? ""));
|
|
@@ -112,9 +128,31 @@ export function toChatMessages(req, opts) {
|
|
|
112
128
|
knownCalls.add(call.id);
|
|
113
129
|
calls.push({ id: call.id, type: "function", function: { name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) } });
|
|
114
130
|
}
|
|
131
|
+
const envelope = envelopeFor(block, "chat", opts?.env, (reason) => {
|
|
132
|
+
bump(opts, "undecodable");
|
|
133
|
+
if (opts?.replay !== false)
|
|
134
|
+
opts?.log?.(`dropped a reasoning carrier: ${reason}`);
|
|
135
|
+
});
|
|
136
|
+
if (!envelope || opts?.replay === false)
|
|
137
|
+
continue;
|
|
138
|
+
const held = block.thinking;
|
|
139
|
+
if (typeof held === "string" && held.length > 0)
|
|
140
|
+
reasoning.push(held);
|
|
141
|
+
field = envelope.f === "reasoning" ? "reasoning" : "reasoning_content";
|
|
142
|
+
carriedHere++;
|
|
143
|
+
}
|
|
144
|
+
if (carriedHere > 0)
|
|
145
|
+
bump(opts, "replayed", carriedHere);
|
|
146
|
+
// A turn whose only content was reasoning is still a turn the model had: it goes out with the
|
|
147
|
+
// reasoning alone rather than vanishing, which is what dropping thinking did before.
|
|
148
|
+
if (text.some(Boolean) || calls.length || reasoning.length) {
|
|
149
|
+
messages.push({
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: text.join("\n") || null,
|
|
152
|
+
...(calls.length ? { tool_calls: calls } : {}),
|
|
153
|
+
...(reasoning.length ? (field === "reasoning" ? { reasoning: reasoning.join("\n\n") } : { reasoning_content: reasoning.join("\n\n") }) : {}),
|
|
154
|
+
});
|
|
115
155
|
}
|
|
116
|
-
if (text.some(Boolean) || calls.length)
|
|
117
|
-
messages.push({ role: "assistant", content: text.join("\n") || null, ...(calls.length ? { tool_calls: calls } : {}) });
|
|
118
156
|
continue;
|
|
119
157
|
}
|
|
120
158
|
let parts = [];
|
|
@@ -125,13 +163,23 @@ export function toChatMessages(req, opts) {
|
|
|
125
163
|
messages.push({ role: "user", content: onlyText ? parts.map((part) => part.text).join("\n") : parts });
|
|
126
164
|
parts = [];
|
|
127
165
|
};
|
|
166
|
+
// A tool result's images (and the fallback text of an unknown call) wait until the run of tool
|
|
167
|
+
// results ends: every `tool` message must follow the assistant's tool_calls directly, and a user
|
|
168
|
+
// message between parallel results is refused (DeepSeek: "insufficient tool messages", #64).
|
|
169
|
+
let deferred = [];
|
|
170
|
+
const release = () => {
|
|
171
|
+
parts.push(...deferred);
|
|
172
|
+
deferred = [];
|
|
173
|
+
};
|
|
128
174
|
for (const block of message.content) {
|
|
129
175
|
if (block.type === "text") {
|
|
176
|
+
release();
|
|
130
177
|
const text = String(block.text ?? "");
|
|
131
178
|
if (text)
|
|
132
179
|
parts.push({ type: "text", text });
|
|
133
180
|
}
|
|
134
181
|
else if (block.type === "image") {
|
|
182
|
+
release();
|
|
135
183
|
const url = imageUrl(block);
|
|
136
184
|
if (url)
|
|
137
185
|
parts.push({ type: "image_url", image_url: { url } });
|
|
@@ -148,19 +196,27 @@ export function toChatMessages(req, opts) {
|
|
|
148
196
|
if (knownCalls.has(result.tool_use_id))
|
|
149
197
|
messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
|
|
150
198
|
else
|
|
151
|
-
|
|
199
|
+
deferred.push({ type: "text", text: `[Tool result]\n${output}` });
|
|
152
200
|
for (const url of images)
|
|
153
|
-
|
|
201
|
+
deferred.push({ type: "image_url", image_url: { url } });
|
|
154
202
|
}
|
|
155
203
|
}
|
|
204
|
+
release();
|
|
156
205
|
flush();
|
|
157
206
|
}
|
|
158
207
|
return messages;
|
|
159
208
|
}
|
|
160
209
|
/** Translate to the stateless OpenAI Responses input grammar. */
|
|
161
|
-
export function toResponsesInput(req) {
|
|
210
|
+
export function toResponsesInput(req, opts) {
|
|
162
211
|
const input = [];
|
|
163
212
|
const knownCalls = new Set();
|
|
213
|
+
let lastCallId;
|
|
214
|
+
// A translation with no options is a translation with nothing to say about reasoning: there is no
|
|
215
|
+
// provider to name in an envelope and no model to check it against, so nothing is carried either
|
|
216
|
+
// way. `opts` is only consulted when it is there.
|
|
217
|
+
const carried = (count) => {
|
|
218
|
+
bump(opts, count);
|
|
219
|
+
};
|
|
164
220
|
for (const message of req.messages) {
|
|
165
221
|
const role = message.role === "assistant" ? "assistant" : "user";
|
|
166
222
|
if (typeof message.content === "string") {
|
|
@@ -177,6 +233,27 @@ export function toResponsesInput(req) {
|
|
|
177
233
|
parts = [];
|
|
178
234
|
};
|
|
179
235
|
for (const block of message.content) {
|
|
236
|
+
// A reasoning item this conversation came back with, in the position it was replayed from —
|
|
237
|
+
// beside the function calls of that turn, and never for the other wire's carrier (reasoning.ts).
|
|
238
|
+
const envelope = envelopeFor(block, "responses", opts?.env, (reason) => {
|
|
239
|
+
carried("undecodable");
|
|
240
|
+
if (opts?.replay !== false)
|
|
241
|
+
opts?.log?.(`dropped a reasoning carrier: ${reason}`);
|
|
242
|
+
});
|
|
243
|
+
if (envelope) {
|
|
244
|
+
if (opts?.replay !== false) {
|
|
245
|
+
const item = replayableReasoning(envelope.r);
|
|
246
|
+
if (item) {
|
|
247
|
+
flush();
|
|
248
|
+
input.push(item);
|
|
249
|
+
carried("replayed");
|
|
250
|
+
opts?.onArrival?.(item, lastCallId);
|
|
251
|
+
}
|
|
252
|
+
else
|
|
253
|
+
carried("undecodable");
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
180
257
|
if (block.type === "text") {
|
|
181
258
|
const text = String(block.text ?? "");
|
|
182
259
|
if (text)
|
|
@@ -191,6 +268,7 @@ export function toResponsesInput(req) {
|
|
|
191
268
|
flush();
|
|
192
269
|
const call = block;
|
|
193
270
|
knownCalls.add(call.id);
|
|
271
|
+
lastCallId = call.id;
|
|
194
272
|
input.push({ type: "function_call", call_id: call.id, name: toolNameForResponses(call.name), arguments: typeof call.input === "string" ? call.input : JSON.stringify(call.input ?? {}) });
|
|
195
273
|
}
|
|
196
274
|
else if (block.type === "tool_result") {
|
|
@@ -243,11 +321,18 @@ export function toOpenAiRequest(req, opts) {
|
|
|
243
321
|
return out;
|
|
244
322
|
}
|
|
245
323
|
const responseTools = tools.map(({ function: fn }) => ({ type: "function", ...fn }));
|
|
324
|
+
const input = toResponsesInput(req, opts);
|
|
246
325
|
const out = {
|
|
247
326
|
model: opts.model,
|
|
248
|
-
input
|
|
327
|
+
input,
|
|
249
328
|
stream: true,
|
|
250
329
|
};
|
|
330
|
+
// A vendor marked `sealedReasoning` is asked for its reasoning on every turn, the way OpenCode's own
|
|
331
|
+
// client asks: asking only once a conversation already carried some would never start one on a
|
|
332
|
+
// vendor that seals nothing unasked. Anyone else is asked only when the conversation is already
|
|
333
|
+
// carrying its reasoning, so a request for a vendor never measured stays as it was.
|
|
334
|
+
if (opts.replay !== false && (opts.sealedReasoning || input.some((item) => item.type === "reasoning")))
|
|
335
|
+
out.include = ["reasoning.encrypted_content"];
|
|
251
336
|
const instructions = systemWithIdentity(systemWithDroppedToolNote(req), opts);
|
|
252
337
|
if (instructions)
|
|
253
338
|
out.instructions = instructions;
|
|
@@ -268,7 +353,7 @@ export function toOpenAiRequest(req, opts) {
|
|
|
268
353
|
if (typeof req.temperature === "number")
|
|
269
354
|
out.temperature = req.temperature;
|
|
270
355
|
if (effort)
|
|
271
|
-
out.reasoning = { effort };
|
|
356
|
+
out.reasoning = opts.sealedReasoning && opts.replay !== false ? { effort, summary: "auto" } : { effort };
|
|
272
357
|
return out;
|
|
273
358
|
}
|
|
274
359
|
export { conversationKey, estimateTokens };
|
|
@@ -277,26 +362,43 @@ export class OpenAiStreamMapper {
|
|
|
277
362
|
started = false;
|
|
278
363
|
finished = false;
|
|
279
364
|
blockIndex = -1;
|
|
365
|
+
/** `block` is the content entry this block writes into; `signature` seals a Responses item. */
|
|
280
366
|
open = null;
|
|
281
367
|
sawTool = false;
|
|
282
368
|
finishReason;
|
|
283
369
|
responseItems = [];
|
|
284
370
|
responseItemsById = new Map();
|
|
371
|
+
/** Reasoning items of a Responses stream, by item id, until the item that carries one is done. */
|
|
372
|
+
reasoningItems = new Map();
|
|
373
|
+
/** The item id of the most recent function_call, which is what a following reasoning describes. */
|
|
374
|
+
lastCallId;
|
|
375
|
+
/** The field a Chat vendor's reasoning arrived under; the same one it is replayed under. */
|
|
376
|
+
chatReasoningField = "reasoning_content";
|
|
285
377
|
messageId = `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
286
378
|
servedTier;
|
|
287
379
|
content = [];
|
|
288
380
|
usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
289
381
|
stopReason = "end_turn";
|
|
382
|
+
/** How many reasoning items this response handed over whole. The `reasoning=` figure in the log. */
|
|
383
|
+
reasoningCount = 0;
|
|
290
384
|
model;
|
|
291
385
|
startInput;
|
|
292
386
|
/** Mangled tool name → the name Claude Code knows, from `toolNameRestoreMap`. */
|
|
293
387
|
toolNames;
|
|
388
|
+
/** What goes in each envelope: the provider and model this stream is going to (reasoning.ts). */
|
|
389
|
+
env;
|
|
390
|
+
/**
|
|
391
|
+
* The wire this stream is on. It is fixed for the mapper's life — the adapter knows it before the
|
|
392
|
+
* first event. Decides what a closing block does: on Chat the block itself is the carrier.
|
|
393
|
+
*/
|
|
394
|
+
wire = "chat";
|
|
294
395
|
/** Applied to every error message before it is stored or sent. */
|
|
295
396
|
redactError = (message) => message;
|
|
296
|
-
constructor(model, startInput = 0, toolNames = new Map()) {
|
|
397
|
+
constructor(model, startInput = 0, toolNames = new Map(), env = { p: "", m: "" }) {
|
|
297
398
|
this.model = model;
|
|
298
399
|
this.startInput = startInput;
|
|
299
400
|
this.toolNames = toolNames;
|
|
401
|
+
this.env = { p: env.p, m: env.m || model };
|
|
300
402
|
}
|
|
301
403
|
get isFinished() { return this.finished; }
|
|
302
404
|
/**
|
|
@@ -315,17 +417,40 @@ export class OpenAiStreamMapper {
|
|
|
315
417
|
this.started = true;
|
|
316
418
|
return [{ event: "message_start", data: { type: "message_start", message: { id: this.messageId, type: "message", role: "assistant", model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: this.startInput, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } } } }];
|
|
317
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Close the block that is open, writing any envelope it carries as a `signature_delta` first — last
|
|
422
|
+
* in the block, after all of its text, the order Anthropic's own thinking blocks use.
|
|
423
|
+
*
|
|
424
|
+
* A Chat reasoning block is sealed here, because only now is its text whole; a Responses one was
|
|
425
|
+
* sealed by its item (see `sealReasoningItem`) before this is reached, so it never gets here
|
|
426
|
+
* unsigned.
|
|
427
|
+
*/
|
|
318
428
|
closeBlock() {
|
|
319
429
|
if (!this.open)
|
|
320
430
|
return [];
|
|
321
|
-
const index = this.open
|
|
431
|
+
const { index, kind, block } = this.open;
|
|
432
|
+
let signature = this.open.signature;
|
|
433
|
+
if (signature === undefined && kind === "thinking" && this.wire === "chat") {
|
|
434
|
+
signature = this.signatureForChat(block);
|
|
435
|
+
}
|
|
322
436
|
this.open = null;
|
|
323
|
-
|
|
437
|
+
const out = [];
|
|
438
|
+
if (signature !== undefined)
|
|
439
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index, delta: { type: "signature_delta", signature } } });
|
|
440
|
+
out.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
|
|
441
|
+
return out;
|
|
324
442
|
}
|
|
325
|
-
|
|
443
|
+
/**
|
|
444
|
+
* `block` is the caller's entry in `this.content` for this block, when it has one. It is held here
|
|
445
|
+
* rather than found as the last entry, because a Chat caller pushes the entry it is about to open
|
|
446
|
+
* *before* calling this — which would close the block against the wrong entry.
|
|
447
|
+
*/
|
|
448
|
+
openBlock(kind, contentBlock, toolIndex, block) {
|
|
326
449
|
const out = this.closeBlock();
|
|
327
450
|
const index = ++this.blockIndex;
|
|
328
|
-
|
|
451
|
+
if (block !== undefined)
|
|
452
|
+
this.content.push(block);
|
|
453
|
+
this.open = { kind, index, ...(toolIndex === undefined ? {} : { toolIndex }), ...(block === undefined ? {} : { block }) };
|
|
329
454
|
out.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: contentBlock } });
|
|
330
455
|
return out;
|
|
331
456
|
}
|
|
@@ -371,27 +496,34 @@ export class OpenAiStreamMapper {
|
|
|
371
496
|
for (const choice of choices) {
|
|
372
497
|
const delta = choice.delta ?? {};
|
|
373
498
|
// Thinking arrives before the answer, so it opens the first block of the turn. `reasoning` is
|
|
374
|
-
// accepted alongside `reasoning_content` because vendors on this wire disagree on the name
|
|
375
|
-
|
|
376
|
-
|
|
499
|
+
// accepted alongside `reasoning_content` because vendors on this wire disagree on the name;
|
|
500
|
+
// the name it arrived under is remembered, because the same name is what the reasoning has to
|
|
501
|
+
// be replayed under on the next turn (reasoning.ts).
|
|
502
|
+
const field = typeof delta.reasoning_content === "string" && delta.reasoning_content ? "reasoning_content"
|
|
503
|
+
: typeof delta.reasoning === "string" && delta.reasoning ? "reasoning" : null;
|
|
504
|
+
const reasoning = field === "reasoning_content" ? delta.reasoning_content : field === "reasoning" ? delta.reasoning : "";
|
|
505
|
+
if (field)
|
|
506
|
+
this.chatReasoningField = field;
|
|
377
507
|
if (reasoning) {
|
|
378
508
|
if (!this.open || this.open.kind !== "thinking") {
|
|
379
|
-
|
|
380
|
-
|
|
509
|
+
// A Chat model's reasoning has no sealed blob to resume from, so the carrier is the block
|
|
510
|
+
// itself: the block's text is the reasoning to replay, and its signature is the envelope
|
|
511
|
+
// naming the field to replay it under (reasoning.ts). It is sealed when the block closes,
|
|
512
|
+
// so the envelope is built once the whole string has arrived (see `signatureForChat`).
|
|
513
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }, undefined, { type: "thinking", thinking: "" }));
|
|
381
514
|
}
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
384
|
-
|
|
515
|
+
const block = this.open?.block;
|
|
516
|
+
if (block)
|
|
517
|
+
block.thinking += reasoning;
|
|
385
518
|
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning } } });
|
|
386
519
|
}
|
|
387
520
|
if (typeof delta.content === "string" && delta.content) {
|
|
388
521
|
if (!this.open || this.open.kind !== "text") {
|
|
389
|
-
|
|
390
|
-
out.push(...this.openBlock("text", { type: "text", text: "" }));
|
|
522
|
+
out.push(...this.openBlock("text", { type: "text", text: "" }, undefined, { type: "text", text: "" }));
|
|
391
523
|
}
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
394
|
-
|
|
524
|
+
const block = this.open?.block;
|
|
525
|
+
if (block)
|
|
526
|
+
block.text += delta.content;
|
|
395
527
|
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "text_delta", text: delta.content } } });
|
|
396
528
|
}
|
|
397
529
|
if (Array.isArray(delta.tool_calls)) {
|
|
@@ -417,6 +549,42 @@ export class OpenAiStreamMapper {
|
|
|
417
549
|
}
|
|
418
550
|
return out;
|
|
419
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Seal a closing thinking block of our own: a Chat block into the envelope that names the field to
|
|
554
|
+
* replay its text under (see `feedChat`).
|
|
555
|
+
*/
|
|
556
|
+
signatureForChat(block) {
|
|
557
|
+
if (this.wire !== "chat" || block?.type !== "thinking" || block.thinking.length === 0)
|
|
558
|
+
return undefined;
|
|
559
|
+
const signature = encodeEnvelope({ w: "chat", p: this.env.p, m: this.env.m, f: this.chatReasoningField });
|
|
560
|
+
block.signature = signature;
|
|
561
|
+
this.reasoningCount++;
|
|
562
|
+
return signature;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Put the finished Responses reasoning item into the envelope its block carries: the wire, this
|
|
566
|
+
* provider and model, and the item itself — type, id, summary and the backend's sealed blob,
|
|
567
|
+
* untouched (reasoning.ts). An item the backend did not seal has nothing to resume, so its block
|
|
568
|
+
* stays unsigned and only its summary survives, as display text.
|
|
569
|
+
*/
|
|
570
|
+
sealReasoningItem(item) {
|
|
571
|
+
const held = this.reasoningItems.get(item.id);
|
|
572
|
+
const block = item.block;
|
|
573
|
+
block.thinking = held?.text.trim() || block.thinking.trim() || (typeof held?.summary === "string" ? held.summary.trim() : "");
|
|
574
|
+
if (!held?.encrypted) {
|
|
575
|
+
// An item the backend did not seal has nothing to resume. The block stays unsigned, so the
|
|
576
|
+
// client treats it as display text and never replays it (reasoning.ts).
|
|
577
|
+
block.signature = "";
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const reasoning = { type: "reasoning", encrypted_content: held.encrypted };
|
|
581
|
+
if (item.id)
|
|
582
|
+
reasoning.id = item.id;
|
|
583
|
+
if (Array.isArray(held.summary))
|
|
584
|
+
reasoning.summary = held.summary;
|
|
585
|
+
block.signature = encodeEnvelope({ w: "responses", p: this.env.p, m: this.env.m, r: reasoning });
|
|
586
|
+
this.reasoningCount++;
|
|
587
|
+
}
|
|
420
588
|
responseItem(id, kind) {
|
|
421
589
|
if (typeof id === "string")
|
|
422
590
|
return this.responseItemsById.get(id);
|
|
@@ -452,6 +620,24 @@ export class OpenAiStreamMapper {
|
|
|
452
620
|
item.closed = true;
|
|
453
621
|
continue;
|
|
454
622
|
}
|
|
623
|
+
if (item.kind === "reasoning") {
|
|
624
|
+
// Emitted only once the item is done, because the block is handed over whole: the sealed
|
|
625
|
+
// reasoning is what goes in the envelope, and it may only arrive with the completed item.
|
|
626
|
+
// `sealReasoningItem` has already put the envelope on the block by then.
|
|
627
|
+
if (!item.done)
|
|
628
|
+
break;
|
|
629
|
+
const reasoning = item.block;
|
|
630
|
+
out.push(...this.openBlock("thinking", { type: "thinking", thinking: "" }));
|
|
631
|
+
if (reasoning.signature !== undefined)
|
|
632
|
+
this.open = { ...this.open, signature: reasoning.signature };
|
|
633
|
+
if (reasoning.thinking)
|
|
634
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: reasoning.thinking } } });
|
|
635
|
+
item.emitted = true;
|
|
636
|
+
item.emittedText = reasoning.thinking.length;
|
|
637
|
+
out.push(...this.closeBlock());
|
|
638
|
+
item.closed = true;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
455
641
|
const tool = item.block;
|
|
456
642
|
out.push(...this.openBlock("tool", { type: "tool_use", id: tool.id, name: tool.name, input: {} }, tool.index));
|
|
457
643
|
if (tool.args) {
|
|
@@ -480,12 +666,28 @@ export class OpenAiStreamMapper {
|
|
|
480
666
|
this.responseItemsById.set(id, responseItem);
|
|
481
667
|
}
|
|
482
668
|
else if (id && item?.type === "function_call") {
|
|
669
|
+
this.lastCallId = item.call_id === undefined || item.call_id === null ? id : String(item.call_id);
|
|
483
670
|
const index = this.content.filter((block) => block.type === "tool_use").length;
|
|
484
671
|
const tool = this.toolFor(index, { id: item.call_id ?? item.id, function: { name: item.name } });
|
|
485
672
|
const responseItem = { id, kind: "tool", block: tool, done: false, emitted: false, closed: false, emittedText: 0 };
|
|
486
673
|
this.responseItems.push(responseItem);
|
|
487
674
|
this.responseItemsById.set(id, responseItem);
|
|
488
675
|
}
|
|
676
|
+
else if (id && item?.type === "reasoning") {
|
|
677
|
+
// The item's sealed reasoning and summary, held until the item is done: the block handed over
|
|
678
|
+
// carries both, and a half-assembled one must not reach the client (reasoning.ts).
|
|
679
|
+
const reasoning = {
|
|
680
|
+
...(typeof item.encrypted_content === "string" && item.encrypted_content ? { encrypted: item.encrypted_content } : {}),
|
|
681
|
+
summary: item.summary,
|
|
682
|
+
text: "",
|
|
683
|
+
};
|
|
684
|
+
this.reasoningItems.set(id, reasoning);
|
|
685
|
+
const block = { type: "thinking", thinking: "" };
|
|
686
|
+
this.content.push(block);
|
|
687
|
+
const responseItem = { id, kind: "reasoning", block, done: false, emitted: false, closed: false, emittedText: 0 };
|
|
688
|
+
this.responseItems.push(responseItem);
|
|
689
|
+
this.responseItemsById.set(id, responseItem);
|
|
690
|
+
}
|
|
489
691
|
}
|
|
490
692
|
else if (type === "response.output_text.delta") {
|
|
491
693
|
const text = typeof ev.delta === "string" ? ev.delta : "";
|
|
@@ -511,10 +713,40 @@ export class OpenAiStreamMapper {
|
|
|
511
713
|
}
|
|
512
714
|
}
|
|
513
715
|
}
|
|
716
|
+
else if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") {
|
|
717
|
+
// The summary, shown as the thinking block's text. When there is none — which is the usual
|
|
718
|
+
// shape — nothing arrives and the block stays empty, exactly as the item was measured.
|
|
719
|
+
const text = typeof ev.delta === "string" ? ev.delta : "";
|
|
720
|
+
const item = this.responseItem(ev.item_id, "reasoning");
|
|
721
|
+
if (item?.kind === "reasoning" && text) {
|
|
722
|
+
const block = item.block;
|
|
723
|
+
block.thinking += text;
|
|
724
|
+
if (this.reasoningItems.get(item.id))
|
|
725
|
+
this.reasoningItems.get(item.id).text += text;
|
|
726
|
+
if (item.emitted && this.open?.kind === "thinking") {
|
|
727
|
+
out.push({ event: "content_block_delta", data: { type: "content_block_delta", index: this.open.index, delta: { type: "thinking_delta", thinking: text } } });
|
|
728
|
+
item.emittedText += text.length;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
514
732
|
else if (type === "response.output_item.done") {
|
|
515
|
-
const
|
|
516
|
-
|
|
733
|
+
const done = ev.item;
|
|
734
|
+
const item = this.responseItem(ev.item_id ?? done?.id);
|
|
735
|
+
if (done?.encrypted_content !== undefined || done?.summary !== undefined) {
|
|
736
|
+
// The backend may seal the reasoning only when the item completes; take it from there too.
|
|
737
|
+
const held = this.reasoningItems.get(typeof (ev.item_id ?? done?.id) === "string" ? String(ev.item_id ?? done?.id) : "");
|
|
738
|
+
if (held) {
|
|
739
|
+
if (typeof done.encrypted_content === "string" && done.encrypted_content)
|
|
740
|
+
held.encrypted = done.encrypted_content;
|
|
741
|
+
if (done.summary !== undefined)
|
|
742
|
+
held.summary = done.summary;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (item) {
|
|
517
746
|
item.done = true;
|
|
747
|
+
if (item.kind === "reasoning")
|
|
748
|
+
this.sealReasoningItem(item);
|
|
749
|
+
}
|
|
518
750
|
}
|
|
519
751
|
else if (type === "response.completed" || type === "response.incomplete") {
|
|
520
752
|
const response = ev.response;
|
|
@@ -535,6 +767,7 @@ export class OpenAiStreamMapper {
|
|
|
535
767
|
feed(ev, wire) {
|
|
536
768
|
if (this.finished)
|
|
537
769
|
return [];
|
|
770
|
+
this.wire = wire;
|
|
538
771
|
// Some OpenAI-compatible servers emit an ordinary `{error:{...}}` JSON SSE payload
|
|
539
772
|
// instead of the Responses `error` event shape. Do not finish it as a successful answer.
|
|
540
773
|
if (ev.error && typeof ev.error === "object") {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// How much of an OpenCode Go subscription is used, for the status bar's limits row.
|
|
2
|
+
//
|
|
3
|
+
// One request per provider: GET {provider.url}/usage (measured 2026-10-15: https://opencode.ai/zen/go/v1/usage)
|
|
4
|
+
// with the provider's own config headers (the bearer key) and the same default User-Agent Node's
|
|
5
|
+
// fetch sends elsewhere — Cloudflare answers 403 `error 1010` to a request carrying no User-Agent
|
|
6
|
+
// and no browser fingerprint, but accepts Node's fetch as it is. Source of the route (behavioral
|
|
7
|
+
// only, no code copied): anomalyco/opencode packages/console/app/src/routes/zen/go/v1/usage.ts and
|
|
8
|
+
// lib/inference-proxy.ts (the `GET /zen/go/v1/usage` → `/go/v1/usage` mapping).
|
|
9
|
+
//
|
|
10
|
+
// Measured answer:
|
|
11
|
+
// {"usage":{"rolling":{"status":"ok","percent":3,"resetsAt":"2026-10-15T05:39:48Z"},
|
|
12
|
+
// "weekly":{"status":"ok","percent":52,"resetsAt":"2026-10-19T00:00:00Z"},
|
|
13
|
+
// "monthly":{"status":"ok","percent":92,"resetsAt":"2026-10-18T05:43:30Z"}}}
|
|
14
|
+
// `rolling` is the five-hour window, `weekly` the week, `monthly` roughly 30 days.
|
|
15
|
+
/** The five-hour, weekly and ~monthly window lengths in minutes. */
|
|
16
|
+
const FIVE_HOURS = 5 * 60;
|
|
17
|
+
const WEEK = 7 * 24 * 60;
|
|
18
|
+
const MONTH = 30 * 24 * 60;
|
|
19
|
+
/** The window lengths and read order of the endpoint's three keys. */
|
|
20
|
+
const KEYS = [
|
|
21
|
+
{ key: "rolling", kind: "five_hour", window_minutes: FIVE_HOURS },
|
|
22
|
+
{ key: "weekly", kind: "seven_day", window_minutes: WEEK },
|
|
23
|
+
{ key: "monthly", kind: "window", window_minutes: MONTH },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Parse the `/usage` body into windows. A window missing a finite percent is dropped; null when the
|
|
27
|
+
* body carries no `usage` object at all (so the caller keeps its last good answer instead of
|
|
28
|
+
* blanking).
|
|
29
|
+
*/
|
|
30
|
+
export function opencodeWindowsFromBody(body) {
|
|
31
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
32
|
+
return null;
|
|
33
|
+
const usage = body.usage;
|
|
34
|
+
if (!usage || typeof usage !== "object" || Array.isArray(usage))
|
|
35
|
+
return null;
|
|
36
|
+
const source = usage;
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const { key, kind, window_minutes } of KEYS) {
|
|
39
|
+
const w = source[key];
|
|
40
|
+
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
41
|
+
continue;
|
|
42
|
+
const win = w;
|
|
43
|
+
if (typeof win.percent !== "number" || !Number.isFinite(win.percent))
|
|
44
|
+
continue;
|
|
45
|
+
const at = typeof win.resetsAt === "string" ? Date.parse(win.resetsAt) : NaN;
|
|
46
|
+
out.push({ kind, used_percent: win.percent, window_minutes, ...(Number.isFinite(at) ? { reset_at: Math.round(at / 1000) } : {}) });
|
|
47
|
+
}
|
|
48
|
+
return out.length ? out : null;
|
|
49
|
+
}
|
|
50
|
+
/** Whether a provider is an OpenCode Go subscription: its preset, or an OpenCode Go endpoint. */
|
|
51
|
+
export function isOpenCodeGo(p) {
|
|
52
|
+
if (p.type === "openai-compatible" && p.preset === "opencode-go")
|
|
53
|
+
return true;
|
|
54
|
+
if (typeof p.url !== "string")
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(p.url);
|
|
58
|
+
return url.hostname === "opencode.ai" && /^\/zen\/go\/v1(?:\/|$)/.test(url.pathname);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Per-provider usage windows, fetched at most once per `ttlMs` per provider. A failed lookup keeps
|
|
66
|
+
* the last good windows rather than blanking them, and only the status code is logged.
|
|
67
|
+
*/
|
|
68
|
+
export class OpenCodeUsage {
|
|
69
|
+
last = new Map();
|
|
70
|
+
at = new Map();
|
|
71
|
+
inFlight = new Map();
|
|
72
|
+
opts;
|
|
73
|
+
constructor(opts) {
|
|
74
|
+
this.opts = opts;
|
|
75
|
+
}
|
|
76
|
+
/** The provider's windows, cached for `ttlMs`; the last good ones survive a failed lookup. */
|
|
77
|
+
windows(name, url, headers) {
|
|
78
|
+
const now = (this.opts.now ?? Date.now)();
|
|
79
|
+
const ttl = this.opts.ttlMs ?? 5 * 60_000;
|
|
80
|
+
if (now - (this.at.get(name) ?? Number.NEGATIVE_INFINITY) < ttl)
|
|
81
|
+
return Promise.resolve(this.last.get(name) ?? []);
|
|
82
|
+
const running = this.inFlight.get(name);
|
|
83
|
+
if (running)
|
|
84
|
+
return running;
|
|
85
|
+
const task = this.fetchOne(name, url, headers).finally(() => {
|
|
86
|
+
this.at.set(name, (this.opts.now ?? Date.now)());
|
|
87
|
+
this.inFlight.delete(name);
|
|
88
|
+
});
|
|
89
|
+
this.inFlight.set(name, task);
|
|
90
|
+
return task;
|
|
91
|
+
}
|
|
92
|
+
async fetchOne(name, url, headers) {
|
|
93
|
+
const keep = () => this.last.get(name) ?? [];
|
|
94
|
+
try {
|
|
95
|
+
const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/usage`, {
|
|
96
|
+
method: "GET",
|
|
97
|
+
headers: { accept: "application/json", ...headers },
|
|
98
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
// The status alone: the body of an auth failure is not worth the risk of echoing.
|
|
102
|
+
this.opts.warn?.(`opencode usage ${name}: HTTP ${res.status}`);
|
|
103
|
+
return keep();
|
|
104
|
+
}
|
|
105
|
+
const windows = opencodeWindowsFromBody(await res.json());
|
|
106
|
+
if (!windows)
|
|
107
|
+
return keep();
|
|
108
|
+
this.last.set(name, windows);
|
|
109
|
+
return windows;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
this.opts.warn?.(`opencode usage ${name}: ${e.message}`);
|
|
113
|
+
return keep();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|