@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.
Files changed (55) hide show
  1. package/README.md +11 -8
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +47 -10
  4. package/dist/atomic.js +24 -14
  5. package/dist/batch.js +49 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/commands.js +2 -0
  8. package/dist/config.js +8 -4
  9. package/dist/context/automatic.js +35 -0
  10. package/dist/context/compactor.js +32 -2
  11. package/dist/context/manual.js +20 -3
  12. package/dist/context/request-projection.js +130 -0
  13. package/dist/controller-request.js +33 -11
  14. package/dist/credential-commands.js +22 -6
  15. package/dist/credentials.js +56 -18
  16. package/dist/directory-anchor.js +91 -0
  17. package/dist/file-identity.js +12 -0
  18. package/dist/model-command.js +5 -4
  19. package/dist/openai-account-command.js +13 -2
  20. package/dist/process-lease.js +329 -0
  21. package/dist/provider-commands.js +53 -10
  22. package/dist/provider-errors.js +94 -3
  23. package/dist/provider-label.js +13 -0
  24. package/dist/providers/anthropic-stream.js +4 -1
  25. package/dist/providers/anthropic-wire.js +8 -3
  26. package/dist/providers/anthropic.js +39 -19
  27. package/dist/providers/catalog.js +4 -4
  28. package/dist/providers/failure.js +181 -0
  29. package/dist/providers/http.js +86 -57
  30. package/dist/providers/ollama-stream.js +5 -1
  31. package/dist/providers/ollama.js +31 -19
  32. package/dist/providers/openai-codex.js +67 -41
  33. package/dist/providers/openai-stream.js +69 -9
  34. package/dist/providers/openai.js +51 -24
  35. package/dist/providers/sse.js +89 -17
  36. package/dist/request-identity.js +32 -0
  37. package/dist/sessions/catalog.js +199 -0
  38. package/dist/sessions/lease.js +132 -49
  39. package/dist/sessions/runtime.js +15 -8
  40. package/dist/sessions/store.js +451 -183
  41. package/dist/settings.js +62 -10
  42. package/dist/stable-directory.js +148 -0
  43. package/dist/store-lock.js +68 -84
  44. package/dist/tools/args.js +2 -2
  45. package/dist/tools/fs.js +124 -102
  46. package/dist/tools/search.js +81 -107
  47. package/dist/tools/text-boundary.js +7 -33
  48. package/dist/tui/app-workflows.js +32 -4
  49. package/dist/tui/components/footer.js +1 -1
  50. package/dist/tui/feedback.js +4 -0
  51. package/dist/tui/session-view.js +7 -2
  52. package/dist/tui/workspace.js +21 -7
  53. package/dist/user-store.js +23 -31
  54. package/package.json +4 -4
  55. 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: "anthropic",
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
- const catalog = await loadModels(signal, onStatus);
47
- contextByModel = catalog.contexts;
48
- return catalog.ids;
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
- const catalog = await loadModels(signal, onStatus);
57
- contextByModel = catalog.contexts;
58
- const context = contextByModel.get(model);
59
- if (!contextByModel.has(model))
60
- contextByModel.set(model, undefined);
61
- return context;
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
- const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus);
84
- const data = await assembleAnthropic(events, req.onStream);
85
- // A refusal or a truncation never arrives as streamed text, so it has to
86
- // be announced separately or the user watches the turn end in silence.
87
- const notice = stopNotice(data);
88
- if (notice !== undefined)
89
- req.onStream?.({ kind: "text", text: `\n${notice}` });
90
- return fromWireResponse(data);
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
+ }
@@ -1,6 +1,8 @@
1
1
  // The entire HTTP layer: one bounded request, then either a JSON body or an
2
- // event stream. Only idempotent reads retry. Once a POST starts or response
3
- // bytes flow, a failure is surfaced rather than silently replayed.
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
- function httpError(message, status, body) {
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
- const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
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
- return readSseJson(withIdleTimeout(url, res.body), maximumChars);
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 maxRetries = body === undefined ? GET_RETRIES : 0;
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 (waitMs > 0)
51
- await sleep(waitMs, signal);
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
- if (attempt < maxRetries)
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
- lastError = httpError(`${url} -> ${res.status} ${res.statusText}`, res.status, text);
89
- if (!RETRYABLE.has(res.status))
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 = retryAfter(res) ?? backoff(attempt);
92
- if (attempt < maxRetries) {
93
- const reason = res.status === 429 ? "Rate limited" : `HTTP ${res.status}`;
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(60_000, seconds * 1_000);
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) ? undefined : Math.max(0, Math.min(60_000, at - Date.now()));
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 new Error(`ollama stream error: ${message ?? "unspecified"}`);
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;
@@ -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: "ollama",
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
- const at = endpoint();
52
- return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
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
- const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
87
- model: req.model,
88
- messages: toWireMessages(req.system, req.messages),
89
- tools: req.tools.map(toWireTool),
90
- max_tokens: req.maxTokens,
91
- reasoning_effort: effort,
92
- stream: true,
93
- stream_options: { include_usage: true },
94
- }, req.maxTokens, req.signal, req.onStatus);
95
- const reply = await assembleOllama(events, req.onStream);
96
- const notice = stopNotice(reply);
97
- if (notice !== undefined)
98
- req.onStream?.({ kind: "text", text: `\n${notice}` });
99
- return fromWireReply(reply);
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 };