@giovannijecha/jecode 0.8.2 → 0.8.4
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/README.md +22 -280
- package/assets/wordmark-steel.svg +3 -0
- package/dist/accounts.js +17 -13
- package/dist/batch.js +66 -6
- package/dist/config.js +6 -3
- package/dist/context/budget.js +13 -1
- package/dist/context/compactor.js +5 -4
- package/dist/context/estimate.js +43 -1
- package/dist/context/manual.js +8 -4
- package/dist/context/policy.js +68 -18
- package/dist/controller-request.js +8 -8
- package/dist/controller.js +6 -1
- package/dist/conversation.js +94 -33
- package/dist/credential-safety.js +56 -9
- package/dist/credentials.js +32 -4
- package/dist/input-boundary.js +80 -0
- package/dist/main.js +4 -1
- package/dist/openai-oauth-callback.js +1 -1
- package/dist/process-shutdown.js +52 -0
- package/dist/provider-commands.js +43 -6
- package/dist/provider-errors.js +59 -1
- package/dist/providers/anthropic-stream.js +24 -20
- package/dist/providers/anthropic-wire.js +7 -2
- package/dist/providers/http.js +4 -34
- package/dist/providers/ollama-wire.js +7 -15
- package/dist/providers/ollama.js +1 -0
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai-stream.js +43 -7
- package/dist/providers/openai-wire.js +2 -16
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +45 -17
- package/dist/providers/tool-input.js +17 -0
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/codec.js +2 -1
- package/dist/sessions/lease.js +7 -0
- package/dist/sessions/runtime.js +11 -3
- package/dist/sessions/store.js +171 -78
- package/dist/settings.js +10 -5
- package/dist/start.js +12 -2
- package/dist/text-boundary.js +2 -0
- package/dist/tools/search.js +27 -18
- package/dist/tui/app-input.js +40 -5
- package/dist/tui/app-state.js +1 -0
- package/dist/tui/app-workflows.js +1 -0
- package/dist/tui/app.js +11 -3
- package/dist/tui/editor.js +2 -0
- package/dist/tui/keys.js +64 -5
- package/dist/tui/overlay.js +12 -4
- package/dist/tui/picker.js +2 -0
- package/dist/tui/screen.js +5 -17
- package/dist/user-store.js +54 -0
- package/package.json +7 -3
- /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
package/dist/credentials.js
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
// secret in the working tree is one `git add -A` from being published, which
|
|
11
11
|
// is why "not in the repo" is a rule and not a preference.
|
|
12
12
|
import { chmod, mkdir } from "node:fs/promises";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
13
|
import * as path from "node:path";
|
|
15
14
|
import { atomicWrite } from "./atomic.js";
|
|
16
15
|
import { withStoreLock } from "./store-lock.js";
|
|
17
16
|
import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
|
|
17
|
+
import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
|
|
18
18
|
/** Keys this session was given but not asked to keep. Dies with the window. */
|
|
19
19
|
const held = new Map();
|
|
20
20
|
/** The saved file, read once. `undefined` until the first look at it. */
|
|
@@ -58,6 +58,10 @@ export function hasSaved(name) {
|
|
|
58
58
|
}
|
|
59
59
|
/** Take a key for this session only. Nothing is written anywhere. */
|
|
60
60
|
export function hold(name, value) {
|
|
61
|
+
assertCredential(name, value);
|
|
62
|
+
if (!held.has(name) && held.size >= USER_STORE_LIMITS.credentialEntries) {
|
|
63
|
+
throw new Error("too many session credentials");
|
|
64
|
+
}
|
|
61
65
|
held.set(name, value);
|
|
62
66
|
}
|
|
63
67
|
/** Remove only the value held by this process. Saved and environment values remain. */
|
|
@@ -73,10 +77,14 @@ export function forgetSession(name) {
|
|
|
73
77
|
* Windows ignores the mode and relies on the profile directory's own ACL.
|
|
74
78
|
*/
|
|
75
79
|
export async function keep(name, value) {
|
|
80
|
+
assertCredential(name, value);
|
|
76
81
|
const file = storePath();
|
|
77
82
|
await prepare(file);
|
|
78
83
|
return withStoreLock(file, async () => {
|
|
79
84
|
const all = { ...readSavedStore(), [name]: value };
|
|
85
|
+
if (Object.keys(all).length > USER_STORE_LIMITS.credentialEntries) {
|
|
86
|
+
throw new Error("too many saved credentials");
|
|
87
|
+
}
|
|
80
88
|
await persist(file, all);
|
|
81
89
|
saved = all;
|
|
82
90
|
// A newly saved replacement must become active immediately. Otherwise an
|
|
@@ -131,9 +139,14 @@ function readSavedStore() {
|
|
|
131
139
|
}
|
|
132
140
|
function readStore(file) {
|
|
133
141
|
try {
|
|
134
|
-
const parsed =
|
|
142
|
+
const parsed = readBoundedJsonSync(file, USER_STORE_LIMITS.credentialsBytes);
|
|
143
|
+
if (!record(parsed))
|
|
144
|
+
return {};
|
|
135
145
|
// Anything that is not a string is not a key, whatever the file says.
|
|
136
|
-
|
|
146
|
+
const entries = Object.entries(parsed);
|
|
147
|
+
if (entries.length > USER_STORE_LIMITS.credentialEntries)
|
|
148
|
+
return {};
|
|
149
|
+
return Object.fromEntries(entries.filter((entry) => credential(entry[0], entry[1])));
|
|
137
150
|
}
|
|
138
151
|
catch (error) {
|
|
139
152
|
// Only a missing canonical file falls through to the legacy location. A
|
|
@@ -148,7 +161,22 @@ async function prepare(file) {
|
|
|
148
161
|
await chmod(directory, 0o700);
|
|
149
162
|
}
|
|
150
163
|
async function persist(file, values) {
|
|
151
|
-
|
|
164
|
+
const text = `${JSON.stringify(values, null, 2)}\n`;
|
|
165
|
+
assertStoreText(text, USER_STORE_LIMITS.credentialsBytes);
|
|
166
|
+
await atomicWrite(file, text, { mode: 0o600 });
|
|
167
|
+
}
|
|
168
|
+
function assertCredential(name, value) {
|
|
169
|
+
if (!credential(name, value))
|
|
170
|
+
throw new Error("invalid credential name or value");
|
|
171
|
+
}
|
|
172
|
+
function credential(name, value) {
|
|
173
|
+
return name.length > 0 && name.length <= USER_STORE_LIMITS.credentialName &&
|
|
174
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
175
|
+
typeof value === "string" && value.length > 0 &&
|
|
176
|
+
value.length <= USER_STORE_LIMITS.credentialValue;
|
|
177
|
+
}
|
|
178
|
+
function record(value) {
|
|
179
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
152
180
|
}
|
|
153
181
|
/** An empty variable is an unset variable — an exported "" is not a key. */
|
|
154
182
|
function use(value) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// One bounded ingress for text that can become a user prompt.
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
|
|
5
|
+
export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
|
|
6
|
+
export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
|
|
7
|
+
export class PromptLimitError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(PROMPT_LIMIT_MESSAGE);
|
|
10
|
+
this.name = "PromptLimitError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function assertPromptLength(length) {
|
|
14
|
+
if (length > MAX_PROMPT_CODE_UNITS)
|
|
15
|
+
throw new PromptLimitError();
|
|
16
|
+
}
|
|
17
|
+
export function assertPromptAppend(current, added) {
|
|
18
|
+
if (added > MAX_PROMPT_CODE_UNITS - current)
|
|
19
|
+
throw new PromptLimitError();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Split raw UTF-8 input without letting one unterminated line grow past the
|
|
23
|
+
* prompt boundary. Newline, CRLF, and a final line without a newline match the
|
|
24
|
+
* line semantics used by batch mode.
|
|
25
|
+
*/
|
|
26
|
+
export async function* boundedInputLines(source) {
|
|
27
|
+
const decoder = new StringDecoder("utf8");
|
|
28
|
+
let line = "";
|
|
29
|
+
let pendingCr = false;
|
|
30
|
+
const append = (text, from, to) => {
|
|
31
|
+
const length = to - from;
|
|
32
|
+
assertPromptAppend(line.length, length);
|
|
33
|
+
if (length > 0)
|
|
34
|
+
line += text.slice(from, to);
|
|
35
|
+
};
|
|
36
|
+
const consume = function* (text) {
|
|
37
|
+
if (text === "")
|
|
38
|
+
return;
|
|
39
|
+
let from = 0;
|
|
40
|
+
if (pendingCr) {
|
|
41
|
+
if (text.startsWith("\n"))
|
|
42
|
+
from = 1;
|
|
43
|
+
pendingCr = false;
|
|
44
|
+
yield line;
|
|
45
|
+
line = "";
|
|
46
|
+
}
|
|
47
|
+
while (from < text.length) {
|
|
48
|
+
const cr = text.indexOf("\r", from);
|
|
49
|
+
const lf = text.indexOf("\n", from);
|
|
50
|
+
const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
|
|
51
|
+
if (newline === -1) {
|
|
52
|
+
append(text, from, text.length);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
append(text, from, newline);
|
|
56
|
+
if (text[newline] === "\r" && newline + 1 === text.length) {
|
|
57
|
+
pendingCr = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
yield line;
|
|
61
|
+
line = "";
|
|
62
|
+
from = text[newline] === "\r" && text[newline + 1] === "\n"
|
|
63
|
+
? newline + 2
|
|
64
|
+
: newline + 1;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for await (const chunk of source) {
|
|
68
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
|
|
69
|
+
yield* consume(text);
|
|
70
|
+
}
|
|
71
|
+
const tail = decoder.end();
|
|
72
|
+
if (tail !== "")
|
|
73
|
+
yield* consume(tail);
|
|
74
|
+
if (pendingCr) {
|
|
75
|
+
yield line;
|
|
76
|
+
line = "";
|
|
77
|
+
}
|
|
78
|
+
if (line !== "")
|
|
79
|
+
yield line;
|
|
80
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Process entry point. The testable bootstrap lives in start.ts.
|
|
2
2
|
import { start } from "./start.js";
|
|
3
|
+
import { isProcessSignalError, withProcessShutdown } from "./process-shutdown.js";
|
|
3
4
|
import { terminalText } from "./ui/terminal-text.js";
|
|
4
|
-
start().catch((error) => {
|
|
5
|
+
withProcessShutdown((signal) => start(process.argv.slice(2), { signal })).catch((error) => {
|
|
6
|
+
if (isProcessSignalError(error))
|
|
7
|
+
return;
|
|
5
8
|
process.stderr.write(`jecode: ${terminalText(error.message)}\n`);
|
|
6
9
|
process.exitCode = 1;
|
|
7
10
|
});
|
|
@@ -173,7 +173,7 @@ function resultPage(success) {
|
|
|
173
173
|
let mascot;
|
|
174
174
|
function mascotDataUri() {
|
|
175
175
|
if (mascot === undefined) {
|
|
176
|
-
const file = new URL("../
|
|
176
|
+
const file = new URL("../assets/jeco-256.png", import.meta.url);
|
|
177
177
|
mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
|
|
178
178
|
}
|
|
179
179
|
return mascot;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// One process-wide cancellation boundary for operating-system signals.
|
|
2
|
+
const SHUTDOWN_GRACE_MS = 2_000;
|
|
3
|
+
const SIGNALS = [
|
|
4
|
+
["SIGINT", 2],
|
|
5
|
+
["SIGHUP", 1],
|
|
6
|
+
["SIGTERM", 15],
|
|
7
|
+
];
|
|
8
|
+
export class ProcessSignalError extends Error {
|
|
9
|
+
signal;
|
|
10
|
+
exitCode;
|
|
11
|
+
constructor(signal, exitCode) {
|
|
12
|
+
super(`received ${signal}`);
|
|
13
|
+
this.name = "ProcessSignalError";
|
|
14
|
+
this.signal = signal;
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isProcessSignalError(error) {
|
|
19
|
+
return error instanceof ProcessSignalError;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Abort foreground work on the first fatal signal and reserve a bounded hard
|
|
23
|
+
* exit for work that ignores cancellation. A second signal exits immediately.
|
|
24
|
+
*/
|
|
25
|
+
export async function withProcessShutdown(work) {
|
|
26
|
+
const control = new AbortController();
|
|
27
|
+
let exitCode;
|
|
28
|
+
let forceTimer;
|
|
29
|
+
const listeners = [];
|
|
30
|
+
for (const [name, number] of SIGNALS) {
|
|
31
|
+
const listener = () => {
|
|
32
|
+
if (control.signal.aborted) {
|
|
33
|
+
process.exit(exitCode ?? 128 + number);
|
|
34
|
+
}
|
|
35
|
+
exitCode = 128 + number;
|
|
36
|
+
process.exitCode = exitCode;
|
|
37
|
+
control.abort(new ProcessSignalError(name, exitCode));
|
|
38
|
+
forceTimer = setTimeout(() => process.exit(exitCode), SHUTDOWN_GRACE_MS);
|
|
39
|
+
};
|
|
40
|
+
listeners.push([name, listener]);
|
|
41
|
+
process.on(name, listener);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await work(control.signal);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (forceTimer !== undefined)
|
|
48
|
+
clearTimeout(forceTimer);
|
|
49
|
+
for (const [name, listener] of listeners)
|
|
50
|
+
process.off(name, listener);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -15,28 +15,65 @@ export async function providersCommand(session, host) {
|
|
|
15
15
|
const choose = chooser(host);
|
|
16
16
|
if (choose === undefined)
|
|
17
17
|
return;
|
|
18
|
-
|
|
18
|
+
const groups = providerGroups();
|
|
19
|
+
let selected = Math.max(0, groups.findIndex((group) => group.providers.some((provider) => provider.id === session.provider.id)));
|
|
19
20
|
while (true) {
|
|
20
21
|
const index = await choose({
|
|
21
22
|
title: [],
|
|
22
|
-
options:
|
|
23
|
+
options: groups.map((group) => ({
|
|
24
|
+
label: group.label,
|
|
25
|
+
value: providerCount(group.providers.length),
|
|
26
|
+
})),
|
|
27
|
+
index: selected,
|
|
28
|
+
});
|
|
29
|
+
if (index === undefined) {
|
|
30
|
+
throwIfAborted(host.signal);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const group = groups[index];
|
|
34
|
+
if (group === undefined)
|
|
35
|
+
return;
|
|
36
|
+
selected = index;
|
|
37
|
+
await providerGroupCommand(group, session, host);
|
|
38
|
+
throwIfAborted(host.signal);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function providerGroups() {
|
|
42
|
+
return [
|
|
43
|
+
{ label: "Account", providers: PROVIDERS.filter((provider) => provider.auth.kind === "oauth") },
|
|
44
|
+
{ label: "API", providers: PROVIDERS.filter((provider) => provider.auth.kind !== "oauth") },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
async function providerGroupCommand(group, session, host) {
|
|
48
|
+
if (host.choose === undefined)
|
|
49
|
+
return;
|
|
50
|
+
let selected = Math.max(0, group.providers.findIndex((provider) => provider.id === session.provider.id));
|
|
51
|
+
while (true) {
|
|
52
|
+
const index = await host.choose({
|
|
53
|
+
title: heading(group.label, "provider access", session.palette),
|
|
54
|
+
options: group.providers.map((provider) => ({
|
|
23
55
|
label: providerLabel(provider.id),
|
|
24
56
|
value: providerAccessHint(provider),
|
|
25
57
|
})),
|
|
26
58
|
index: selected,
|
|
27
59
|
});
|
|
28
|
-
if (index === undefined)
|
|
60
|
+
if (index === undefined) {
|
|
61
|
+
throwIfAborted(host.signal);
|
|
29
62
|
return;
|
|
30
|
-
|
|
63
|
+
}
|
|
64
|
+
const provider = group.providers[index];
|
|
31
65
|
if (provider === undefined)
|
|
32
66
|
return;
|
|
33
67
|
selected = index;
|
|
34
68
|
await manageProvider(provider, session, host);
|
|
35
|
-
// Esc closes only the
|
|
36
|
-
//
|
|
69
|
+
// Esc closes only the provider-specific flow. Ctrl+C also settles that
|
|
70
|
+
// interaction, but aborts the command signal and must not reopen a menu.
|
|
37
71
|
throwIfAborted(host.signal);
|
|
38
72
|
}
|
|
39
73
|
}
|
|
74
|
+
function providerCount(count) {
|
|
75
|
+
return `${count} ${count === 1 ? "provider" : "providers"}`;
|
|
76
|
+
}
|
|
40
77
|
export function providerAccessHint(provider) {
|
|
41
78
|
if (provider.id === "ollama") {
|
|
42
79
|
const connection = ollamaConnection();
|
package/dist/provider-errors.js
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
1
|
// Actionable provider failures for user-facing command and turn surfaces.
|
|
2
|
+
import { redactCredentials } from "./credential-safety.js";
|
|
2
3
|
import { providerLabel } from "./provider-label.js";
|
|
4
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
|
+
import { terminalText } from "./ui/terminal-text.js";
|
|
3
6
|
const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
|
|
7
|
+
const MAX_MESSAGE_CHARS = 1_000;
|
|
8
|
+
const MAX_REASON_CHARS = 500;
|
|
9
|
+
const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
|
|
4
10
|
export function providerFailure(provider, error, labelProvider = false) {
|
|
5
11
|
if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
|
|
6
12
|
return provider.location?.() === "local"
|
|
7
13
|
? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
|
|
8
14
|
: "Ollama is not reachable · check its connection in /providers";
|
|
9
15
|
}
|
|
10
|
-
|
|
16
|
+
const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
|
|
17
|
+
const reason = providerReason(error);
|
|
18
|
+
const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
|
|
19
|
+
? ` · ${reason}`
|
|
20
|
+
: "";
|
|
21
|
+
const failure = `${message}${detail}`;
|
|
22
|
+
return labelProvider ? `${providerLabel(provider.id)}: ${failure}` : failure;
|
|
23
|
+
}
|
|
24
|
+
function providerReason(error) {
|
|
25
|
+
const body = error.body;
|
|
26
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
27
|
+
return undefined;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(body);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
const raw = body.trim();
|
|
34
|
+
if (raw.startsWith("{") || raw.startsWith("[") || HTML.test(raw))
|
|
35
|
+
return undefined;
|
|
36
|
+
return safeReason(raw);
|
|
37
|
+
}
|
|
38
|
+
for (const candidate of reasonCandidates(parsed)) {
|
|
39
|
+
const reason = safeReason(candidate);
|
|
40
|
+
if (reason !== undefined)
|
|
41
|
+
return reason;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function reasonCandidates(value) {
|
|
46
|
+
if (typeof value === "string")
|
|
47
|
+
return [value];
|
|
48
|
+
if (!record(value))
|
|
49
|
+
return [];
|
|
50
|
+
const error = value["error"];
|
|
51
|
+
return [
|
|
52
|
+
record(error) ? error["message"] : undefined,
|
|
53
|
+
typeof error === "string" ? error : undefined,
|
|
54
|
+
value["message"],
|
|
55
|
+
value["detail"],
|
|
56
|
+
].filter((candidate) => typeof candidate === "string");
|
|
57
|
+
}
|
|
58
|
+
function safeReason(text) {
|
|
59
|
+
if (HTML.test(text))
|
|
60
|
+
return undefined;
|
|
61
|
+
return safeText(text, MAX_REASON_CHARS) || undefined;
|
|
62
|
+
}
|
|
63
|
+
function safeText(text, max) {
|
|
64
|
+
const redacted = redactCredentials(text).trim().replace(/\s+/gu, " ");
|
|
65
|
+
return leadingText(terminalText(redacted), max);
|
|
66
|
+
}
|
|
67
|
+
function record(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
69
|
}
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// beyond display: thinking blocks carry a signature and must be echoed back
|
|
7
7
|
// byte-for-byte on the next request.
|
|
8
8
|
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
9
|
+
import { toolInputFromJson } from "./tool-input.js";
|
|
9
10
|
export async function assembleAnthropic(events, onStream) {
|
|
10
11
|
const blocks = new Map();
|
|
11
12
|
const partialJson = new Map();
|
|
12
13
|
const announcedTools = new Set();
|
|
13
14
|
const sizes = { toolArguments: 0 };
|
|
15
|
+
const toolInputErrors = {};
|
|
14
16
|
let stopReason;
|
|
15
17
|
let stopDetails;
|
|
16
18
|
let usage;
|
|
@@ -46,7 +48,10 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
46
48
|
const pending = partialJson.get(event.index);
|
|
47
49
|
const block = blocks.get(event.index);
|
|
48
50
|
if (pending !== undefined && block !== undefined) {
|
|
49
|
-
|
|
51
|
+
const parsed = toolInputFromJson(pending);
|
|
52
|
+
block.input = parsed.input;
|
|
53
|
+
if (parsed.inputError !== undefined)
|
|
54
|
+
toolInputErrors[event.index] = parsed.inputError;
|
|
50
55
|
partialJson.delete(event.index);
|
|
51
56
|
}
|
|
52
57
|
break;
|
|
@@ -69,10 +74,24 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
69
74
|
}
|
|
70
75
|
if (!complete)
|
|
71
76
|
throw new Error("anthropic stream ended before message_stop");
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const ordered = [...blocks.entries()].sort(([a], [b]) => a - b);
|
|
78
|
+
const content = ordered.map(([, block]) => block);
|
|
79
|
+
const orderedInputErrors = {};
|
|
80
|
+
for (let index = 0; index < ordered.length; index++) {
|
|
81
|
+
const sourceIndex = ordered[index]?.[0];
|
|
82
|
+
if (sourceIndex === undefined || toolInputErrors[sourceIndex] === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
orderedInputErrors[index] = toolInputErrors[sourceIndex];
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
content,
|
|
88
|
+
stop_reason: stopReason,
|
|
89
|
+
stop_details: stopDetails,
|
|
90
|
+
usage,
|
|
91
|
+
...(Object.keys(orderedInputErrors).length === 0
|
|
92
|
+
? {}
|
|
93
|
+
: { toolInputErrors: orderedInputErrors }),
|
|
94
|
+
};
|
|
76
95
|
}
|
|
77
96
|
function mergeUsage(before, after) {
|
|
78
97
|
return after === undefined ? before : { ...before, ...after };
|
|
@@ -108,18 +127,3 @@ function applyDelta(blocks, partialJson, sizes, index, delta, onStream) {
|
|
|
108
127
|
return;
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
|
-
// Tool arguments arrive as a stream of JSON fragments. An empty accumulation
|
|
112
|
-
// is a call with no arguments, not a malformed one.
|
|
113
|
-
function parseJsonObject(text) {
|
|
114
|
-
if (text.trim() === "")
|
|
115
|
-
return {};
|
|
116
|
-
try {
|
|
117
|
-
const parsed = JSON.parse(text);
|
|
118
|
-
return typeof parsed === "object" && parsed !== null
|
|
119
|
-
? parsed
|
|
120
|
-
: {};
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return {};
|
|
124
|
-
}
|
|
125
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the Anthropic wire shape.
|
|
2
2
|
// Pure functions, no I/O — which is what makes them testable without a key.
|
|
3
|
+
import { toolInputFromValue } from "./tool-input.js";
|
|
3
4
|
import { wireTokenCount } from "./wire-usage.js";
|
|
4
5
|
export function toWireTool(tool) {
|
|
5
6
|
return { name: tool.name, description: tool.description, input_schema: tool.input };
|
|
@@ -45,7 +46,8 @@ export function fromWireResponse(data) {
|
|
|
45
46
|
const raw = Array.isArray(data.content) ? data.content : [];
|
|
46
47
|
const content = [];
|
|
47
48
|
let suppressedToolCall = false;
|
|
48
|
-
for (
|
|
49
|
+
for (let index = 0; index < raw.length; index++) {
|
|
50
|
+
const item = raw[index];
|
|
49
51
|
const block = item;
|
|
50
52
|
if (block.type === "text" && typeof block.text === "string") {
|
|
51
53
|
content.push({ kind: "text", text: block.text });
|
|
@@ -54,11 +56,14 @@ export function fromWireResponse(data) {
|
|
|
54
56
|
if (data.stop_reason === "tool_use" &&
|
|
55
57
|
typeof block.id === "string" &&
|
|
56
58
|
typeof block.name === "string") {
|
|
59
|
+
const parsed = toolInputFromValue(block.input ?? {});
|
|
60
|
+
const inputError = data.toolInputErrors?.[index] ?? parsed.inputError;
|
|
57
61
|
content.push({
|
|
58
62
|
kind: "tool_call",
|
|
59
63
|
id: block.id,
|
|
60
64
|
name: block.name,
|
|
61
|
-
input:
|
|
65
|
+
input: parsed.input,
|
|
66
|
+
...(inputError === undefined ? {} : { inputError }),
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
else {
|
package/dist/providers/http.js
CHANGED
|
@@ -40,7 +40,10 @@ export async function postSse(url, headers, body, maxOutputTokens, signal, onSta
|
|
|
40
40
|
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
|
|
41
41
|
if (res.body === null)
|
|
42
42
|
throw httpError(`${url} returned no body`, res.status);
|
|
43
|
-
return readSseJson(
|
|
43
|
+
return readSseJson(res.body, maximumChars, {
|
|
44
|
+
milliseconds: BODY_IDLE_TIMEOUT_MS,
|
|
45
|
+
error: () => httpError(`${url} SSE stream was idle for ${BODY_IDLE_TIMEOUT_MS}ms without an event`, res.status),
|
|
46
|
+
});
|
|
44
47
|
}
|
|
45
48
|
async function request(url, headers, body, signal, onStatus) {
|
|
46
49
|
const maxRetries = body === undefined ? GET_RETRIES : 0;
|
|
@@ -154,39 +157,6 @@ async function timedRead(url, reader) {
|
|
|
154
157
|
clearTimeout(timer);
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
|
-
function withIdleTimeout(url, body) {
|
|
158
|
-
const reader = body.getReader();
|
|
159
|
-
let released = false;
|
|
160
|
-
const release = () => {
|
|
161
|
-
if (released)
|
|
162
|
-
return;
|
|
163
|
-
released = true;
|
|
164
|
-
reader.releaseLock();
|
|
165
|
-
};
|
|
166
|
-
return new ReadableStream({
|
|
167
|
-
async pull(controller) {
|
|
168
|
-
try {
|
|
169
|
-
const { done, value } = await timedRead(url, reader);
|
|
170
|
-
if (done) {
|
|
171
|
-
release();
|
|
172
|
-
controller.close();
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
controller.enqueue(value);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
catch (error) {
|
|
179
|
-
await reader.cancel(error).catch(() => undefined);
|
|
180
|
-
release();
|
|
181
|
-
controller.error(error);
|
|
182
|
-
}
|
|
183
|
-
},
|
|
184
|
-
async cancel(reason) {
|
|
185
|
-
await reader.cancel(reason).catch(() => undefined);
|
|
186
|
-
release();
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
160
|
function waitLabel(ms) {
|
|
191
161
|
return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
|
|
192
162
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Chat Completions differs on two points that matter here — a tool result is a
|
|
7
7
|
// message of its own with role "tool", not a block inside a user turn, and tool
|
|
8
8
|
// arguments travel as a JSON string rather than an object.
|
|
9
|
+
import { toolInputFromJson } from "./tool-input.js";
|
|
9
10
|
import { wireTokenCount } from "./wire-usage.js";
|
|
10
11
|
export function toWireTool(tool) {
|
|
11
12
|
return {
|
|
@@ -63,7 +64,12 @@ export function fromWireReply(reply) {
|
|
|
63
64
|
const acceptsToolCalls = reply.finishReason === "tool_calls";
|
|
64
65
|
if (acceptsToolCalls) {
|
|
65
66
|
for (const call of reply.toolCalls) {
|
|
66
|
-
content.push({
|
|
67
|
+
content.push({
|
|
68
|
+
kind: "tool_call",
|
|
69
|
+
id: call.id,
|
|
70
|
+
name: call.name,
|
|
71
|
+
...toolInputFromJson(call.args),
|
|
72
|
+
});
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
const notice = stopNotice(reply);
|
|
@@ -104,17 +110,3 @@ export function stopNotice(reply) {
|
|
|
104
110
|
? "[truncated: hit the output limit — raise --max-tokens]"
|
|
105
111
|
: undefined;
|
|
106
112
|
}
|
|
107
|
-
// A model that emits malformed JSON gets the empty object, which fails
|
|
108
|
-
// validation in tools/args.ts with a message written for it to read. That is a
|
|
109
|
-
// recoverable turn; throwing here would end the whole thing instead.
|
|
110
|
-
function parseArgs(args) {
|
|
111
|
-
if (args.trim() === "")
|
|
112
|
-
return {};
|
|
113
|
-
try {
|
|
114
|
-
const parsed = JSON.parse(args);
|
|
115
|
-
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return {};
|
|
119
|
-
}
|
|
120
|
-
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -90,6 +90,7 @@ export const ollama = {
|
|
|
90
90
|
max_tokens: req.maxTokens,
|
|
91
91
|
reasoning_effort: effort,
|
|
92
92
|
stream: true,
|
|
93
|
+
stream_options: { include_usage: true },
|
|
93
94
|
}, req.maxTokens, req.signal, req.onStatus);
|
|
94
95
|
const reply = await assembleOllama(events, req.onStream);
|
|
95
96
|
const notice = stopNotice(reply);
|
|
@@ -72,7 +72,7 @@ export const openaiCodex = {
|
|
|
72
72
|
include: ["reasoning.encrypted_content"],
|
|
73
73
|
prompt_cache_key: SESSION_ID,
|
|
74
74
|
}, req.maxTokens, req.signal, req.onStatus);
|
|
75
|
-
const data = await assembleOpenAI(events, req.onStream);
|
|
75
|
+
const data = await assembleOpenAI(events, req.onStream, req.onStatus);
|
|
76
76
|
const notice = stopNotice(data);
|
|
77
77
|
if (notice !== undefined)
|
|
78
78
|
req.onStream?.({ kind: "text", text: `\n${notice}` });
|