@synqa/cli 1.1.1 → 1.2.1
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 +74 -9
- package/bin/synqa.js +0 -0
- package/dist/agent.js +84 -14
- package/dist/api.js +68 -7
- package/dist/commands.js +144 -17
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +180 -75
- package/dist/modes.js +20 -0
- package/dist/paths.js +22 -15
- package/dist/prompt.js +84 -5
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/tiers.js +1 -1
- package/dist/tools.js +9 -1
- package/dist/ui.js +137 -10
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,7 +59,66 @@ From a checkout of this repo (for development): `npm link ./cli`.
|
|
|
59
59
|
2. Paste your `synqa_live_...` key (typing is hidden). It is checked against the Synqa API and
|
|
60
60
|
saved to `~/.config/synqa/config.json` (file mode 0600).
|
|
61
61
|
|
|
62
|
-
Or
|
|
62
|
+
Or set `SYNQA_API_KEY` in your shell. A key saved by `synqa login` or `/login` wins over the variable,
|
|
63
|
+
so you never need to unset it; pass `--env-key` to make the variable win for one run.
|
|
64
|
+
|
|
65
|
+
Starting `synqa` in a terminal with no valid Synqa key shows the welcome screen and asks for the key
|
|
66
|
+
first (typing shows `••••`, the key is checked at `https://api.synqa.ca/v1/account`). Chat and every
|
|
67
|
+
other provider stay locked until you are signed in to Synqa Cloud.
|
|
68
|
+
|
|
69
|
+
## Providers
|
|
70
|
+
|
|
71
|
+
After you sign in, `/provider` (or `/login`) opens an arrow-key menu: Synqa Cloud (primary),
|
|
72
|
+
OpenCode Go, OpenCode Zen, OpenRouter (bring your own `sk-or-...` key) and a custom
|
|
73
|
+
OpenAI-compatible endpoint. Keys for the others are typed masked, checked with `GET /v1/models` and
|
|
74
|
+
saved in the same private config file (mode 0600 on macOS and Linux; Windows ignores the mode). The
|
|
75
|
+
active provider and model show in the header and the footer. `/logout` offers to leave the secondary
|
|
76
|
+
provider or to log out fully, which clears the Synqa key (and `SYNQA_API_KEY` in this process) and
|
|
77
|
+
returns to the sign-in prompt. Use `OPENCODE_API_KEY` or the router's key variable as a fallback when
|
|
78
|
+
nothing is saved.
|
|
79
|
+
|
|
80
|
+
## Keys and modes
|
|
81
|
+
|
|
82
|
+
- `/` opens the command list under the prompt: Up/Down move, Tab fills, Enter runs, Esc closes.
|
|
83
|
+
- `/model`, `/effort`, `/provider`, `/login`, `/logout` and `/hooks` with no argument open an inline
|
|
84
|
+
Up/Down list (Enter selects, Esc cancels).
|
|
85
|
+
- Shift+Tab cycles the permission mode: `ask before edits`, `auto-accept edits` (file edits in the
|
|
86
|
+
workspace apply without a question, like `--yes`; shell commands still ask) and `plan only` (no
|
|
87
|
+
file is written and no command is run). The mode shows in the footer.
|
|
88
|
+
- While an answer streams, the keyboard is locked: what you type is not echoed or kept. A tool call
|
|
89
|
+
that is still arriving (for example a large `write_file`) shows a live line such as
|
|
90
|
+
`⠋ write_file index.html · streaming 18.4 KB…`. Esc or Ctrl+C stops the request and returns to the
|
|
91
|
+
prompt; if a shell command is still running, press it a second time to leave. A tool call cut off
|
|
92
|
+
by the output limit is never run: the model is told to split the work into smaller files.
|
|
93
|
+
|
|
94
|
+
## Hooks
|
|
95
|
+
|
|
96
|
+
`.synqa/hooks.json` in a project and `hooks.json` in the user config folder can run shell commands at
|
|
97
|
+
`SessionStart`, `PreToolUse`, `PostToolUse` and `Stop`:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{ "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ] } }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The hook gets the event as JSON on stdin. A `PreToolUse` hook blocks the tool by exiting with code 2
|
|
104
|
+
or printing `{"decision":"block","reason":"..."}`; other output is ignored. A command that starts
|
|
105
|
+
with `python` runs with `python3` (or `py` on Windows) when `python` is not on PATH. Project hooks
|
|
106
|
+
are repository content, so they start off: `/hooks` lists them and switches them on or off.
|
|
107
|
+
|
|
108
|
+
### Hooks security
|
|
109
|
+
|
|
110
|
+
- Project hooks start disabled. An approval is tied to the project path and the exact entry
|
|
111
|
+
(command, matcher and timeout): a different folder or any edit to the entry needs a new approval.
|
|
112
|
+
- The hook payload includes the tool input and result (up to 4000 characters each), so a hook sees
|
|
113
|
+
file contents and command output.
|
|
114
|
+
- Never point a hook at a file the agent can write: the agent could rewrite the script that runs next.
|
|
115
|
+
- In plan only mode the `PostToolUse` and `Stop` hooks do not run.
|
|
116
|
+
|
|
117
|
+
## Updates
|
|
118
|
+
|
|
119
|
+
On start `synqa` asks the npm registry once, with a 1.5 second limit and no error message, whether a
|
|
120
|
+
newer `@synqa/cli` exists, and shows a quiet hint in the footer. `/update` runs
|
|
121
|
+
`npm install -g @synqa/cli@latest`. Set `SYNQA_NO_UPDATE_CHECK=1` to skip the check.
|
|
63
122
|
|
|
64
123
|
## Use
|
|
65
124
|
|
|
@@ -72,14 +131,18 @@ Or skip login and set `SYNQA_API_KEY` in your shell. The variable wins over the
|
|
|
72
131
|
shell command while an `AGENTS.md` is loaded, and shell commands that name a secrets file or a glob that could.
|
|
73
132
|
Diffs are still shown.
|
|
74
133
|
- `synqa --model sonata` : start on another tier (`blink`, `sonata`, `aeon`).
|
|
75
|
-
- `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`).
|
|
134
|
+
- `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`, `high`).
|
|
76
135
|
|
|
77
136
|
## Slash commands
|
|
78
137
|
|
|
79
138
|
| Command | What it does |
|
|
80
139
|
|---|---|
|
|
81
|
-
| `/model blink\|sonata\|aeon` |
|
|
82
|
-
| `/effort default\|low\|medium` | Set the reasoning effort. `default` sends no effort setting. |
|
|
140
|
+
| `/model blink\|sonata\|aeon` | Choose the model. With no argument an arrow-key list opens: Blink 5.5, Sonata 6.0, Aeon 6.5 on Synqa Cloud, the provider's own model list otherwise. |
|
|
141
|
+
| `/effort default\|low\|medium\|high` | Set the reasoning effort (arrow-key list with no argument). `default` sends no effort setting. |
|
|
142
|
+
| `/provider`, `/login` | Pick Synqa Cloud, OpenCode Go, OpenCode Zen, OpenRouter or a custom endpoint. |
|
|
143
|
+
| `/logout` | Leave the secondary provider, or log out fully and lock the session. |
|
|
144
|
+
| `/hooks` | List hooks and switch them on or off. |
|
|
145
|
+
| `/update` | Install the latest `@synqa/cli`. |
|
|
83
146
|
| `/status` | Tier, routing, session tokens, context use of the last request, and your live credit and weekly pool balance. |
|
|
84
147
|
| `/init` | Scan the project and write a starter `AGENTS.md`. |
|
|
85
148
|
| `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
|
|
@@ -107,7 +170,7 @@ The balance lookup waits at most 2 seconds; if it fails the note says `balance u
|
|
|
107
170
|
▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
|
|
108
171
|
▄▄▄▀ █
|
|
109
172
|
|
|
110
|
-
v1.
|
|
173
|
+
v1.2.0 · Blink 5.5 · default effort
|
|
111
174
|
~/synqa-test
|
|
112
175
|
⎿ no AGENTS.md yet: /init writes one
|
|
113
176
|
⎿ $9.98 credit · weekly pool $4.00 of $10.00
|
|
@@ -221,7 +284,7 @@ it. Use git for that.
|
|
|
221
284
|
|
|
222
285
|
| Name | Meaning |
|
|
223
286
|
|---|---|
|
|
224
|
-
| `SYNQA_API_KEY` | Your key
|
|
287
|
+
| `SYNQA_API_KEY` | Your key. A key saved by `synqa login` or `/login` wins over it unless you pass `--env-key`. Never passed to commands the agent runs. |
|
|
225
288
|
| `NO_COLOR` | Any non-empty value turns colour off. |
|
|
226
289
|
| `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
|
|
227
290
|
|
|
@@ -259,8 +322,8 @@ it. Use git for that.
|
|
|
259
322
|
|
|
260
323
|
- `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
|
|
261
324
|
- `Your API key was rejected`: run `synqa login` again, or check on the dashboard that the key is
|
|
262
|
-
active.
|
|
263
|
-
|
|
325
|
+
active. A key saved with `/login` is used before `SYNQA_API_KEY`; `/logout` then `Full logout` clears
|
|
326
|
+
both in the running session.
|
|
264
327
|
- `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
|
|
265
328
|
chat access.
|
|
266
329
|
- `request timed out`: a request is cut off after 10 minutes.
|
|
@@ -270,7 +333,9 @@ it. Use git for that.
|
|
|
270
333
|
## Develop
|
|
271
334
|
|
|
272
335
|
From the repo root: `node --test 'cli/test/*.test.ts'` (or `npm test` inside `cli/`). Tests use fake
|
|
273
|
-
`fetch`, fake terminals and temporary folders and never call the live API.
|
|
336
|
+
`fetch`, fake terminals and temporary folders and never call the live API. `test/pty.test.ts` also
|
|
337
|
+
runs the real CLI in a pseudo-terminal through a small Python driver (standard library only) with a
|
|
338
|
+
throwaway home folder and no network; it skips with a message where Python or a PTY is missing.
|
|
274
339
|
|
|
275
340
|
Sources are TypeScript run directly by Node (type stripping); only erasable syntax is allowed. Node
|
|
276
341
|
will not strip types inside `node_modules`, so the published package ships plain JS: `npm run build`
|
package/bin/synqa.js
CHANGED
|
File without changes
|
package/dist/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fenceText } from "./sanitize.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { AbortedError, chat } from "./api.js";
|
|
3
|
+
|
|
4
4
|
import { runTool, TOOL_DEFS } from "./tools.js";
|
|
5
5
|
|
|
6
6
|
|
|
@@ -13,7 +13,7 @@ export const MAX_NOTES_CHARS = 8000;
|
|
|
13
13
|
// from a third party (a cloned repo), so it goes in a delimited block as data: conventions only,
|
|
14
14
|
// never an instruction source. fenceText removes invisible characters and neutralizes every "<", so
|
|
15
15
|
// nothing inside can open or close the block.
|
|
16
|
-
export function systemPrompt(tier , cwd , notes ) {
|
|
16
|
+
export function systemPrompt(tier , cwd , notes , external = false) {
|
|
17
17
|
const body = fenceText(notes ?? "", MAX_NOTES_CHARS);
|
|
18
18
|
const extra = body
|
|
19
19
|
? [
|
|
@@ -26,8 +26,10 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
26
26
|
]
|
|
27
27
|
: [];
|
|
28
28
|
return [
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
external
|
|
30
|
+
? "You are the Synqa terminal coding agent, running on a model provider the user chose. If asked who created this tool, say Synqa."
|
|
31
|
+
: `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
|
|
32
|
+
...(external ? [] : ["If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside."]),
|
|
31
33
|
`Working directory: ${cwd}. File tools cannot leave it.`,
|
|
32
34
|
"Use the tools to read, search and change files and to run commands. Make small, correct changes and say briefly what you did.",
|
|
33
35
|
...extra,
|
|
@@ -40,6 +42,8 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
|
|
46
|
+
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
|
|
@@ -52,6 +56,16 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
52
56
|
|
|
53
57
|
|
|
54
58
|
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
55
69
|
|
|
56
70
|
|
|
57
71
|
|
|
@@ -62,6 +76,44 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
62
76
|
|
|
63
77
|
|
|
64
78
|
|
|
79
|
+
// What the model is told when a tool call was cut off by the output limit. No provider or model
|
|
80
|
+
// name, and no file content: only what to do next.
|
|
81
|
+
export const CUT_OFF_RESULT =
|
|
82
|
+
"error: this tool call was cut off before its arguments were complete (the output limit was reached), so nothing was run and nothing was written. Split the work into smaller modular files (for example styles.css, app.js and index.html), one short tool call each, and do not repeat the large call.";
|
|
83
|
+
|
|
84
|
+
// A different failure, a different message: the stream ended early (a dropped connection), not the
|
|
85
|
+
// output limit. The call may be fine to try again as it was.
|
|
86
|
+
export const CONNECTION_LOST_RESULT =
|
|
87
|
+
"error: the connection ended before this tool call was complete, so nothing was run and nothing was written. This is not a size problem: try the same call again.";
|
|
88
|
+
|
|
89
|
+
export const CONNECTION_LOST_NOTICE = "connection lost before the response finished; no tool was run";
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
// Decides whether a streamed tool call can run. Its arguments must be complete JSON (an object).
|
|
94
|
+
// Policy for finish_reason "length": the cap can only cut the last call, and a call whose JSON
|
|
95
|
+
// parsed is complete (a string closed with its quote cannot hold a truncated file), so it runs
|
|
96
|
+
// as usual; a call with no arguments at all is treated as cut off, because a tool that needs
|
|
97
|
+
// arguments was begun and never filled. Anything that does not parse is never run, and its
|
|
98
|
+
// arguments are stored as "{}" so the next request stays valid.
|
|
99
|
+
// complete: false when the stream ended without [DONE] and without a finish_reason (a dropped
|
|
100
|
+
// connection): then no call is trusted, whatever its arguments look like. Whatever the outcome,
|
|
101
|
+
// `stored` is always a valid JSON object text, so the next request cannot be refused for it.
|
|
102
|
+
export function checkCall(raw , finishReason , complete = true) {
|
|
103
|
+
if (!complete) return { ok: false, stored: "{}", result: CONNECTION_LOST_RESULT };
|
|
104
|
+
if (raw.trim() === "") {
|
|
105
|
+
return finishReason === "length" ? { ok: false, stored: "{}", result: CUT_OFF_RESULT } : { ok: true, args: {}, stored: "{}" };
|
|
106
|
+
}
|
|
107
|
+
let v ;
|
|
108
|
+
try {
|
|
109
|
+
v = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return { ok: false, stored: "{}", result: CUT_OFF_RESULT };
|
|
112
|
+
}
|
|
113
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { ok: false, stored: "{}", result: "error: tool arguments must be a JSON object" };
|
|
114
|
+
return { ok: true, args: v , stored: raw };
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
// Runs model -> tools -> model until the model answers without tool calls. Mutates history.
|
|
66
118
|
export async function runAgent(o ) {
|
|
67
119
|
const total = { prompt: 0, completion: 0 };
|
|
@@ -75,14 +127,18 @@ export async function runAgent(o ) {
|
|
|
75
127
|
let routing = null;
|
|
76
128
|
let lastUsage = { prompt: 0, completion: 0 };
|
|
77
129
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
130
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
78
131
|
o.events.onRequest?.();
|
|
79
132
|
const r = await chat({
|
|
80
133
|
apiKey: o.apiKey,
|
|
81
|
-
model: o.tier.id,
|
|
134
|
+
model: o.modelId ?? o.tier.id,
|
|
135
|
+
baseUrl: o.baseUrl,
|
|
82
136
|
messages: o.history,
|
|
83
137
|
tools: TOOL_DEFS,
|
|
84
138
|
fetchFn: o.fetchFn,
|
|
85
139
|
onText: o.events.onText,
|
|
140
|
+
onToolProgress: o.events.onToolProgress,
|
|
141
|
+
signal: o.signal,
|
|
86
142
|
timeoutMs: o.timeoutMs,
|
|
87
143
|
reasoningEffort: o.effort && o.effort !== "default" ? o.effort : undefined,
|
|
88
144
|
now: o.now,
|
|
@@ -99,23 +155,37 @@ export async function runAgent(o ) {
|
|
|
99
155
|
lastUsage = r.usage;
|
|
100
156
|
}
|
|
101
157
|
o.events.onTurn(r);
|
|
158
|
+
if (r.complete === false && r.toolCalls.length) o.events.onNotice?.(CONNECTION_LOST_NOTICE);
|
|
159
|
+
const checks = r.toolCalls.map((c) => checkCall(c.arguments, r.finishReason, r.complete !== false));
|
|
102
160
|
const assistant = { role: "assistant", content: r.content || null };
|
|
103
161
|
if (r.toolCalls.length) {
|
|
104
|
-
assistant.tool_calls = r.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments:
|
|
162
|
+
assistant.tool_calls = r.toolCalls.map((c, i) => ({ id: c.id, type: "function", function: { name: c.name, arguments: (checks[i] ).stored } }));
|
|
105
163
|
}
|
|
106
164
|
o.history.push(assistant);
|
|
107
165
|
if (!r.toolCalls.length) return result();
|
|
108
|
-
|
|
109
|
-
|
|
166
|
+
// Every call gets exactly one result, also when the person stops the run between calls.
|
|
167
|
+
for (const [i, c] of r.toolCalls.entries()) {
|
|
168
|
+
const check = checks[i] ;
|
|
169
|
+
const shownArgs = check.stored;
|
|
170
|
+
o.events.onTool(c.name, shownArgs);
|
|
110
171
|
let result ;
|
|
111
|
-
|
|
112
|
-
result =
|
|
113
|
-
}
|
|
114
|
-
result =
|
|
172
|
+
if (o.signal?.aborted) {
|
|
173
|
+
result = "error: stopped by the user before this tool ran";
|
|
174
|
+
} else if (!check.ok) {
|
|
175
|
+
result = check.result;
|
|
176
|
+
} else {
|
|
177
|
+
try {
|
|
178
|
+
const blocked = await o.hooks?.pre(c.name, c.arguments);
|
|
179
|
+
result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, check.args);
|
|
180
|
+
if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
result = `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
183
|
+
}
|
|
115
184
|
}
|
|
116
|
-
o.events.onToolResult?.(c.name,
|
|
185
|
+
o.events.onToolResult?.(c.name, shownArgs, result);
|
|
117
186
|
o.history.push({ role: "tool", tool_call_id: c.id, content: result });
|
|
118
187
|
}
|
|
188
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
119
189
|
}
|
|
120
190
|
o.events.onText("\n[stopped: step limit reached]\n");
|
|
121
191
|
return result();
|
package/dist/api.js
CHANGED
|
@@ -14,14 +14,42 @@ import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// What the terminal shows while a tool call is still streaming in.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const PATH_SCAN_CHARS = 4096;
|
|
32
|
+
|
|
33
|
+
// The "path" argument out of a possibly cut-off JSON object. Only a string whose closing quote has
|
|
34
|
+
// arrived counts, so a half-received path is never shown. A "path" key inside another string value
|
|
35
|
+
// is escaped there (\"path\"), so it cannot match.
|
|
36
|
+
export function partialPath(raw ) {
|
|
37
|
+
const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw);
|
|
38
|
+
if (!m) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const v = JSON.parse(`"${m[1]}"`);
|
|
41
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
20
46
|
|
|
21
47
|
// Folds streamed chunks into text and complete tool calls.
|
|
22
48
|
export class StreamAccumulator {
|
|
23
49
|
content = "";
|
|
24
50
|
usage ;
|
|
51
|
+
finishReason ;
|
|
52
|
+
onToolProgress ;
|
|
25
53
|
calls = new Map ();
|
|
26
54
|
|
|
27
55
|
push(chunk ) {
|
|
@@ -32,14 +60,27 @@ export class StreamAccumulator {
|
|
|
32
60
|
text = delta.content;
|
|
33
61
|
this.content += text;
|
|
34
62
|
}
|
|
63
|
+
if (typeof choice?.finish_reason === "string") this.finishReason = choice.finish_reason;
|
|
35
64
|
if (delta && Array.isArray(delta.tool_calls)) {
|
|
36
65
|
for (const tc of delta.tool_calls) {
|
|
37
66
|
const idx = typeof tc.index === "number" ? tc.index : 0;
|
|
38
|
-
const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "" };
|
|
67
|
+
const cur = this.calls.get(idx) ?? { id: "", name: "", arguments: "", bytes: 0 };
|
|
39
68
|
if (tc.id) cur.id = tc.id;
|
|
40
69
|
if (tc.function?.name) cur.name += tc.function.name;
|
|
41
|
-
if (typeof tc.function?.arguments === "string")
|
|
70
|
+
if (typeof tc.function?.arguments === "string") {
|
|
71
|
+
cur.arguments += tc.function.arguments;
|
|
72
|
+
cur.bytes += Buffer.byteLength(tc.function.arguments);
|
|
73
|
+
}
|
|
42
74
|
this.calls.set(idx, cur);
|
|
75
|
+
if (this.onToolProgress && cur.name) {
|
|
76
|
+
// The path is looked for only near the start and only until it is found, so a 60 KB
|
|
77
|
+
// argument string is not rescanned on every chunk.
|
|
78
|
+
if (cur.path === undefined && !cur.scanned) {
|
|
79
|
+
cur.path = partialPath(cur.arguments.slice(0, PATH_SCAN_CHARS));
|
|
80
|
+
if (cur.arguments.length >= PATH_SCAN_CHARS) cur.scanned = true;
|
|
81
|
+
}
|
|
82
|
+
this.onToolProgress({ index: idx, name: cur.name, bytes: cur.bytes, path: cur.path });
|
|
83
|
+
}
|
|
43
84
|
}
|
|
44
85
|
}
|
|
45
86
|
const u = usageFrom(chunk?.usage);
|
|
@@ -66,7 +107,7 @@ export function parseToolArguments(raw ) {
|
|
|
66
107
|
}
|
|
67
108
|
|
|
68
109
|
// Parses an SSE byte stream into JSON events; "[DONE]" ends it.
|
|
69
|
-
export async function* sseEvents(body ) {
|
|
110
|
+
export async function* sseEvents(body , state ) {
|
|
70
111
|
const dec = new TextDecoder();
|
|
71
112
|
let buf = "";
|
|
72
113
|
for await (const part of body) {
|
|
@@ -77,7 +118,7 @@ export async function* sseEvents(body )
|
|
|
77
118
|
buf = buf.slice(nl + 1);
|
|
78
119
|
if (!line.startsWith("data:")) continue;
|
|
79
120
|
const data = line.slice(5).trim();
|
|
80
|
-
if (data === "[DONE]") return;
|
|
121
|
+
if (data === "[DONE]") { if (state) state.done = true; return; }
|
|
81
122
|
if (data) yield JSON.parse(data);
|
|
82
123
|
}
|
|
83
124
|
}
|
|
@@ -141,20 +182,34 @@ export async function checkKey(apiKey , fetchFn = fetch)
|
|
|
141
182
|
|
|
142
183
|
|
|
143
184
|
|
|
185
|
+
|
|
144
186
|
|
|
187
|
+
|
|
188
|
+
|
|
145
189
|
|
|
146
190
|
|
|
147
191
|
|
|
148
192
|
|
|
149
193
|
|
|
194
|
+
// Thrown when the person aborts a request; not a failure to report as an error.
|
|
195
|
+
export class AbortedError extends Error {
|
|
196
|
+
constructor() {
|
|
197
|
+
super("interrupted");
|
|
198
|
+
this.name = "AbortedError";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
150
202
|
export async function chat(o ) {
|
|
151
203
|
const fetchFn = o.fetchFn ?? fetch;
|
|
152
204
|
const now = o.now ?? (() => performance.now());
|
|
153
205
|
const t0 = now();
|
|
154
206
|
const ac = new AbortController();
|
|
155
207
|
const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
208
|
+
const onUserAbort = () => ac.abort();
|
|
209
|
+
if (o.signal?.aborted) { clearTimeout(timer); throw new AbortedError(); }
|
|
210
|
+
o.signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
156
211
|
try {
|
|
157
|
-
const res = await fetchFn(`${API_BASE}/chat/completions`, {
|
|
212
|
+
const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
|
|
158
213
|
method: "POST",
|
|
159
214
|
headers: { authorization: `Bearer ${o.apiKey}`, "content-type": "application/json" },
|
|
160
215
|
body: JSON.stringify({
|
|
@@ -171,8 +226,10 @@ export async function chat(o ) {
|
|
|
171
226
|
if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
|
|
172
227
|
if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
|
|
173
228
|
const acc = new StreamAccumulator();
|
|
229
|
+
acc.onToolProgress = o.onToolProgress;
|
|
174
230
|
let firstAt ;
|
|
175
|
-
|
|
231
|
+
const sseState = { done: false };
|
|
232
|
+
for await (const ev of sseEvents(res.body , sseState)) {
|
|
176
233
|
const t = acc.push(ev);
|
|
177
234
|
if (firstAt === undefined && acc.hasOutput()) firstAt = now();
|
|
178
235
|
if (t && o.onText) o.onText(t);
|
|
@@ -183,14 +240,18 @@ export async function chat(o ) {
|
|
|
183
240
|
toolCalls: acc.toolCalls(),
|
|
184
241
|
usage: acc.usage,
|
|
185
242
|
routing: res.headers.get("x-synqa-routing"),
|
|
243
|
+
finishReason: acc.finishReason,
|
|
244
|
+
complete: sseState.done || acc.finishReason !== undefined,
|
|
186
245
|
ttftMs: firstAt === undefined ? undefined : firstAt - t0,
|
|
187
246
|
genMs: firstAt === undefined ? undefined : end - firstAt,
|
|
188
247
|
};
|
|
189
248
|
} catch (err) {
|
|
249
|
+
if (o.signal?.aborted) throw new AbortedError();
|
|
190
250
|
if (ac.signal.aborted) throw new Error("request timed out");
|
|
191
251
|
throw err;
|
|
192
252
|
} finally {
|
|
193
253
|
clearTimeout(timer);
|
|
254
|
+
o.signal?.removeEventListener("abort", onUserAbort);
|
|
194
255
|
}
|
|
195
256
|
}
|
|
196
257
|
|