@giovannijecha/jecode 0.8.3 → 0.8.5
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 +11 -8
- package/assets/wordmark-steel.svg +3 -0
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +49 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +8 -4
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +22 -6
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +13 -2
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +53 -10
- package/dist/provider-errors.js +94 -3
- package/dist/provider-label.js +13 -0
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -19
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +86 -57
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +31 -19
- package/dist/providers/openai-codex.js +67 -41
- package/dist/providers/openai-stream.js +69 -9
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +89 -17
- package/dist/request-identity.js +32 -0
- package/dist/sessions/catalog.js +199 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +451 -183
- package/dist/settings.js +62 -10
- package/dist/stable-directory.js +148 -0
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/fs.js +124 -102
- package/dist/tools/search.js +81 -107
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +32 -4
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +4 -4
- package/dist/tools/ripgrep.js +0 -230
|
@@ -11,11 +11,13 @@ import { modelCatalog } from "./catalog.js";
|
|
|
11
11
|
import { keyFor } from "../credentials.js";
|
|
12
12
|
import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
13
13
|
import { assembleAnthropic } from "./anthropic-stream.js";
|
|
14
|
+
import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
|
|
14
15
|
import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
|
|
15
16
|
const ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
16
17
|
const MODELS = "https://api.anthropic.com/v1/models?limit=100";
|
|
17
18
|
const API_VERSION = "2023-06-01";
|
|
18
19
|
const KEY = "ANTHROPIC_API_KEY";
|
|
20
|
+
const ID = "anthropic";
|
|
19
21
|
const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
|
|
20
22
|
const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
|
|
21
23
|
const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
|
|
@@ -32,7 +34,7 @@ export function anthropicEfforts(model) {
|
|
|
32
34
|
return supportsAdaptiveThinking(model) ? EFFORTS : [];
|
|
33
35
|
}
|
|
34
36
|
export const anthropic = {
|
|
35
|
-
id:
|
|
37
|
+
id: ID,
|
|
36
38
|
// Sonnet is the default because it is the one that can be left running.
|
|
37
39
|
// Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
|
|
38
40
|
defaultModel: "claude-sonnet-5",
|
|
@@ -43,9 +45,14 @@ export const anthropic = {
|
|
|
43
45
|
// Newest first is how the endpoint already answers, so the order is left
|
|
44
46
|
// exactly as it arrives rather than re-sorted into something less useful.
|
|
45
47
|
async models(signal, onStatus) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
try {
|
|
49
|
+
const catalog = await loadModels(signal, onStatus);
|
|
50
|
+
contextByModel = catalog.contexts;
|
|
51
|
+
return catalog.ids;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throwProviderError(ID, signal, error);
|
|
55
|
+
}
|
|
49
56
|
},
|
|
50
57
|
async efforts(model) {
|
|
51
58
|
return anthropicEfforts(model);
|
|
@@ -53,12 +60,17 @@ export const anthropic = {
|
|
|
53
60
|
async contextWindow(model, signal, onStatus) {
|
|
54
61
|
if (contextByModel.has(model))
|
|
55
62
|
return contextByModel.get(model);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
contextByModel.
|
|
61
|
-
|
|
63
|
+
try {
|
|
64
|
+
const catalog = await loadModels(signal, onStatus);
|
|
65
|
+
contextByModel = catalog.contexts;
|
|
66
|
+
const context = contextByModel.get(model);
|
|
67
|
+
if (!contextByModel.has(model))
|
|
68
|
+
contextByModel.set(model, undefined);
|
|
69
|
+
return context;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throwProviderError(ID, signal, error);
|
|
73
|
+
}
|
|
62
74
|
},
|
|
63
75
|
location: () => "cloud",
|
|
64
76
|
async send(req) {
|
|
@@ -70,6 +82,9 @@ export const anthropic = {
|
|
|
70
82
|
messages: req.messages.map(toWireMessage),
|
|
71
83
|
tools: req.tools.map(toWireTool),
|
|
72
84
|
stream: true,
|
|
85
|
+
...(req.identity?.purpose === "compaction"
|
|
86
|
+
? {}
|
|
87
|
+
: { cache_control: { type: "ephemeral" } }),
|
|
73
88
|
};
|
|
74
89
|
if (supportsAdaptiveThinking(req.model)) {
|
|
75
90
|
body["thinking"] = { type: "adaptive", display: "summarized" };
|
|
@@ -80,18 +95,23 @@ export const anthropic = {
|
|
|
80
95
|
effort: requireSupportedEffort(req.model, req.effort, efforts),
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
try {
|
|
99
|
+
const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus, undefined, (error) => isRetryableGenerationFailure(ID, error));
|
|
100
|
+
const data = await assembleAnthropic(events, req.onStream);
|
|
101
|
+
// A refusal or a truncation never arrives as streamed text, so it has to
|
|
102
|
+
// be announced separately or the user watches the turn end in silence.
|
|
103
|
+
const notice = stopNotice(data);
|
|
104
|
+
if (notice !== undefined)
|
|
105
|
+
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
106
|
+
return fromWireResponse(data);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
throwProviderError(ID, req.signal, error);
|
|
110
|
+
}
|
|
91
111
|
},
|
|
92
112
|
};
|
|
93
113
|
async function loadModels(signal, onStatus) {
|
|
94
|
-
const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus);
|
|
114
|
+
const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
|
|
95
115
|
return {
|
|
96
116
|
ids: entries.map((entry) => entry.id),
|
|
97
117
|
contexts: new Map(entries.map((entry) => [
|
|
@@ -12,11 +12,11 @@ import { getJson } from "./http.js";
|
|
|
12
12
|
export const MAX_MODEL_CATALOG_ENTRIES = 1_000;
|
|
13
13
|
export const MAX_MODEL_CATALOG_ITEMS = 4_000;
|
|
14
14
|
export const MAX_MODEL_ID_CHARS = 256;
|
|
15
|
-
export async function listModels(url, headers, signal, onStatus) {
|
|
16
|
-
return (await modelCatalog(url, headers, signal, onStatus)).map((entry) => entry.id);
|
|
15
|
+
export async function listModels(url, headers, signal, onStatus, retry) {
|
|
16
|
+
return (await modelCatalog(url, headers, signal, onStatus, retry)).map((entry) => entry.id);
|
|
17
17
|
}
|
|
18
|
-
export async function modelCatalog(url, headers, signal, onStatus) {
|
|
19
|
-
const body = await getJson(url, headers, signal, onStatus);
|
|
18
|
+
export async function modelCatalog(url, headers, signal, onStatus, retry) {
|
|
19
|
+
const body = await getJson(url, headers, signal, onStatus, retry);
|
|
20
20
|
const data = body.data;
|
|
21
21
|
if (!Array.isArray(data))
|
|
22
22
|
throw new Error(`${url} did not return a model list`);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Provider failures normalized at adapter boundaries. The controller and TUI
|
|
2
|
+
// never need to understand a vendor response shape in order to make a safe
|
|
3
|
+
// retry decision or show an actionable error.
|
|
4
|
+
import { leadingText } from "../text-boundary.js";
|
|
5
|
+
const MAX_WIRE_ERROR_CHARS = 2_000;
|
|
6
|
+
/** One stable error contract for every provider adapter. */
|
|
7
|
+
export class ProviderRequestError extends Error {
|
|
8
|
+
providerId;
|
|
9
|
+
kind;
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
retryAfterMs;
|
|
13
|
+
requestId;
|
|
14
|
+
body;
|
|
15
|
+
constructor(providerId, source, details) {
|
|
16
|
+
super(source.message, { cause: source });
|
|
17
|
+
this.name = "ProviderRequestError";
|
|
18
|
+
this.providerId = providerId;
|
|
19
|
+
this.kind = details.kind;
|
|
20
|
+
this.status = details.status;
|
|
21
|
+
this.code = details.code;
|
|
22
|
+
this.retryAfterMs = details.retryAfterMs;
|
|
23
|
+
this.requestId = details.requestId;
|
|
24
|
+
this.body = source.body;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Preserve structured fields carried by an error event inside a live stream. */
|
|
28
|
+
export function providerWireError(prefix, message, metadata = {}) {
|
|
29
|
+
const detail = message === undefined ? "unspecified" : leadingText(message, MAX_WIRE_ERROR_CHARS);
|
|
30
|
+
const error = new Error(`${prefix}: ${detail}`);
|
|
31
|
+
const code = safeIdentifier(metadata.code);
|
|
32
|
+
const type = safeIdentifier(metadata.type);
|
|
33
|
+
if (code !== undefined)
|
|
34
|
+
error.code = code;
|
|
35
|
+
if (type !== undefined)
|
|
36
|
+
error.type = type;
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
export function normalizeProviderError(providerId, error) {
|
|
40
|
+
if (error instanceof ProviderRequestError)
|
|
41
|
+
return error;
|
|
42
|
+
const source = error instanceof Error ? error : new Error(String(error));
|
|
43
|
+
return new ProviderRequestError(providerId, source, providerFailureDetails(providerId, source));
|
|
44
|
+
}
|
|
45
|
+
/** Keep caller cancellation intact; normalize every other adapter failure. */
|
|
46
|
+
export function throwProviderError(providerId, signal, error) {
|
|
47
|
+
if (signal?.aborted === true)
|
|
48
|
+
throw error;
|
|
49
|
+
throw normalizeProviderError(providerId, error);
|
|
50
|
+
}
|
|
51
|
+
export function providerFailureDetails(providerId, error) {
|
|
52
|
+
if (error instanceof ProviderRequestError) {
|
|
53
|
+
return compact({
|
|
54
|
+
kind: error.kind,
|
|
55
|
+
status: error.status,
|
|
56
|
+
code: error.code,
|
|
57
|
+
retryAfterMs: error.retryAfterMs,
|
|
58
|
+
requestId: error.requestId,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const http = error;
|
|
62
|
+
const wire = error;
|
|
63
|
+
const body = bodyError(http.body);
|
|
64
|
+
const code = firstIdentifier(wire.code, body.code);
|
|
65
|
+
const type = firstIdentifier(wire.type, body.type);
|
|
66
|
+
const evidence = [code, type, leadingText(error.message, 4_000), body.message]
|
|
67
|
+
.filter((value) => value !== undefined)
|
|
68
|
+
.join(" ")
|
|
69
|
+
.toLocaleLowerCase();
|
|
70
|
+
const status = number(http.status);
|
|
71
|
+
const kind = classify(providerId, status, evidence);
|
|
72
|
+
return compact({
|
|
73
|
+
kind,
|
|
74
|
+
status,
|
|
75
|
+
code,
|
|
76
|
+
retryAfterMs: number(http.retryAfterMs),
|
|
77
|
+
requestId: safeRequestId(http.requestId),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Generation replay is allowed only for a definite transient rate rejection. */
|
|
81
|
+
export function isRetryableGenerationFailure(providerId, error) {
|
|
82
|
+
return providerFailureDetails(providerId, error).kind === "rate-limit";
|
|
83
|
+
}
|
|
84
|
+
/** Idempotent reads may retry transient pressure, never account hard stops. */
|
|
85
|
+
export function isRetryableReadFailure(providerId, error) {
|
|
86
|
+
const kind = providerFailureDetails(providerId, error).kind;
|
|
87
|
+
return kind === "rate-limit" || kind === "overload" || kind === "network" || kind === "unknown";
|
|
88
|
+
}
|
|
89
|
+
function classify(providerId, status, evidence) {
|
|
90
|
+
if (providerId === "anthropic" &&
|
|
91
|
+
/enforced[_ -]?spend[_ -]?limit[_ -]?reached/u.test(evidence))
|
|
92
|
+
return "billing";
|
|
93
|
+
if ((providerId === "openai" || providerId === "openai-codex") &&
|
|
94
|
+
/billing[_ -]?hard[_ -]?limit[_ -]?reached/u.test(evidence))
|
|
95
|
+
return "billing";
|
|
96
|
+
if (providerId === "openai-codex" &&
|
|
97
|
+
/usage[_ -]?limit[_ -]?reached|plan limit/u.test(evidence))
|
|
98
|
+
return "quota";
|
|
99
|
+
if (/insufficient[_ -]?quota|usage quota|quota (?:exceeded|exhausted)/u.test(evidence)) {
|
|
100
|
+
return "quota";
|
|
101
|
+
}
|
|
102
|
+
if (status === 402 ||
|
|
103
|
+
/\b(?:no|zero) credits? remaining\b|\bcredits? exhausted\b|billing|payment required|(?:hard|spend)[_ -]?limit/u
|
|
104
|
+
.test(evidence))
|
|
105
|
+
return "billing";
|
|
106
|
+
if (status === 401 ||
|
|
107
|
+
/invalid[_ -]?(?:api[_ -]?)?key|authentication|unauthorized|invalid[_ -]?token|oauth/u
|
|
108
|
+
.test(evidence))
|
|
109
|
+
return "authentication";
|
|
110
|
+
if (/context[_ -]?(?:length|window)|maximum context|too many input tokens|prompt (?:is )?too long/u
|
|
111
|
+
.test(evidence))
|
|
112
|
+
return "context";
|
|
113
|
+
if (status === 429 ||
|
|
114
|
+
/rate[_ -]?limit|too many requests|tokens per min|requests per min|\btpm\b|\brpm\b/u
|
|
115
|
+
.test(evidence))
|
|
116
|
+
return "rate-limit";
|
|
117
|
+
if (status === 408 ||
|
|
118
|
+
status === 409 ||
|
|
119
|
+
status === 500 ||
|
|
120
|
+
status === 502 ||
|
|
121
|
+
status === 503 ||
|
|
122
|
+
status === 504 ||
|
|
123
|
+
status === 529 ||
|
|
124
|
+
/overload|temporarily unavailable|server is busy/u.test(evidence))
|
|
125
|
+
return "overload";
|
|
126
|
+
if (status === undefined &&
|
|
127
|
+
/network error calling|timed out waiting|response body was idle|stream was idle|fetch failed|socket/u
|
|
128
|
+
.test(evidence))
|
|
129
|
+
return "network";
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
function bodyError(body) {
|
|
133
|
+
if (body === undefined || body.trim() === "")
|
|
134
|
+
return {};
|
|
135
|
+
let parsed;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(body);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { message: body };
|
|
141
|
+
}
|
|
142
|
+
if (!record(parsed))
|
|
143
|
+
return {};
|
|
144
|
+
const nested = record(parsed["error"]) ? parsed["error"] : undefined;
|
|
145
|
+
const detail = record(parsed["detail"]) ? parsed["detail"] : undefined;
|
|
146
|
+
return compact({
|
|
147
|
+
code: firstText(nested?.["code"], detail?.["code"], parsed["code"]),
|
|
148
|
+
type: firstText(nested?.["type"], detail?.["type"], parsed["type"]),
|
|
149
|
+
message: firstText(nested?.["message"], typeof parsed["error"] === "string" ? parsed["error"] : undefined, detail?.["message"], typeof parsed["detail"] === "string" ? parsed["detail"] : undefined, parsed["message"]),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function firstText(...values) {
|
|
153
|
+
return values.find((value) => typeof value === "string" && value !== "");
|
|
154
|
+
}
|
|
155
|
+
function firstIdentifier(...values) {
|
|
156
|
+
for (const value of values) {
|
|
157
|
+
const identifier = safeIdentifier(value);
|
|
158
|
+
if (identifier !== undefined)
|
|
159
|
+
return identifier;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function safeIdentifier(value) {
|
|
164
|
+
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(value)
|
|
165
|
+
? value
|
|
166
|
+
: undefined;
|
|
167
|
+
}
|
|
168
|
+
function number(value) {
|
|
169
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
170
|
+
}
|
|
171
|
+
function safeRequestId(value) {
|
|
172
|
+
return typeof value === "string" && /^[A-Za-z0-9._:-]{1,256}$/u.test(value)
|
|
173
|
+
? value
|
|
174
|
+
: undefined;
|
|
175
|
+
}
|
|
176
|
+
function compact(value) {
|
|
177
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
178
|
+
}
|
|
179
|
+
function record(value) {
|
|
180
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
181
|
+
}
|
package/dist/providers/http.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The entire HTTP layer: one bounded request, then either a JSON body or an
|
|
2
|
-
// event stream.
|
|
3
|
-
//
|
|
2
|
+
// event stream. Idempotent reads retry transient failures. A generation POST
|
|
3
|
+
// gets one retry only when its provider adapter classifies an explicit,
|
|
4
|
+
// delayed rejection as transient, before a stream exists. Ambiguous POST
|
|
5
|
+
// failures and failures after response bytes flow are surfaced.
|
|
4
6
|
import { leadingText } from "../text-boundary.js";
|
|
5
7
|
import { readSseJson } from "./sse.js";
|
|
6
8
|
import { sseStreamCharacterLimit } from "./stream-limits.js";
|
|
@@ -9,19 +11,26 @@ const MAX_JSON_CHARS = 5_000_000;
|
|
|
9
11
|
const MAX_ERROR_CHARS = 2_000;
|
|
10
12
|
const HANDSHAKE_TIMEOUT_MS = 60_000;
|
|
11
13
|
const BODY_IDLE_TIMEOUT_MS = 120_000;
|
|
14
|
+
const MODEL_PROGRESS_TIMEOUT_MS = 300_000;
|
|
12
15
|
const GET_RETRIES = 3;
|
|
13
|
-
|
|
16
|
+
const GENERATION_RATE_LIMIT_RETRIES = 1;
|
|
17
|
+
const MAX_RETRY_DELAY_MS = 60_000;
|
|
18
|
+
function httpError(message, status, body, metadata = {}) {
|
|
14
19
|
const error = new Error(message);
|
|
15
20
|
error.status = status;
|
|
16
21
|
error.body = body;
|
|
22
|
+
if (metadata.retryAfterMs !== undefined)
|
|
23
|
+
error.retryAfterMs = metadata.retryAfterMs;
|
|
24
|
+
if (metadata.requestId !== undefined)
|
|
25
|
+
error.requestId = metadata.requestId;
|
|
17
26
|
return error;
|
|
18
27
|
}
|
|
19
28
|
export async function postJson(url, headers, body, signal, onStatus) {
|
|
20
29
|
return asJson(url, await request(url, headers, body, signal, onStatus));
|
|
21
30
|
}
|
|
22
31
|
/** A plain read. The only thing jecode asks for without sending anything. */
|
|
23
|
-
export async function getJson(url, headers, signal, onStatus) {
|
|
24
|
-
return asJson(url, await request(url, headers, undefined, signal, onStatus));
|
|
32
|
+
export async function getJson(url, headers, signal, onStatus, retry) {
|
|
33
|
+
return asJson(url, await request(url, headers, undefined, signal, onStatus, 0, undefined, retry));
|
|
25
34
|
}
|
|
26
35
|
async function asJson(url, res) {
|
|
27
36
|
const { text, truncated } = await boundedText(url, res, MAX_JSON_CHARS);
|
|
@@ -35,20 +44,39 @@ async function asJson(url, res) {
|
|
|
35
44
|
throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
|
|
36
45
|
}
|
|
37
46
|
}
|
|
38
|
-
export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
|
|
47
|
+
export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus, progress, retry) {
|
|
39
48
|
const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
|
|
40
|
-
|
|
49
|
+
onStatus?.("Connecting");
|
|
50
|
+
const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus, GENERATION_RATE_LIMIT_RETRIES, retry);
|
|
41
51
|
if (res.body === null)
|
|
42
52
|
throw httpError(`${url} returned no body`, res.status);
|
|
43
|
-
|
|
53
|
+
onStatus?.("Waiting for model");
|
|
54
|
+
return readSseJson(res.body, maximumChars, {
|
|
55
|
+
milliseconds: BODY_IDLE_TIMEOUT_MS,
|
|
56
|
+
error: () => httpError(`${url} SSE stream was idle for ${BODY_IDLE_TIMEOUT_MS}ms without an event`, res.status),
|
|
57
|
+
...(progress === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: {
|
|
60
|
+
progress: {
|
|
61
|
+
milliseconds: MODEL_PROGRESS_TIMEOUT_MS,
|
|
62
|
+
observed: progress,
|
|
63
|
+
error: () => httpError(`${url} SSE stream made no model progress for ${MODEL_PROGRESS_TIMEOUT_MS}ms`, res.status),
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
44
67
|
}
|
|
45
|
-
async function request(url, headers, body, signal, onStatus) {
|
|
46
|
-
const
|
|
68
|
+
async function request(url, headers, body, signal, onStatus, generationRetries = 0, generationRetry, readRetry) {
|
|
69
|
+
const read = body === undefined;
|
|
70
|
+
const maxRetries = read ? GET_RETRIES : generationRetries;
|
|
47
71
|
let lastError;
|
|
48
72
|
let waitMs = 0;
|
|
49
73
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
50
|
-
if (
|
|
51
|
-
|
|
74
|
+
if (attempt > 0) {
|
|
75
|
+
if (waitMs > 0)
|
|
76
|
+
await sleep(waitMs, signal);
|
|
77
|
+
if (!read)
|
|
78
|
+
onStatus?.("Connecting");
|
|
79
|
+
}
|
|
52
80
|
let res;
|
|
53
81
|
const handshake = handshakeSignal(url, signal);
|
|
54
82
|
try {
|
|
@@ -70,9 +98,10 @@ async function request(url, headers, body, signal, onStatus) {
|
|
|
70
98
|
throw cause;
|
|
71
99
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
72
100
|
lastError = httpError(`network error calling ${url}: ${detail}`);
|
|
101
|
+
if (!read || attempt >= maxRetries)
|
|
102
|
+
throw lastError;
|
|
73
103
|
waitMs = backoff(attempt);
|
|
74
|
-
|
|
75
|
-
onStatus?.(`Network error · retrying in ${waitLabel(waitMs)}`);
|
|
104
|
+
onStatus?.(`Network error · retrying in ${waitLabel(waitMs)}`);
|
|
76
105
|
continue;
|
|
77
106
|
}
|
|
78
107
|
finally {
|
|
@@ -85,14 +114,22 @@ async function request(url, headers, body, signal, onStatus) {
|
|
|
85
114
|
if (res.ok)
|
|
86
115
|
return res;
|
|
87
116
|
const { text } = await boundedText(url, res, MAX_ERROR_CHARS);
|
|
88
|
-
|
|
89
|
-
|
|
117
|
+
const providerDelay = retryAfter(res) ?? retryAfterMessage(text);
|
|
118
|
+
const requestId = responseRequestId(res);
|
|
119
|
+
lastError = httpError(`${url} -> ${res.status} ${res.statusText}`, res.status, text, {
|
|
120
|
+
...(providerDelay === undefined ? {} : { retryAfterMs: providerDelay }),
|
|
121
|
+
...(requestId === undefined ? {} : { requestId }),
|
|
122
|
+
});
|
|
123
|
+
const retryable = read
|
|
124
|
+
? RETRYABLE.has(res.status) && (readRetry?.(lastError) ?? true)
|
|
125
|
+
: generationRetries > 0 &&
|
|
126
|
+
providerDelay !== undefined &&
|
|
127
|
+
generationRetry?.(lastError) === true;
|
|
128
|
+
if (!retryable || attempt >= maxRetries)
|
|
90
129
|
throw lastError;
|
|
91
|
-
waitMs =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
onStatus?.(`${reason} · retrying in ${waitLabel(waitMs)}`);
|
|
95
|
-
}
|
|
130
|
+
waitMs = providerDelay ?? backoff(attempt);
|
|
131
|
+
const reason = res.status === 429 ? "Rate limited" : `HTTP ${res.status}`;
|
|
132
|
+
onStatus?.(`${reason} · retrying in ${waitLabel(waitMs)}`);
|
|
96
133
|
}
|
|
97
134
|
throw lastError ?? httpError(`${url} failed`);
|
|
98
135
|
}
|
|
@@ -154,39 +191,6 @@ async function timedRead(url, reader) {
|
|
|
154
191
|
clearTimeout(timer);
|
|
155
192
|
}
|
|
156
193
|
}
|
|
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
194
|
function waitLabel(ms) {
|
|
191
195
|
return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
|
|
192
196
|
}
|
|
@@ -198,10 +202,35 @@ function retryAfter(res) {
|
|
|
198
202
|
if (header === null)
|
|
199
203
|
return undefined;
|
|
200
204
|
const seconds = Number(header);
|
|
201
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
202
|
-
return Math.min(
|
|
205
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
206
|
+
return Math.min(MAX_RETRY_DELAY_MS, seconds * 1_000);
|
|
207
|
+
}
|
|
203
208
|
const at = Date.parse(header);
|
|
204
|
-
return Number.isNaN(at)
|
|
209
|
+
return Number.isNaN(at)
|
|
210
|
+
? undefined
|
|
211
|
+
: Math.max(0, Math.min(MAX_RETRY_DELAY_MS, at - Date.now()));
|
|
212
|
+
}
|
|
213
|
+
function retryAfterMessage(body) {
|
|
214
|
+
const match = /\btry again in\s+(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|s)\b/iu
|
|
215
|
+
.exec(body);
|
|
216
|
+
if (match === null)
|
|
217
|
+
return undefined;
|
|
218
|
+
const amount = Number(match[1]);
|
|
219
|
+
if (!Number.isFinite(amount) || amount < 0)
|
|
220
|
+
return undefined;
|
|
221
|
+
const unit = match[2]?.toLowerCase();
|
|
222
|
+
const milliseconds = unit === "ms" || unit?.startsWith("millisecond") === true
|
|
223
|
+
? amount
|
|
224
|
+
: amount * 1_000;
|
|
225
|
+
return Math.min(MAX_RETRY_DELAY_MS, Math.ceil(milliseconds));
|
|
226
|
+
}
|
|
227
|
+
function responseRequestId(res) {
|
|
228
|
+
for (const name of ["x-request-id", "request-id"]) {
|
|
229
|
+
const value = res.headers.get(name)?.trim();
|
|
230
|
+
if (value !== undefined && /^[A-Za-z0-9._:-]{1,256}$/u.test(value))
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
205
234
|
}
|
|
206
235
|
function sleep(ms, signal) {
|
|
207
236
|
return new Promise((resolve, reject) => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Nothing arrives finished here: text accumulates, and each tool call is
|
|
4
4
|
// spread across chunks keyed by `index` — id and name usually in the first,
|
|
5
5
|
// arguments as JSON fragments after it.
|
|
6
|
+
import { providerWireError } from "./failure.js";
|
|
6
7
|
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
7
8
|
export async function assembleOllama(events, onStream) {
|
|
8
9
|
const calls = new Map();
|
|
@@ -16,7 +17,10 @@ export async function assembleOllama(events, onStream) {
|
|
|
16
17
|
const event = raw;
|
|
17
18
|
if (event.error !== undefined) {
|
|
18
19
|
const message = typeof event.error === "string" ? event.error : event.error.message;
|
|
19
|
-
throw
|
|
20
|
+
throw providerWireError("ollama stream error", message, {
|
|
21
|
+
code: typeof event.error === "string" ? undefined : event.error.code,
|
|
22
|
+
type: typeof event.error === "string" ? undefined : event.error.type,
|
|
23
|
+
});
|
|
20
24
|
}
|
|
21
25
|
if (event.usage !== undefined)
|
|
22
26
|
usage = event.usage;
|
package/dist/providers/ollama.js
CHANGED
|
@@ -10,9 +10,11 @@ import { getJson, postJson, postSse } from "./http.js";
|
|
|
10
10
|
import { listModels } from "./catalog.js";
|
|
11
11
|
import { keyFor } from "../credentials.js";
|
|
12
12
|
import { assembleOllama } from "./ollama-stream.js";
|
|
13
|
+
import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
|
|
13
14
|
import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
|
|
14
15
|
import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
|
|
15
16
|
const KEY = "OLLAMA_API_KEY";
|
|
17
|
+
const ID = "ollama";
|
|
16
18
|
// Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
|
|
17
19
|
const OLLAMA_EFFORTS = ["low", "medium", "high"];
|
|
18
20
|
let configuredHost;
|
|
@@ -29,7 +31,7 @@ export function ollamaConnection() {
|
|
|
29
31
|
return { ...endpoint, kind: ollamaConnectionKind(endpoint), inferred };
|
|
30
32
|
}
|
|
31
33
|
export const ollama = {
|
|
32
|
-
id:
|
|
34
|
+
id: ID,
|
|
33
35
|
defaultModel: "",
|
|
34
36
|
auth: { kind: "api-key", keyVar: KEY },
|
|
35
37
|
// The only provider whose key is conditional: a daemon on this machine is
|
|
@@ -47,9 +49,14 @@ export const ollama = {
|
|
|
47
49
|
}
|
|
48
50
|
},
|
|
49
51
|
// Whatever the daemon has pulled, or whatever the subscription grants.
|
|
50
|
-
models(signal, onStatus) {
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
async models(signal, onStatus) {
|
|
53
|
+
try {
|
|
54
|
+
const at = endpoint();
|
|
55
|
+
return await listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throwProviderError(ID, signal, error);
|
|
59
|
+
}
|
|
53
60
|
},
|
|
54
61
|
async efforts() {
|
|
55
62
|
return OLLAMA_EFFORTS;
|
|
@@ -83,25 +90,30 @@ export const ollama = {
|
|
|
83
90
|
const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
|
|
84
91
|
// The OpenAI-compatible endpoint accepts this vocabulary for thinking
|
|
85
92
|
// models. Invalid levels are rejected locally instead of being rewritten.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
try {
|
|
94
|
+
const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
|
|
95
|
+
model: req.model,
|
|
96
|
+
messages: toWireMessages(req.system, req.messages),
|
|
97
|
+
tools: req.tools.map(toWireTool),
|
|
98
|
+
max_tokens: req.maxTokens,
|
|
99
|
+
reasoning_effort: effort,
|
|
100
|
+
stream: true,
|
|
101
|
+
stream_options: { include_usage: true },
|
|
102
|
+
}, req.maxTokens, req.signal, req.onStatus, undefined, (error) => isRetryableGenerationFailure(ID, error));
|
|
103
|
+
const reply = await assembleOllama(events, req.onStream);
|
|
104
|
+
const notice = stopNotice(reply);
|
|
105
|
+
if (notice !== undefined)
|
|
106
|
+
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
107
|
+
return fromWireReply(reply);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throwProviderError(ID, req.signal, error);
|
|
111
|
+
}
|
|
100
112
|
},
|
|
101
113
|
};
|
|
102
114
|
async function nativeContextWindow(at, model, fallback, signal, onStatus) {
|
|
103
115
|
try {
|
|
104
|
-
const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus);
|
|
116
|
+
const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
|
|
105
117
|
const allocated = runningContext(running, model);
|
|
106
118
|
if (allocated !== undefined)
|
|
107
119
|
return { value: usableContext(allocated), runtime: true };
|