oakode 0.0.0-stage → 0.5.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/CHANGELOG.md +93 -0
- package/LICENSE +9 -0
- package/README.md +245 -2
- package/SECURITY.md +13 -0
- package/dist/account.js +34 -0
- package/dist/agent.js +584 -0
- package/dist/agents/approval.js +116 -0
- package/dist/agents/budget.js +293 -0
- package/dist/agents/child.js +103 -0
- package/dist/agents/host.js +289 -0
- package/dist/agents/orchestrator.js +286 -0
- package/dist/agents/plan.js +143 -0
- package/dist/agents/types.js +20 -0
- package/dist/agents/verify.js +94 -0
- package/dist/app.js +619 -0
- package/dist/auth.js +456 -0
- package/dist/brief.js +97 -0
- package/dist/commands.js +588 -0
- package/dist/compact.js +231 -0
- package/dist/config.js +87 -0
- package/dist/diff.js +98 -0
- package/dist/doctor.js +106 -0
- package/dist/errors.js +15 -0
- package/dist/fileio.js +688 -0
- package/dist/input.js +244 -0
- package/dist/jobs.js +262 -0
- package/dist/main.js +560 -0
- package/dist/mask.js +63 -0
- package/dist/mcp.js +721 -0
- package/dist/models.js +106 -0
- package/dist/modes.js +114 -0
- package/dist/oicki.js +475 -0
- package/dist/output.js +101 -0
- package/dist/permissions.js +389 -0
- package/dist/permissions_ui.js +134 -0
- package/dist/print.js +76 -0
- package/dist/proc.js +63 -0
- package/dist/runners.js +264 -0
- package/dist/serial.js +9 -0
- package/dist/session.js +452 -0
- package/dist/shell.js +40 -0
- package/dist/skills.js +335 -0
- package/dist/syntax.js +80 -0
- package/dist/tools.js +480 -0
- package/dist/tui/editor.js +279 -0
- package/dist/tui/highlight.js +78 -0
- package/dist/tui/markdown.js +254 -0
- package/dist/tui/screen.js +146 -0
- package/dist/tui/tui.js +505 -0
- package/dist/tui/view.js +337 -0
- package/dist/ui.js +105 -0
- package/dist/update.js +64 -0
- package/package.json +57 -4
package/dist/oicki.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/** Thin client for OICKI's public HTTP API (OpenAI-compatible chat completions + model discovery). */
|
|
2
|
+
import { VERSION } from "./config.js";
|
|
3
|
+
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
|
|
4
|
+
/**
|
|
5
|
+
* A stored message as the API wants it: images become content parts, and reasoning is sent back only to the model
|
|
6
|
+
* that produced it (another provider may reject a foreign format).
|
|
7
|
+
*/
|
|
8
|
+
export function toWire(m, vision, model) {
|
|
9
|
+
if (m.role === "assistant") {
|
|
10
|
+
const { reasoning_details, reasoning_model, ...rest } = m;
|
|
11
|
+
return reasoning_details?.length && reasoning_model === model ? { ...rest, reasoning_details } : rest;
|
|
12
|
+
}
|
|
13
|
+
// always a copy: cache breakpoints are added to the wire messages, and must never change the stored conversation
|
|
14
|
+
if (m.role !== "user" || !m.images?.length)
|
|
15
|
+
return m.role === "user" ? { role: m.role, content: m.content } : { ...m };
|
|
16
|
+
if (!vision)
|
|
17
|
+
return { role: "user", content: `${m.content}\n[${m.images.length} image${m.images.length > 1 ? "s" : ""} not sent: this model cannot receive images (${m.images.map((i) => i.path).join(", ")})]` };
|
|
18
|
+
return {
|
|
19
|
+
role: "user",
|
|
20
|
+
content: [{ type: "text", text: m.content }, ...m.images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mime};base64,${i.data}` } }))],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const EPHEMERAL = { type: "ephemeral" };
|
|
24
|
+
/** Puts a cache breakpoint on a wire message's last text part (strings become one text part). */
|
|
25
|
+
function markCache(w) {
|
|
26
|
+
if (typeof w.content === "string") {
|
|
27
|
+
if (!w.content)
|
|
28
|
+
return false;
|
|
29
|
+
w.content = [{ type: "text", text: w.content, cache_control: EPHEMERAL }];
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const part = Array.isArray(w.content) ? w.content.findLast((p) => p.type === "text" && p.text) : undefined;
|
|
33
|
+
if (!part)
|
|
34
|
+
return false;
|
|
35
|
+
part.cache_control = EPHEMERAL;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Prompt-cache breakpoints for models with explicit caching: the system prompt (which, with the tools before it, is the
|
|
40
|
+
* same for the whole session) and the last two user/tool messages, so each request reads what the previous one wrote
|
|
41
|
+
* and writes a new prefix for the next. Assistant messages cannot carry markers. OICKI uses at most 4.
|
|
42
|
+
*/
|
|
43
|
+
export function addCacheBreakpoints(wire) {
|
|
44
|
+
if (wire[0]?.role === "system")
|
|
45
|
+
markCache(wire[0]);
|
|
46
|
+
let marked = 0;
|
|
47
|
+
for (let i = wire.length - 1; i > 0 && marked < 2; i--)
|
|
48
|
+
if ((wire[i].role === "user" || wire[i].role === "tool") && markCache(wire[i]))
|
|
49
|
+
marked++;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Streamed reasoning arrives in pieces. Pieces of the same block (same `index`, or adjacent pieces of the same type
|
|
53
|
+
* without one) are joined: text is concatenated, other fields (a signature, an id) take the latest value. The result
|
|
54
|
+
* has the shape a non-streamed reply would have.
|
|
55
|
+
*/
|
|
56
|
+
export function mergeReasoning(pieces) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const p of pieces) {
|
|
59
|
+
if (!p || typeof p !== "object")
|
|
60
|
+
continue;
|
|
61
|
+
const last = out.at(-1);
|
|
62
|
+
// Without an index, a block that already got its signature is finished: the next piece starts a new one.
|
|
63
|
+
const same = last && last.type === p.type && (p.index !== undefined ? last.index === p.index : last.index === undefined && !last.signature);
|
|
64
|
+
if (!same) {
|
|
65
|
+
out.push({ ...p });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
for (const [k, v] of Object.entries(p)) {
|
|
69
|
+
if ((k === "text" || k === "summary" || k === "data") && typeof v === "string" && typeof last[k] === "string")
|
|
70
|
+
last[k] += v;
|
|
71
|
+
else if (v !== undefined && v !== null && v !== "")
|
|
72
|
+
last[k] = v;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export class OickiError extends Error {
|
|
78
|
+
status;
|
|
79
|
+
code;
|
|
80
|
+
requestId;
|
|
81
|
+
/** What had streamed when a request failed after it started (never retried automatically). */
|
|
82
|
+
partial;
|
|
83
|
+
constructor(status, code, message, requestId) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "OickiError";
|
|
86
|
+
this.status = status;
|
|
87
|
+
this.code = code;
|
|
88
|
+
this.requestId = requestId;
|
|
89
|
+
}
|
|
90
|
+
/** Actionable advice for the user, when we know it. */
|
|
91
|
+
get hint() {
|
|
92
|
+
switch (this.code) {
|
|
93
|
+
case "not_logged_in":
|
|
94
|
+
return "Sign in with `oakode login` (or use an API key: `oakode login --api-key`, or set OICKI_API_KEY).";
|
|
95
|
+
case "session_expired":
|
|
96
|
+
return "Sign in again with `oakode login`. Your sessions and settings are kept.";
|
|
97
|
+
case "wrong_server":
|
|
98
|
+
return undefined;
|
|
99
|
+
case "unauthorized":
|
|
100
|
+
return "OICKI did not accept the credentials. Check them with `oakode status`; replace the key with `oakode login --api-key` (keys: https://oicki.vercel.app/dashboard/developer).";
|
|
101
|
+
case "insufficient_scope":
|
|
102
|
+
return "If this model is expensive: `oakode login --expensive` (OAuth) or enable \"Allow expensive models\" on your API key. Otherwise re-login to grant the missing scope, or pick another model with /model.";
|
|
103
|
+
case "insufficient_credits":
|
|
104
|
+
case "insufficient_super_credits":
|
|
105
|
+
return "Not enough credits. Add credits or wait for your plan to renew; retrying will not help.";
|
|
106
|
+
case "quota_exceeded":
|
|
107
|
+
return "A usage limit was reached. Waiting will not clear it; raise the limit in your OICKI account.";
|
|
108
|
+
case "app_limit_exceeded":
|
|
109
|
+
return "This would go over the monthly credit limit you set for OAKODE. Raise it under Account → Connected apps on OICKI (it resets on the 1st), or lower the reply budget (`maxOutputTokens` in ~/.oakode/config.json) so smaller requests fit.";
|
|
110
|
+
case "content_filtered":
|
|
111
|
+
return "The model provider's safety filter refused this request (not billed). Rephrase it or switch models (/model).";
|
|
112
|
+
case "context_length_exceeded":
|
|
113
|
+
return "Conversation too long for this model. Use /compact or switch to a larger-context model.";
|
|
114
|
+
case "invalid_request":
|
|
115
|
+
if (this.status === 413)
|
|
116
|
+
return "The request is larger than OICKI accepts (4 MB), usually because of an image. Ask for a smaller image, or start a fresh conversation with /clear.";
|
|
117
|
+
return undefined;
|
|
118
|
+
case "over_budget":
|
|
119
|
+
return "The subagent's credit budget is used up; its partial work is kept. A new delegation with a larger budget can continue it.";
|
|
120
|
+
case "ai_disabled":
|
|
121
|
+
return "AI is disabled for this account.";
|
|
122
|
+
case "provider_unavailable":
|
|
123
|
+
return "The model's provider is busy. Try again shortly, or pick another model with /model.";
|
|
124
|
+
case "provider_rejected":
|
|
125
|
+
return "OICKI's provider account refused this request (not billed). Try again later or another model (/model).";
|
|
126
|
+
case "connection_failed":
|
|
127
|
+
return "The request was never sent, so nothing was billed. Check your connection (or proxy); `oakode doctor` tests it. /retry when it is back.";
|
|
128
|
+
case "stream_incomplete":
|
|
129
|
+
case "provider_error":
|
|
130
|
+
case "usage_unavailable":
|
|
131
|
+
case "usage_settlement_failed":
|
|
132
|
+
case "timeout":
|
|
133
|
+
case "network_error":
|
|
134
|
+
if (this.status === 0 || this.status === 200)
|
|
135
|
+
return "The request may have been processed and billed. OAKODE does not retry these automatically; /retry sends a new request.";
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Raised when a stream fails after it started. The request may have been billed: never auto-retry. */
|
|
141
|
+
export class StreamError extends OickiError {
|
|
142
|
+
}
|
|
143
|
+
/** How long to keep retrying requests that could not connect at all, and the longest Retry-After waited out. */
|
|
144
|
+
const MAX_OFFLINE_MS = 5 * 60_000;
|
|
145
|
+
const MAX_RETRY_AFTER_S = 60;
|
|
146
|
+
/**
|
|
147
|
+
* Errors that mean the connection was never established (DNS, refused, unreachable, connect timeout). The code alone
|
|
148
|
+
* is not enough: an established socket can also fail with EHOSTUNREACH, after the request was written. So each error
|
|
149
|
+
* must come from the connect or DNS step (every one of a happy-eyeballs AggregateError's).
|
|
150
|
+
*/
|
|
151
|
+
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]);
|
|
152
|
+
export function connectFailure(e) {
|
|
153
|
+
const c = e?.cause;
|
|
154
|
+
if (c?.code === "UND_ERR_CONNECT_TIMEOUT")
|
|
155
|
+
return c.code; // undici: TCP + TLS handshake, before any request bytes
|
|
156
|
+
const all = c?.errors?.length ? c.errors : c ? [c] : [];
|
|
157
|
+
const ok = all.length > 0 && all.every((x) => x?.code && CONNECT_CODES.has(x.code) && (x.syscall === "connect" || x.syscall === "getaddrinfo"));
|
|
158
|
+
return ok ? all[0].code : undefined;
|
|
159
|
+
}
|
|
160
|
+
/** Retry-After in seconds: delta-seconds or an HTTP date. */
|
|
161
|
+
export function retryAfter(v) {
|
|
162
|
+
const s = v?.trim() ?? "";
|
|
163
|
+
if (/^\d+$/.test(s))
|
|
164
|
+
return Number(s);
|
|
165
|
+
const at = /[a-z]/i.test(s) ? Date.parse(s) : NaN; // an HTTP date has a day name; Date.parse("-5") is a year
|
|
166
|
+
return Number.isFinite(at) ? Math.max(0, (at - Date.now()) / 1000) : undefined;
|
|
167
|
+
}
|
|
168
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
169
|
+
if (signal?.aborted)
|
|
170
|
+
return reject(new DOMException("aborted", "AbortError"));
|
|
171
|
+
const t = setTimeout(resolve, ms);
|
|
172
|
+
signal?.addEventListener("abort", () => (clearTimeout(t), reject(new DOMException("aborted", "AbortError"))), {
|
|
173
|
+
once: true,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
export class OickiClient {
|
|
177
|
+
baseUrl;
|
|
178
|
+
auth;
|
|
179
|
+
constructor(baseUrl, auth) {
|
|
180
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
181
|
+
this.auth = auth;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* `onWait(true)` / `onWait(false)` bracket retry waits (a chat request pauses its no-data timer meanwhile);
|
|
185
|
+
* `onRetry` says why each wait happens. Only callers that pass it wait for a connection: the others (model lists,
|
|
186
|
+
* doctor) fail at once instead of hanging silently.
|
|
187
|
+
*/
|
|
188
|
+
async request(path, init = {}, onWait, onRetry) {
|
|
189
|
+
let refreshed = false;
|
|
190
|
+
let offlineSince = 0; // when the first connection attempt failed
|
|
191
|
+
let connects = 0, limited = 0, busy = 0;
|
|
192
|
+
for (;;) {
|
|
193
|
+
const headers = new Headers(init.headers);
|
|
194
|
+
const token = await this.auth.token();
|
|
195
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
196
|
+
headers.set("User-Agent", `oakode/${VERSION}`);
|
|
197
|
+
let res;
|
|
198
|
+
try {
|
|
199
|
+
res = await fetch(this.baseUrl + path, { ...init, headers });
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
// A connection that was never established cannot have delivered the request, so it cannot have been billed:
|
|
203
|
+
// the one network failure that is safe to resend. Anything after connecting (a reset, a cut) might have been.
|
|
204
|
+
const code = connectFailure(e);
|
|
205
|
+
if (!code || !onRetry || init.signal?.aborted)
|
|
206
|
+
throw e;
|
|
207
|
+
offlineSince ||= Date.now();
|
|
208
|
+
const offline = Date.now() - offlineSince;
|
|
209
|
+
const delay = Math.min(30_000, 2000 * 2 ** Math.min(connects++, 4)) * (0.9 + Math.random() * 0.2);
|
|
210
|
+
if (offline + delay > MAX_OFFLINE_MS)
|
|
211
|
+
throw new OickiError(0, "connection_failed", `Could not connect to ${new URL(this.baseUrl).host} (${code}) for ${Math.round(offline / 1000)} s.`);
|
|
212
|
+
onRetry(`No connection to ${new URL(this.baseUrl).host} (${code}); the request was not sent. Retrying in ${Math.round(delay / 1000)} s (Esc stops).`);
|
|
213
|
+
await this.wait(delay, init.signal ?? undefined, onWait);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (res.ok)
|
|
217
|
+
return res;
|
|
218
|
+
if (res.status === 401 && !refreshed && this.auth.refresh && (await this.auth.refresh(token))) {
|
|
219
|
+
refreshed = true;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const err = await parseError(res);
|
|
223
|
+
// Automatic retries only where the API documents "not billed, wait and resend": `429 rate_limited` with
|
|
224
|
+
// Retry-After, and `503 provider_unavailable`. Everything else (502 provider_error, dropped connections,
|
|
225
|
+
// timeouts) may have been processed and is surfaced instead.
|
|
226
|
+
const wait = retryAfter(res.headers.get("retry-after"));
|
|
227
|
+
if (err.code === "rate_limited" && wait !== undefined && limited < 3) {
|
|
228
|
+
// never sooner than the server asked: an early retry is refused again and wastes the attempt
|
|
229
|
+
if (wait > MAX_RETRY_AFTER_S) {
|
|
230
|
+
err.message += ` OICKI asked to wait ${Math.round(wait)} s before retrying.`;
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
limited++;
|
|
234
|
+
onRetry?.(`Rate limited by OICKI: retrying in ${Math.max(1, Math.round(wait))} s (${limited}/3).`);
|
|
235
|
+
await this.wait(wait * 1000, init.signal ?? undefined, onWait);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (err.code === "provider_unavailable" && res.status === 503 && busy < 2) {
|
|
239
|
+
const delay = Math.min(wait ?? 2, MAX_RETRY_AFTER_S) * 1000 * (0.9 + Math.random() * 0.2);
|
|
240
|
+
busy++;
|
|
241
|
+
onRetry?.(`The model's provider is busy (not billed): retrying in ${Math.max(1, Math.round(delay / 1000))} s (${busy}/2).`);
|
|
242
|
+
await this.wait(delay, init.signal ?? undefined, onWait);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async wait(ms, signal, onWait) {
|
|
249
|
+
onWait?.(true);
|
|
250
|
+
await sleep(ms, signal);
|
|
251
|
+
onWait?.(false);
|
|
252
|
+
}
|
|
253
|
+
async listModels(signal) {
|
|
254
|
+
const res = await this.request("/v1/models", { signal });
|
|
255
|
+
return (await res.json()).data;
|
|
256
|
+
}
|
|
257
|
+
async getModel(id, signal) {
|
|
258
|
+
const res = await this.request(`/v1/models/${encodeURIComponent(id)}`, { signal });
|
|
259
|
+
return (await res.json());
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Plan and credits of the signed-in person. Undefined for API keys (OICKI does not show balances to keys) and when
|
|
263
|
+
* the person did not grant billing:read. Not billed.
|
|
264
|
+
*/
|
|
265
|
+
async billing(signal) {
|
|
266
|
+
if (this.auth.kind() !== "oauth")
|
|
267
|
+
return undefined;
|
|
268
|
+
let b;
|
|
269
|
+
try {
|
|
270
|
+
b = await (await this.request("/v1/widget/summary", { signal })).json();
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
if (e instanceof OickiError && e.code === "insufficient_scope")
|
|
274
|
+
return undefined;
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
// shown in the status line: anything that does not have the documented shape is treated as unknown, never trusted
|
|
278
|
+
const str = (x) => typeof x === "string";
|
|
279
|
+
const num = (x) => typeof x === "number" && Number.isFinite(x);
|
|
280
|
+
const ok = b && typeof b === "object" && ["no_plan", "past_due", "exhausted", "low", "active"].includes(b.state) && str(b.headline) && str(b.message) &&
|
|
281
|
+
b.credits && num(b.credits.total) && num(b.credits.standard) && num(b.credits.super) &&
|
|
282
|
+
b.plan && typeof b.plan === "object" && (b.action === null || (b.action && str(b.action.label) && str(b.action.href))) &&
|
|
283
|
+
(b.usage === undefined || (b.usage && num(b.usage.cycleCredits) && str(b.usage.period))) &&
|
|
284
|
+
(b.allowance === null || b.allowance === undefined || (num(b.allowance.usedTokens) && num(b.allowance.capTokens))) &&
|
|
285
|
+
(b.appLimit === null || b.appLimit === undefined || (["credits", "tokens"].includes(b.appLimit.unit) && num(b.appLimit.used) && num(b.appLimit.cap)));
|
|
286
|
+
return ok ? b : undefined;
|
|
287
|
+
}
|
|
288
|
+
/** The signed-in person's opaque id (GET /oauth/userinfo): proves an OAuth sign-in works. Not billed. */
|
|
289
|
+
async whoami(signal) {
|
|
290
|
+
return String((await (await this.request("/oauth/userinfo", { signal })).json()).sub ?? "");
|
|
291
|
+
}
|
|
292
|
+
/** Credits for OAuth sessions (needs billing:read), request totals for API keys. */
|
|
293
|
+
async account(signal) {
|
|
294
|
+
const path = this.auth.kind() === "oauth" ? "/v1/widget/summary" : "/v1/usage";
|
|
295
|
+
return (await this.request(path, { signal })).json();
|
|
296
|
+
}
|
|
297
|
+
async chat(opts) {
|
|
298
|
+
const ctl = new AbortController();
|
|
299
|
+
const onAbort = () => ctl.abort();
|
|
300
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
301
|
+
if (opts.signal?.aborted)
|
|
302
|
+
ctl.abort();
|
|
303
|
+
let idle;
|
|
304
|
+
let timedOut = false;
|
|
305
|
+
const bump = () => {
|
|
306
|
+
clearTimeout(idle);
|
|
307
|
+
idle = setTimeout(() => ((timedOut = true), ctl.abort()), opts.idleTimeoutMs ?? 90_000);
|
|
308
|
+
};
|
|
309
|
+
const out = { content: "", tool_calls: [], finish_reason: null, aborted: false };
|
|
310
|
+
const acc = new Map();
|
|
311
|
+
const reasoning = [];
|
|
312
|
+
let started = false; // true once the server answered; failures after this point may have been billed
|
|
313
|
+
const withPartial = (e) => {
|
|
314
|
+
if (started)
|
|
315
|
+
e.partial = { content: out.content, toolCalls: [...acc.values()].map((c) => ({ name: c.function.name, argChars: c.function.arguments.length })) };
|
|
316
|
+
return e;
|
|
317
|
+
};
|
|
318
|
+
const wire = opts.messages.map((m) => toWire(m, opts.vision !== false, opts.reasoningModel ?? opts.model));
|
|
319
|
+
if (opts.cache)
|
|
320
|
+
addCacheBreakpoints(wire);
|
|
321
|
+
const body = () => JSON.stringify({
|
|
322
|
+
model: opts.model,
|
|
323
|
+
messages: wire,
|
|
324
|
+
...(opts.tools?.length ? { tools: opts.tools, parallel_tool_calls: true } : {}),
|
|
325
|
+
...(opts.maxTokens ? { max_tokens: opts.maxTokens } : {}),
|
|
326
|
+
...(opts.reasoningEffort ? { reasoning_effort: opts.reasoningEffort } : {}),
|
|
327
|
+
stream: true,
|
|
328
|
+
stream_options: { include_usage: true },
|
|
329
|
+
});
|
|
330
|
+
out.droppedImages = fitImages(wire, body);
|
|
331
|
+
try {
|
|
332
|
+
bump();
|
|
333
|
+
const res = await this.request("/v1/chat/completions", {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
336
|
+
body: body(),
|
|
337
|
+
signal: ctl.signal,
|
|
338
|
+
}, (waiting) => (waiting ? clearTimeout(idle) : bump()), opts.onRetry);
|
|
339
|
+
started = true;
|
|
340
|
+
let done = false;
|
|
341
|
+
let skipped = 0;
|
|
342
|
+
for await (const ev of sseEvents(res.body, bump)) {
|
|
343
|
+
if (ev === "[DONE]") {
|
|
344
|
+
done = true;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
let chunk;
|
|
348
|
+
try {
|
|
349
|
+
chunk = JSON.parse(ev);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
skipped++; // one garbled event must not throw away a reply that is being paid for
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (chunk.error) {
|
|
356
|
+
throw new StreamError(200, chunk.error.code ?? "provider_error", chunk.error.message ?? "stream failed", chunk.error.request_id);
|
|
357
|
+
}
|
|
358
|
+
if (chunk.model)
|
|
359
|
+
out.model = chunk.model;
|
|
360
|
+
if (chunk.usage)
|
|
361
|
+
out.usage = chunk.usage;
|
|
362
|
+
const choice = chunk.choices?.[0];
|
|
363
|
+
if (!choice)
|
|
364
|
+
continue;
|
|
365
|
+
const d = choice.delta ?? {};
|
|
366
|
+
if (d.content) {
|
|
367
|
+
out.content += d.content;
|
|
368
|
+
opts.onText?.(d.content);
|
|
369
|
+
}
|
|
370
|
+
if (typeof d.reasoning_content === "string" && d.reasoning_content)
|
|
371
|
+
opts.onReasoning?.(d.reasoning_content);
|
|
372
|
+
if (Array.isArray(d.reasoning_details))
|
|
373
|
+
reasoning.push(...d.reasoning_details);
|
|
374
|
+
for (const tc of d.tool_calls ?? []) {
|
|
375
|
+
const cur = acc.get(tc.index) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
|
|
376
|
+
if (tc.id)
|
|
377
|
+
cur.id = tc.id;
|
|
378
|
+
if (tc.function?.name)
|
|
379
|
+
cur.function.name += tc.function.name;
|
|
380
|
+
if (tc.function?.arguments)
|
|
381
|
+
cur.function.arguments += tc.function.arguments;
|
|
382
|
+
acc.set(tc.index, cur);
|
|
383
|
+
}
|
|
384
|
+
if (choice.finish_reason)
|
|
385
|
+
out.finish_reason = choice.finish_reason;
|
|
386
|
+
if (chunk.oicki?.finish_detail)
|
|
387
|
+
out.finish_detail = chunk.oicki.finish_detail;
|
|
388
|
+
}
|
|
389
|
+
// A stream that just stops (no finish reason and no [DONE]) was cut off, not finished: a gateway or connection
|
|
390
|
+
// ended it. Treating it as complete would record half an answer as the final one.
|
|
391
|
+
if (!done && out.finish_reason === null)
|
|
392
|
+
throw new StreamError(200, "stream_incomplete", "The response stream ended before the reply was complete (the connection was closed early).");
|
|
393
|
+
out.tool_calls = [...acc.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
|
|
394
|
+
if (reasoning.length)
|
|
395
|
+
out.reasoning_details = mergeReasoning(reasoning);
|
|
396
|
+
if (skipped)
|
|
397
|
+
out.skippedEvents = skipped;
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
if (opts.signal?.aborted) {
|
|
402
|
+
out.aborted = true; // user interrupt: hand back what streamed so far
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
if (timedOut)
|
|
406
|
+
throw withPartial(new StreamError(0, "timeout", `No data from the server for ${(opts.idleTimeoutMs ?? 90_000) / 1000}s.`));
|
|
407
|
+
if (e instanceof OickiError)
|
|
408
|
+
throw withPartial(e);
|
|
409
|
+
const cause = e.cause;
|
|
410
|
+
throw withPartial(new OickiError(0, "network_error", `Network error: ${e.message}${cause ? ` (${cause.code ?? cause.message})` : ""}`));
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
clearTimeout(idle);
|
|
414
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** OICKI refuses chat bodies over 4,000,000 bytes and more than 50 images; stay a little under. */
|
|
419
|
+
export const MAX_BODY_BYTES = 3_900_000;
|
|
420
|
+
export const MAX_IMAGES = 50;
|
|
421
|
+
/** Replaces the oldest image parts with a short note until the request fits. Returns how many were left out. */
|
|
422
|
+
export function fitImages(wire, body) {
|
|
423
|
+
const parts = wire.flatMap((m) => (Array.isArray(m.content) ? m.content.filter((p) => p.type === "image_url") : []));
|
|
424
|
+
let dropped = 0;
|
|
425
|
+
const tooBig = () => parts.length - dropped > MAX_IMAGES || Buffer.byteLength(body()) > MAX_BODY_BYTES;
|
|
426
|
+
while (dropped < parts.length && tooBig()) {
|
|
427
|
+
const p = parts[dropped++];
|
|
428
|
+
delete p.image_url;
|
|
429
|
+
p.type = "text";
|
|
430
|
+
p.text = "[image left out: the request would be over OICKI's size limit]";
|
|
431
|
+
}
|
|
432
|
+
return dropped;
|
|
433
|
+
}
|
|
434
|
+
async function parseError(res) {
|
|
435
|
+
const rid = res.headers.get("x-request-id") ?? undefined;
|
|
436
|
+
let code = `http_${res.status}`;
|
|
437
|
+
let message = res.statusText || `HTTP ${res.status}`;
|
|
438
|
+
try {
|
|
439
|
+
const body = (await res.json());
|
|
440
|
+
if (body?.error && typeof body.error === "object") {
|
|
441
|
+
code = body.error.code ?? code;
|
|
442
|
+
message = body.error.message ?? message;
|
|
443
|
+
}
|
|
444
|
+
else if (typeof body?.error === "string") {
|
|
445
|
+
code = body.error; // OAuth-style
|
|
446
|
+
message = body.error_description ?? message;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch { }
|
|
450
|
+
return new OickiError(res.status, code, message, rid);
|
|
451
|
+
}
|
|
452
|
+
/** Yields the `data:` payload of each server-sent event. */
|
|
453
|
+
export async function* sseEvents(body, onBytes) {
|
|
454
|
+
const dec = new TextDecoder();
|
|
455
|
+
let buf = "";
|
|
456
|
+
for await (const part of body) {
|
|
457
|
+
onBytes?.();
|
|
458
|
+
buf += dec.decode(part, { stream: true });
|
|
459
|
+
let i;
|
|
460
|
+
while ((i = buf.search(/\r?\n\r?\n/)) >= 0) {
|
|
461
|
+
const block = buf.slice(0, i);
|
|
462
|
+
buf = buf.slice(i).replace(/^\r?\n\r?\n/, "");
|
|
463
|
+
const data = block
|
|
464
|
+
.split(/\r?\n/)
|
|
465
|
+
.filter((l) => l.startsWith("data:"))
|
|
466
|
+
.map((l) => l.slice(5).replace(/^ /, ""))
|
|
467
|
+
.join("\n");
|
|
468
|
+
if (data)
|
|
469
|
+
yield data;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const tail = buf.split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
|
|
473
|
+
if (tail)
|
|
474
|
+
yield tail;
|
|
475
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Cleans command output before it reaches the model: no terminal escapes, no progress-bar rewrites, no endless repeats. */
|
|
2
|
+
const ANSI = /\x1b\[[0-?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Z0-9]|\x1b[=>78]/g;
|
|
3
|
+
export const stripEscapes = (s) => s.replace(ANSI, "");
|
|
4
|
+
const HEAD = 60;
|
|
5
|
+
const TAIL = 300;
|
|
6
|
+
/**
|
|
7
|
+
* At most `maxBytes` of `text`, keeping both ends: compilers print the first (root) error at the top and test runners
|
|
8
|
+
* print the summary at the bottom. A third comes from the start and two thirds from the end, cut on line boundaries
|
|
9
|
+
* where possible and never inside a UTF-8 character.
|
|
10
|
+
*/
|
|
11
|
+
export function keepEnds(text, maxBytes) {
|
|
12
|
+
const buf = Buffer.from(text);
|
|
13
|
+
if (buf.length <= maxBytes)
|
|
14
|
+
return { text, truncated: false };
|
|
15
|
+
let headEnd = Math.floor(maxBytes / 3);
|
|
16
|
+
while (headEnd > 0 && (buf[headEnd] & 0xc0) === 0x80)
|
|
17
|
+
headEnd--; // back to the start of a character
|
|
18
|
+
let tailStart = buf.length - (maxBytes - Math.floor(maxBytes / 3));
|
|
19
|
+
while (tailStart < buf.length && (buf[tailStart] & 0xc0) === 0x80)
|
|
20
|
+
tailStart++;
|
|
21
|
+
let head = buf.subarray(0, headEnd).toString("utf8");
|
|
22
|
+
let tail = buf.subarray(tailStart).toString("utf8");
|
|
23
|
+
const nl = head.lastIndexOf("\n");
|
|
24
|
+
if (nl > head.length / 2)
|
|
25
|
+
head = head.slice(0, nl);
|
|
26
|
+
const nl2 = tail.indexOf("\n");
|
|
27
|
+
if (nl2 >= 0 && nl2 < tail.length / 2)
|
|
28
|
+
tail = tail.slice(nl2 + 1);
|
|
29
|
+
const omitted = buf.length - Buffer.byteLength(head) - Buffer.byteLength(tail);
|
|
30
|
+
return { text: `${head}\n… ${omitted.toLocaleString("en-US")} bytes omitted from the middle …\n${tail}`, truncated: true };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Collects a stream's bytes in bounded memory: the first `head` bytes and the last `tail` bytes, counting what was
|
|
34
|
+
* dropped in between. Keeping only the end would lose the first error of a long build.
|
|
35
|
+
*/
|
|
36
|
+
export class HeadTail {
|
|
37
|
+
headBuf = [];
|
|
38
|
+
headLen = 0;
|
|
39
|
+
tailBuf = [];
|
|
40
|
+
tailLen = 0;
|
|
41
|
+
omitted = 0;
|
|
42
|
+
headMax;
|
|
43
|
+
tailMax;
|
|
44
|
+
constructor(headMax, tailMax) {
|
|
45
|
+
this.headMax = headMax;
|
|
46
|
+
this.tailMax = tailMax;
|
|
47
|
+
}
|
|
48
|
+
push(b) {
|
|
49
|
+
if (this.headLen < this.headMax) {
|
|
50
|
+
const take = b.subarray(0, this.headMax - this.headLen);
|
|
51
|
+
this.headBuf.push(take);
|
|
52
|
+
this.headLen += take.length;
|
|
53
|
+
b = b.subarray(take.length);
|
|
54
|
+
}
|
|
55
|
+
if (!b.length)
|
|
56
|
+
return;
|
|
57
|
+
this.tailBuf.push(b);
|
|
58
|
+
this.tailLen += b.length;
|
|
59
|
+
while (this.tailLen - this.tailBuf[0].length >= this.tailMax) {
|
|
60
|
+
this.omitted += this.tailBuf[0].length;
|
|
61
|
+
this.tailLen -= this.tailBuf.shift().length;
|
|
62
|
+
}
|
|
63
|
+
if (this.tailLen > this.tailMax) {
|
|
64
|
+
const extra = this.tailLen - this.tailMax;
|
|
65
|
+
this.tailBuf[0] = this.tailBuf[0].subarray(extra);
|
|
66
|
+
this.tailLen -= extra;
|
|
67
|
+
this.omitted += extra;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
text() {
|
|
71
|
+
const head = Buffer.concat(this.headBuf).toString("utf8");
|
|
72
|
+
const tail = Buffer.concat(this.tailBuf).toString("utf8");
|
|
73
|
+
return this.omitted ? `${head}\n… ${this.omitted.toLocaleString("en-US")} bytes omitted from the middle …\n${tail}` : head + tail;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** `changed` is true when anything was dropped or collapsed other than escapes, so the caller can keep the full text on disk. */
|
|
77
|
+
export function condenseOutput(raw) {
|
|
78
|
+
const lines = stripEscapes(raw)
|
|
79
|
+
.replace(/\r\n/g, "\n")
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((l) => (l.includes("\r") ? l.slice(l.lastIndexOf("\r", l.length - 2) + 1).replace(/\r$/, "") : l)); // progress bars: keep the last redraw
|
|
82
|
+
let changed = lines.length !== raw.split("\n").length;
|
|
83
|
+
const out = [];
|
|
84
|
+
for (let i = 0; i < lines.length;) {
|
|
85
|
+
let j = i + 1;
|
|
86
|
+
while (j < lines.length && lines[j] === lines[i])
|
|
87
|
+
j++;
|
|
88
|
+
if (j - i >= 4 && lines[i].trim()) {
|
|
89
|
+
out.push(lines[i], `… (the line above repeated ${j - i - 1} more times)`);
|
|
90
|
+
changed = true;
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
out.push(...lines.slice(i, j));
|
|
94
|
+
i = j;
|
|
95
|
+
}
|
|
96
|
+
if (out.length > HEAD + TAIL + 20) {
|
|
97
|
+
const cut = out.length - HEAD - TAIL;
|
|
98
|
+
return { text: [...out.slice(0, HEAD), `… ${cut} lines omitted from the middle …`, ...out.slice(-TAIL)].join("\n"), changed: true };
|
|
99
|
+
}
|
|
100
|
+
return { text: out.join("\n"), changed };
|
|
101
|
+
}
|