@giovannijecha/jecode 0.8.4 → 0.8.6

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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -0,0 +1,329 @@
1
+ // ABA-safe process ownership built from an atomic directory and one unique
2
+ // generation entry. No cleanup operation ever unlinks a shared fixed owner.
3
+ import { randomUUID } from "node:crypto";
4
+ import { lstat, mkdir, open, opendir, rmdir, unlink } from "node:fs/promises";
5
+ import * as path from "node:path";
6
+ import { fileIdentity, sameFileIdentity } from "./file-identity.js";
7
+ const OWNER_NAME = /^owner-([1-9]\d*)-([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/;
8
+ const TOKEN = /^([1-9]\d*):([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/;
9
+ export class ProcessLeaseError extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "ProcessLeaseError";
13
+ }
14
+ }
15
+ class ProcessLeaseChangedError extends ProcessLeaseError {
16
+ }
17
+ export function processLeaseToken() {
18
+ return `${process.pid}:${randomUUID()}`;
19
+ }
20
+ /** Create a new generation in a directory that must not already exist. */
21
+ export async function createProcessLeaseDirectory(directory, token, mode = 0o700, hooks = {}) {
22
+ const entry = entryForToken(token);
23
+ await mkdir(directory, { mode });
24
+ const directoryIdentity = await requireDirectoryIdentity(directory);
25
+ let generation;
26
+ try {
27
+ await assertDirectoryIdentity(directory, directoryIdentity);
28
+ await hooks.beforeOwnerOpen?.();
29
+ const ownerFile = path.join(directory, entry);
30
+ const handle = await open(ownerFile, "wx", 0o600);
31
+ try {
32
+ const opened = await handle.stat({ bigint: true });
33
+ if (!opened.isFile() || opened.size !== 0n) {
34
+ throw new ProcessLeaseError("process lease owner is unsafe");
35
+ }
36
+ const linked = await lstat(ownerFile, { bigint: true });
37
+ const entryIdentity = fileIdentity(opened);
38
+ if (linked.isSymbolicLink() || !linked.isFile() || linked.size !== 0n ||
39
+ !sameFileIdentity(entryIdentity, fileIdentity(linked)))
40
+ throw new ProcessLeaseError("process lease owner changed while starting");
41
+ generation = Object.freeze({ token, entry, directoryIdentity, entryIdentity });
42
+ }
43
+ finally {
44
+ await handle.close();
45
+ }
46
+ await assertDirectoryIdentity(directory, directoryIdentity);
47
+ const owner = await inspectProcessLease(directory);
48
+ if (owner === undefined || owner.entry !== entry || owner.token !== token ||
49
+ !sameFileIdentity(owner.directoryIdentity, directoryIdentity) ||
50
+ generation === undefined ||
51
+ !sameFileIdentity(owner.entryIdentity, generation.entryIdentity))
52
+ throw new ProcessLeaseError("process lease generation lost ownership while starting");
53
+ return generation;
54
+ }
55
+ catch (error) {
56
+ if (generation !== undefined) {
57
+ await unlinkCreatedEntry(directory, generation).catch(() => undefined);
58
+ }
59
+ await removeEmptyDirectory(directory, directoryIdentity).catch(() => undefined);
60
+ throw error;
61
+ }
62
+ }
63
+ /** Acquire once, recovering only an observed dead generation. */
64
+ export async function tryAcquireProcessLease(directory, token, options = {}) {
65
+ const staleMs = boundedDelay(options.staleMs ?? 0);
66
+ const setupGraceMs = boundedDelay(options.setupGraceMs ?? 1_000);
67
+ for (let attempt = 0; attempt < 4; attempt++) {
68
+ try {
69
+ const generation = await createProcessLeaseDirectory(directory, token);
70
+ return processLease(directory, generation);
71
+ }
72
+ catch (error) {
73
+ if (error.code !== "EEXIST")
74
+ throw error;
75
+ }
76
+ const observation = await inspectDirectory(directory);
77
+ if (observation.kind === "changed")
78
+ continue;
79
+ if (observation.kind === "missing")
80
+ continue;
81
+ if (observation.kind === "empty") {
82
+ if (Date.now() - observation.modifiedAt < Math.max(staleMs, setupGraceMs)) {
83
+ return undefined;
84
+ }
85
+ await removeEmptyDirectory(directory, observation.directoryIdentity);
86
+ continue;
87
+ }
88
+ if (Date.now() - observation.owner.modifiedAt < staleMs ||
89
+ pidIsAlive(observation.owner.pid))
90
+ return undefined;
91
+ await removeObservedProcessLease(directory, observation.owner);
92
+ }
93
+ return undefined;
94
+ }
95
+ export function processLease(directory, generation) {
96
+ let released = false;
97
+ return Object.freeze({
98
+ token: generation.token,
99
+ assertOwned: async () => {
100
+ if (released)
101
+ throw new ProcessLeaseError("process lease is closed");
102
+ const owner = await inspectProcessLease(directory);
103
+ if (owner === undefined || owner.entry !== generation.entry ||
104
+ owner.token !== generation.token ||
105
+ !sameFileIdentity(owner.directoryIdentity, generation.directoryIdentity) ||
106
+ !sameFileIdentity(owner.entryIdentity, generation.entryIdentity))
107
+ throw new ProcessLeaseError("process lease is no longer owned by this process");
108
+ },
109
+ release: async () => {
110
+ if (released)
111
+ return true;
112
+ const removed = await removeObservedProcessLease(directory, generation);
113
+ if (removed)
114
+ released = true;
115
+ return removed;
116
+ },
117
+ });
118
+ }
119
+ export async function inspectProcessLease(directory, hooks = {}) {
120
+ for (let attempt = 0; attempt < 4; attempt++) {
121
+ const observation = await inspectDirectory(directory, hooks);
122
+ if (observation.kind === "changed")
123
+ continue;
124
+ if (observation.kind === "missing" || observation.kind === "empty")
125
+ return undefined;
126
+ return observation.owner;
127
+ }
128
+ throw new ProcessLeaseError("process lease kept changing during inspection");
129
+ }
130
+ /** Remove only this unique generation, never a later owner's entry. */
131
+ export async function removeProcessLease(directory, token) {
132
+ const entry = entryForToken(token);
133
+ let owner;
134
+ try {
135
+ owner = await inspectProcessLease(directory);
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return true;
140
+ throw error;
141
+ }
142
+ if (owner === undefined)
143
+ return false;
144
+ if (owner.entry !== entry || owner.token !== token)
145
+ return false;
146
+ return removeObservedProcessLease(directory, owner);
147
+ }
148
+ export async function removeObservedProcessLease(directory, generation) {
149
+ try {
150
+ await assertDirectoryIdentity(directory, generation.directoryIdentity);
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return true;
155
+ if (error instanceof ProcessLeaseError)
156
+ return false;
157
+ throw error;
158
+ }
159
+ const removed = await unlinkObserved(directory, generation);
160
+ if (!removed)
161
+ return false;
162
+ try {
163
+ await rmdir(directory);
164
+ return true;
165
+ }
166
+ catch (error) {
167
+ const code = error.code;
168
+ if (code === "ENOENT")
169
+ return true;
170
+ // Our unique generation is already gone. A delayed or newer generation
171
+ // now owns any remaining entry and must clean up its own directory.
172
+ if (code === "ENOTEMPTY" || code === "EEXIST")
173
+ return true;
174
+ throw error;
175
+ }
176
+ }
177
+ export function pidIsAlive(pid) {
178
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fff_ffff)
179
+ return false;
180
+ try {
181
+ process.kill(pid, 0);
182
+ return true;
183
+ }
184
+ catch (error) {
185
+ return error.code !== "ESRCH";
186
+ }
187
+ }
188
+ async function inspectDirectory(directory, hooks = {}) {
189
+ let details;
190
+ try {
191
+ details = await lstat(directory, { bigint: true });
192
+ }
193
+ catch (error) {
194
+ if (error.code === "ENOENT")
195
+ return { kind: "missing" };
196
+ throw error;
197
+ }
198
+ if (details.isSymbolicLink() || !details.isDirectory()) {
199
+ throw new ProcessLeaseError("process lease path is not a direct directory");
200
+ }
201
+ const directoryIdentity = fileIdentity(details);
202
+ try {
203
+ const names = [];
204
+ const handle = await opendir(directory);
205
+ for await (const entry of handle) {
206
+ names.push(entry.name);
207
+ if (names.length > 1)
208
+ break;
209
+ }
210
+ await assertDirectoryIdentity(directory, directoryIdentity);
211
+ if (names.length === 0) {
212
+ return { kind: "empty", directoryIdentity, modifiedAt: Number(details.mtimeMs) };
213
+ }
214
+ if (names.length !== 1)
215
+ throw new ProcessLeaseError("process lease has multiple owners");
216
+ await hooks.afterEntries?.();
217
+ const name = names[0];
218
+ const parsed = OWNER_NAME.exec(name);
219
+ if (parsed === null)
220
+ throw new ProcessLeaseError("process lease owner is invalid");
221
+ const pid = Number(parsed[1]);
222
+ if (!Number.isSafeInteger(pid) || pid > 0x7fff_ffff) {
223
+ throw new ProcessLeaseError("process lease owner is invalid");
224
+ }
225
+ const ownerFile = path.join(directory, name);
226
+ const owner = await lstat(ownerFile, { bigint: true });
227
+ if (owner.isSymbolicLink() || !owner.isFile() || owner.size !== 0n) {
228
+ throw new ProcessLeaseError("process lease owner is unsafe");
229
+ }
230
+ await assertDirectoryIdentity(directory, directoryIdentity);
231
+ return {
232
+ kind: "owned",
233
+ owner: Object.freeze({
234
+ pid,
235
+ token: `${parsed[1]}:${parsed[2]}`,
236
+ entry: name,
237
+ directoryIdentity,
238
+ entryIdentity: fileIdentity(owner),
239
+ modifiedAt: Number(owner.mtimeMs),
240
+ }),
241
+ };
242
+ }
243
+ catch (error) {
244
+ if (inspectionChanged(error))
245
+ return { kind: "changed" };
246
+ throw error;
247
+ }
248
+ }
249
+ async function unlinkObserved(directory, generation) {
250
+ try {
251
+ await assertDirectoryIdentity(directory, generation.directoryIdentity);
252
+ const ownerFile = path.join(directory, generation.entry);
253
+ const owner = await lstat(ownerFile, { bigint: true });
254
+ if (owner.isSymbolicLink() || !owner.isFile() || owner.size !== 0n ||
255
+ !sameFileIdentity(fileIdentity(owner), generation.entryIdentity))
256
+ return false;
257
+ await unlink(ownerFile);
258
+ return true;
259
+ }
260
+ catch (error) {
261
+ if (error.code === "ENOENT")
262
+ return false;
263
+ if (error instanceof ProcessLeaseError)
264
+ return false;
265
+ throw error;
266
+ }
267
+ }
268
+ /** Clean up only the unique entry this failed creator actually opened. */
269
+ async function unlinkCreatedEntry(directory, generation) {
270
+ try {
271
+ const ownerFile = path.join(directory, generation.entry);
272
+ const owner = await lstat(ownerFile, { bigint: true });
273
+ if (owner.isSymbolicLink() || !owner.isFile() || owner.size !== 0n ||
274
+ !sameFileIdentity(fileIdentity(owner), generation.entryIdentity))
275
+ return false;
276
+ await unlink(ownerFile);
277
+ return true;
278
+ }
279
+ catch (error) {
280
+ if (error.code === "ENOENT")
281
+ return true;
282
+ throw error;
283
+ }
284
+ }
285
+ async function removeEmptyDirectory(directory, identity) {
286
+ try {
287
+ await assertDirectoryIdentity(directory, identity);
288
+ await rmdir(directory);
289
+ return true;
290
+ }
291
+ catch (error) {
292
+ const code = error.code;
293
+ if (code === "ENOENT")
294
+ return true;
295
+ if (code === "ENOTEMPTY" || code === "EEXIST" || error instanceof ProcessLeaseError) {
296
+ return false;
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+ async function requireDirectoryIdentity(directory) {
302
+ const details = await lstat(directory, { bigint: true });
303
+ if (details.isSymbolicLink() || !details.isDirectory()) {
304
+ throw new ProcessLeaseError("process lease path is not a direct directory");
305
+ }
306
+ return fileIdentity(details);
307
+ }
308
+ async function assertDirectoryIdentity(directory, expected) {
309
+ const current = await requireDirectoryIdentity(directory);
310
+ if (!sameFileIdentity(current, expected)) {
311
+ throw new ProcessLeaseChangedError("process lease directory changed during acquisition");
312
+ }
313
+ }
314
+ function inspectionChanged(error) {
315
+ const code = error.code;
316
+ return code === "ENOENT" || code === "ENOTDIR" || error instanceof ProcessLeaseChangedError;
317
+ }
318
+ function entryForToken(token) {
319
+ const parsed = TOKEN.exec(token);
320
+ if (parsed === null)
321
+ throw new ProcessLeaseError("process lease token is invalid");
322
+ return `owner-${parsed[1]}-${parsed[2]}`;
323
+ }
324
+ function boundedDelay(value) {
325
+ if (!Number.isSafeInteger(value) || value < 0 || value > 24 * 60 * 60 * 1_000) {
326
+ throw new RangeError("process lease delay is invalid");
327
+ }
328
+ return value;
329
+ }
@@ -1,16 +1,12 @@
1
1
  // Provider access and connection management. Runtime selection lives in /models.
2
- import { saveCommandSettings } from "./command-settings.js";
3
2
  import { apiKeyCommand } from "./credential-commands.js";
4
3
  import { credentialSource } from "./credentials.js";
5
- import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
4
  import { openAIAccountHint } from "./openai-account.js";
7
5
  import { openAIAccountCommand } from "./openai-account-command.js";
8
- import { providerLabel } from "./provider-label.js";
6
+ import { providerLabel, providerRouteLabel } from "./provider-label.js";
9
7
  import { PROVIDERS } from "./providers/index.js";
10
- import { ollamaConnection } from "./providers/ollama.js";
11
8
  import { heading } from "./tui/picker.js";
12
- const OLLAMA_KEY = "OLLAMA_API_KEY";
13
- /** The single control plane for API keys, OAuth accounts, and Ollama endpoints. */
9
+ /** The single control plane for API keys and OAuth accounts. */
14
10
  export async function providersCommand(session, host) {
15
11
  const choose = chooser(host);
16
12
  if (choose === undefined)
@@ -20,9 +16,10 @@ export async function providersCommand(session, host) {
20
16
  while (true) {
21
17
  const index = await choose({
22
18
  title: [],
19
+ description: `Current route: ${providerRouteLabel(session.provider)} · access changes do not switch it; use /models`,
23
20
  options: groups.map((group) => ({
24
21
  label: group.label,
25
- value: providerCount(group.providers.length),
22
+ value: providerGroupHint(group, session.provider.id),
26
23
  })),
27
24
  index: selected,
28
25
  });
@@ -53,7 +50,7 @@ async function providerGroupCommand(group, session, host) {
53
50
  title: heading(group.label, "provider access", session.palette),
54
51
  options: group.providers.map((provider) => ({
55
52
  label: providerLabel(provider.id),
56
- value: providerAccessHint(provider),
53
+ value: `${provider.id === session.provider.id ? "current · " : ""}${providerAccessHint(provider)}`,
57
54
  })),
58
55
  index: selected,
59
56
  });
@@ -74,45 +71,22 @@ async function providerGroupCommand(group, session, host) {
74
71
  function providerCount(count) {
75
72
  return `${count} ${count === 1 ? "provider" : "providers"}`;
76
73
  }
74
+ function providerGroupHint(group, activeId) {
75
+ const active = group.providers.find((provider) => provider.id === activeId);
76
+ const count = providerCount(group.providers.length);
77
+ return active === undefined ? count : `${count} · current ${providerRouteLabel(active)}`;
78
+ }
77
79
  export function providerAccessHint(provider) {
78
- if (provider.id === "ollama") {
79
- const connection = ollamaConnection();
80
- if (connection.loopback)
81
- return `${ollamaConnectionHint()} · no key needed`;
82
- return `${ollamaConnectionHint()} · API key ${credentialSource(OLLAMA_KEY) ?? "missing"}`;
83
- }
84
80
  if (provider.auth.kind === "oauth")
85
81
  return openAIAccountHint();
86
82
  return `API key · ${credentialSource(provider.auth.keyVar) ?? "missing"}`;
87
83
  }
88
84
  async function manageProvider(provider, session, host) {
89
- if (provider.id === "ollama") {
90
- await ollamaProviderCommand(session, host);
91
- return;
92
- }
93
85
  if (provider.auth.kind === "oauth") {
94
86
  await openAIAccountCommand(session, host);
95
87
  return;
96
88
  }
97
- await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host);
98
- }
99
- async function ollamaProviderCommand(session, host) {
100
- if (host.choose === undefined)
101
- return;
102
- const index = await host.choose({
103
- title: heading("Ollama", "access and connection", session.palette),
104
- options: [
105
- { label: "connection", hint: ollamaConnectionHint() },
106
- { label: "API key", hint: credentialSource(OLLAMA_KEY) ?? "missing" },
107
- ],
108
- index: 0,
109
- });
110
- if (index === 0) {
111
- await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
112
- }
113
- else if (index === 1) {
114
- await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host);
115
- }
89
+ await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host, provider.id);
116
90
  }
117
91
  function chooser(host) {
118
92
  if (host.choose === undefined) {
@@ -3,23 +3,47 @@ import { redactCredentials } from "./credential-safety.js";
3
3
  import { providerLabel } from "./provider-label.js";
4
4
  import { leadingText } from "./text-boundary.js";
5
5
  import { terminalText } from "./ui/terminal-text.js";
6
- const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
6
+ import { providerFailureDetails, } from "./providers/failure.js";
7
7
  const MAX_MESSAGE_CHARS = 1_000;
8
8
  const MAX_REASON_CHARS = 500;
9
9
  const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
10
10
  export function providerFailure(provider, error, labelProvider = false) {
11
- if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
12
- return provider.location?.() === "local"
13
- ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
14
- : "Ollama is not reachable · check its connection in /providers";
11
+ const details = providerFailureDetails(provider.id, error);
12
+ let failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
13
+ if (details.requestId !== undefined)
14
+ failure += ` · request ${details.requestId}`;
15
+ const label = providerLabel(provider.id);
16
+ return labelProvider && !failure.startsWith(`${label} `) ? `${label}: ${failure}` : failure;
17
+ }
18
+ function actionableFailure(providerId, kind) {
19
+ switch (kind) {
20
+ case "authentication":
21
+ return "authentication failed";
22
+ case "billing":
23
+ return providerId === "openai"
24
+ ? "credits exhausted · add credits or choose another provider in /models"
25
+ : "billing limit reached · check provider billing or choose another provider in /models";
26
+ case "quota":
27
+ return "usage quota exhausted · check provider limits or choose another provider in /models";
28
+ case "rate-limit":
29
+ return "rate limit reached · retry later";
30
+ case "overload":
31
+ return "temporarily unavailable · retry later";
32
+ case "context":
33
+ return "context limit reached · compact the conversation or choose a larger-context model";
34
+ case "network":
35
+ return "network request failed · check the connection and retry";
36
+ case "unknown":
37
+ return undefined;
15
38
  }
39
+ }
40
+ function rawFailure(error) {
16
41
  const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
17
42
  const reason = providerReason(error);
18
43
  const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
19
44
  ? ` · ${reason}`
20
45
  : "";
21
- const failure = `${message}${detail}`;
22
- return labelProvider ? `${providerLabel(provider.id)}: ${failure}` : failure;
46
+ return `${message}${detail}`;
23
47
  }
24
48
  function providerReason(error) {
25
49
  const body = error.body;
@@ -1,10 +1,16 @@
1
1
  // Stable human-facing names for provider identifiers.
2
2
  export function providerLabel(id) {
3
3
  switch (id) {
4
- case "anthropic": return "Anthropic";
4
+ case "anthropic": return "Anthropic API";
5
5
  case "openai": return "OpenAI API";
6
- case "openai-codex": return "ChatGPT";
7
- case "ollama": return "Ollama";
6
+ case "openai-codex": return "OpenAI Account";
7
+ case "ollama": return "Ollama API";
8
8
  default: return id === "" ? "Provider" : `${id[0]?.toUpperCase() ?? ""}${id.slice(1)}`;
9
9
  }
10
10
  }
11
+ /** Name the actual connection a model request will use. */
12
+ export function providerRouteLabel(provider) {
13
+ if (provider.id === "openai")
14
+ return "OpenAI API · billed usage";
15
+ return providerLabel(provider.id);
16
+ }
@@ -5,6 +5,7 @@
5
5
  // exactly the array a non-streamed response would have returned. That matters
6
6
  // beyond display: thinking blocks carry a signature and must be echoed back
7
7
  // byte-for-byte on the next request.
8
+ import { providerWireError } from "./failure.js";
8
9
  import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
9
10
  import { toolInputFromJson } from "./tool-input.js";
10
11
  export async function assembleAnthropic(events, onStream) {
@@ -63,7 +64,9 @@ export async function assembleAnthropic(events, onStream) {
63
64
  break;
64
65
  }
65
66
  case "error": {
66
- throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
67
+ throw providerWireError("anthropic stream error", event.error?.message, {
68
+ type: event.error?.type,
69
+ });
67
70
  }
68
71
  case "message_stop":
69
72
  complete = true;
@@ -87,11 +87,16 @@ function normalizeUsage(data) {
87
87
  const usage = data.usage;
88
88
  if (usage === undefined)
89
89
  return undefined;
90
+ const uncached = wireTokenCount(usage.input_tokens);
91
+ const cached = wireTokenCount(usage.cache_read_input_tokens);
92
+ const cacheWrite = wireTokenCount(usage.cache_creation_input_tokens);
90
93
  return {
91
- inputTokens: wireTokenCount(usage.input_tokens),
94
+ // Anthropic reports these as disjoint buckets. Jecode's provider-neutral
95
+ // input count represents the complete request context, as OpenAI's does.
96
+ inputTokens: uncached + cached + cacheWrite,
92
97
  outputTokens: wireTokenCount(usage.output_tokens),
93
- cachedInputTokens: wireTokenCount(usage.cache_read_input_tokens),
94
- cacheWriteInputTokens: wireTokenCount(usage.cache_creation_input_tokens),
98
+ cachedInputTokens: cached,
99
+ cacheWriteInputTokens: cacheWrite,
95
100
  reasoningTokens: 0,
96
101
  };
97
102
  }
@@ -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,14 +60,18 @@ 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
- location: () => "cloud",
64
75
  async send(req) {
65
76
  const key = requireKey();
66
77
  const body = {
@@ -70,6 +81,9 @@ export const anthropic = {
70
81
  messages: req.messages.map(toWireMessage),
71
82
  tools: req.tools.map(toWireTool),
72
83
  stream: true,
84
+ ...(req.identity?.purpose === "compaction"
85
+ ? {}
86
+ : { cache_control: { type: "ephemeral" } }),
73
87
  };
74
88
  if (supportsAdaptiveThinking(req.model)) {
75
89
  body["thinking"] = { type: "adaptive", display: "summarized" };
@@ -80,18 +94,23 @@ export const anthropic = {
80
94
  effort: requireSupportedEffort(req.model, req.effort, efforts),
81
95
  };
82
96
  }
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);
97
+ try {
98
+ const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus, undefined, (error) => isRetryableGenerationFailure(ID, error));
99
+ const data = await assembleAnthropic(events, req.onStream);
100
+ // A refusal or a truncation never arrives as streamed text, so it has to
101
+ // be announced separately or the user watches the turn end in silence.
102
+ const notice = stopNotice(data);
103
+ if (notice !== undefined)
104
+ req.onStream?.({ kind: "text", text: `\n${notice}` });
105
+ return fromWireResponse(data);
106
+ }
107
+ catch (error) {
108
+ throwProviderError(ID, req.signal, error);
109
+ }
91
110
  },
92
111
  };
93
112
  async function loadModels(signal, onStatus) {
94
- const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus);
113
+ const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus, (error) => isRetryableReadFailure(ID, error));
95
114
  return {
96
115
  ids: entries.map((entry) => entry.id),
97
116
  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`);