@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
package/dist/model-command.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { saveCommandSettings } from "./command-settings.js";
|
|
3
3
|
import { compatibleEffort } from "./effort.js";
|
|
4
4
|
import { providerFailure } from "./provider-errors.js";
|
|
5
|
-
import { providerLabel } from "./provider-label.js";
|
|
5
|
+
import { providerLabel, providerRouteLabel } from "./provider-label.js";
|
|
6
6
|
import { PROVIDERS } from "./providers/index.js";
|
|
7
7
|
import { readSettings } from "./settings.js";
|
|
8
8
|
/** Build one searchable catalogue from every provider that is usable now. */
|
|
@@ -76,7 +76,7 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
|
|
|
76
76
|
label: choice.model,
|
|
77
77
|
// Provider identity is part of the choice, not optional help: keep it
|
|
78
78
|
// visible even when the terminal is only at the supported minimum.
|
|
79
|
-
value:
|
|
79
|
+
value: providerRouteLabel(choice.provider),
|
|
80
80
|
})),
|
|
81
81
|
searchable: true,
|
|
82
82
|
query: "",
|
|
@@ -111,7 +111,7 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
|
|
|
111
111
|
if (behavior.announce !== false) {
|
|
112
112
|
host.emit({
|
|
113
113
|
kind: "notice",
|
|
114
|
-
text: `model · ${
|
|
114
|
+
text: `model · ${providerRouteLabel(chosen.provider)} · ${chosen.model}`,
|
|
115
115
|
tone: "info",
|
|
116
116
|
});
|
|
117
117
|
}
|
|
@@ -138,6 +138,7 @@ async function alignEffort(provider, model, effort, host) {
|
|
|
138
138
|
}
|
|
139
139
|
function catalogDescription(disconnected, failed) {
|
|
140
140
|
const parts = [
|
|
141
|
+
"The connection on the right will run this model; API and account usage stay separate",
|
|
141
142
|
disconnected.length === 0
|
|
142
143
|
? undefined
|
|
143
144
|
: `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
|
|
@@ -145,7 +146,7 @@ function catalogDescription(disconnected, failed) {
|
|
|
145
146
|
? undefined
|
|
146
147
|
: `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
|
|
147
148
|
].filter((part) => part !== undefined);
|
|
148
|
-
return
|
|
149
|
+
return `${parts.join(" · ")} · manage access in /providers`;
|
|
149
150
|
}
|
|
150
151
|
function emptyCatalogMessage(connected, failed) {
|
|
151
152
|
if (failed.length === 1 && connected.length === 1) {
|
|
@@ -29,9 +29,14 @@ export async function openAIAccountCommand(session, host, dependencies = DEFAULT
|
|
|
29
29
|
return false;
|
|
30
30
|
if (connected && actions[action]?.key === "s") {
|
|
31
31
|
const result = await removeOpenAIAccount(host.signal);
|
|
32
|
+
const route = session.provider.id === "openai-codex"
|
|
33
|
+
? " · choose another provider in /models"
|
|
34
|
+
: "";
|
|
32
35
|
host.emit({
|
|
33
36
|
kind: "notice",
|
|
34
|
-
text: result.revokeFailed
|
|
37
|
+
text: result.revokeFailed
|
|
38
|
+
? `ChatGPT disconnected · remote sign-out could not be confirmed${route}`
|
|
39
|
+
: `ChatGPT disconnected${route}`,
|
|
35
40
|
tone: result.revokeFailed ? "warn" : "info",
|
|
36
41
|
});
|
|
37
42
|
return result.removed;
|
|
@@ -89,7 +94,13 @@ async function waitForLogin(login, session, host, openUrl) {
|
|
|
89
94
|
if (outcome.kind === "account") {
|
|
90
95
|
host.dismiss?.();
|
|
91
96
|
await saveOpenAIAccount(outcome.account, host.signal);
|
|
92
|
-
host.emit({
|
|
97
|
+
host.emit({
|
|
98
|
+
kind: "notice",
|
|
99
|
+
text: session.provider.id === "openai-codex"
|
|
100
|
+
? "ChatGPT connected · current provider route"
|
|
101
|
+
: "ChatGPT connected · choose a ChatGPT model in /models to use this account",
|
|
102
|
+
tone: "info",
|
|
103
|
+
});
|
|
93
104
|
return true;
|
|
94
105
|
}
|
|
95
106
|
if (outcome.kind === "error") {
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import { credentialSource } from "./credentials.js";
|
|
|
5
5
|
import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
|
|
6
6
|
import { openAIAccountHint } from "./openai-account.js";
|
|
7
7
|
import { openAIAccountCommand } from "./openai-account-command.js";
|
|
8
|
-
import { providerLabel } from "./provider-label.js";
|
|
8
|
+
import { providerLabel, providerRouteLabel } from "./provider-label.js";
|
|
9
9
|
import { PROVIDERS } from "./providers/index.js";
|
|
10
10
|
import { ollamaConnection } from "./providers/ollama.js";
|
|
11
11
|
import { heading } from "./tui/picker.js";
|
|
@@ -15,28 +15,71 @@ export async function providersCommand(session, host) {
|
|
|
15
15
|
const choose = chooser(host);
|
|
16
16
|
if (choose === undefined)
|
|
17
17
|
return;
|
|
18
|
-
|
|
18
|
+
const groups = providerGroups();
|
|
19
|
+
let selected = Math.max(0, groups.findIndex((group) => group.providers.some((provider) => provider.id === session.provider.id)));
|
|
19
20
|
while (true) {
|
|
20
21
|
const index = await choose({
|
|
21
22
|
title: [],
|
|
22
|
-
|
|
23
|
+
description: `Current route: ${providerRouteLabel(session.provider)} · access changes do not switch it; use /models`,
|
|
24
|
+
options: groups.map((group) => ({
|
|
25
|
+
label: group.label,
|
|
26
|
+
value: providerGroupHint(group, session.provider.id),
|
|
27
|
+
})),
|
|
28
|
+
index: selected,
|
|
29
|
+
});
|
|
30
|
+
if (index === undefined) {
|
|
31
|
+
throwIfAborted(host.signal);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const group = groups[index];
|
|
35
|
+
if (group === undefined)
|
|
36
|
+
return;
|
|
37
|
+
selected = index;
|
|
38
|
+
await providerGroupCommand(group, session, host);
|
|
39
|
+
throwIfAborted(host.signal);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function providerGroups() {
|
|
43
|
+
return [
|
|
44
|
+
{ label: "Account", providers: PROVIDERS.filter((provider) => provider.auth.kind === "oauth") },
|
|
45
|
+
{ label: "API", providers: PROVIDERS.filter((provider) => provider.auth.kind !== "oauth") },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
async function providerGroupCommand(group, session, host) {
|
|
49
|
+
if (host.choose === undefined)
|
|
50
|
+
return;
|
|
51
|
+
let selected = Math.max(0, group.providers.findIndex((provider) => provider.id === session.provider.id));
|
|
52
|
+
while (true) {
|
|
53
|
+
const index = await host.choose({
|
|
54
|
+
title: heading(group.label, "provider access", session.palette),
|
|
55
|
+
options: group.providers.map((provider) => ({
|
|
23
56
|
label: providerLabel(provider.id),
|
|
24
|
-
value: providerAccessHint(provider)
|
|
57
|
+
value: `${provider.id === session.provider.id ? "current · " : ""}${providerAccessHint(provider)}`,
|
|
25
58
|
})),
|
|
26
59
|
index: selected,
|
|
27
60
|
});
|
|
28
|
-
if (index === undefined)
|
|
61
|
+
if (index === undefined) {
|
|
62
|
+
throwIfAborted(host.signal);
|
|
29
63
|
return;
|
|
30
|
-
|
|
64
|
+
}
|
|
65
|
+
const provider = group.providers[index];
|
|
31
66
|
if (provider === undefined)
|
|
32
67
|
return;
|
|
33
68
|
selected = index;
|
|
34
69
|
await manageProvider(provider, session, host);
|
|
35
|
-
// Esc closes only the
|
|
36
|
-
//
|
|
70
|
+
// Esc closes only the provider-specific flow. Ctrl+C also settles that
|
|
71
|
+
// interaction, but aborts the command signal and must not reopen a menu.
|
|
37
72
|
throwIfAborted(host.signal);
|
|
38
73
|
}
|
|
39
74
|
}
|
|
75
|
+
function providerCount(count) {
|
|
76
|
+
return `${count} ${count === 1 ? "provider" : "providers"}`;
|
|
77
|
+
}
|
|
78
|
+
function providerGroupHint(group, activeId) {
|
|
79
|
+
const active = group.providers.find((provider) => provider.id === activeId);
|
|
80
|
+
const count = providerCount(group.providers.length);
|
|
81
|
+
return active === undefined ? count : `${count} · current ${providerRouteLabel(active)}`;
|
|
82
|
+
}
|
|
40
83
|
export function providerAccessHint(provider) {
|
|
41
84
|
if (provider.id === "ollama") {
|
|
42
85
|
const connection = ollamaConnection();
|
|
@@ -57,7 +100,7 @@ async function manageProvider(provider, session, host) {
|
|
|
57
100
|
await openAIAccountCommand(session, host);
|
|
58
101
|
return;
|
|
59
102
|
}
|
|
60
|
-
await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host);
|
|
103
|
+
await apiKeyCommand(provider.auth.keyVar, providerLabel(provider.id), session, host, provider.id);
|
|
61
104
|
}
|
|
62
105
|
async function ollamaProviderCommand(session, host) {
|
|
63
106
|
if (host.choose === undefined)
|
|
@@ -74,7 +117,7 @@ async function ollamaProviderCommand(session, host) {
|
|
|
74
117
|
await ollamaConnectionSetting(session, host, (patch) => saveCommandSettings(host, patch));
|
|
75
118
|
}
|
|
76
119
|
else if (index === 1) {
|
|
77
|
-
await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host);
|
|
120
|
+
await apiKeyCommand(OLLAMA_KEY, "Ollama", session, host, "ollama");
|
|
78
121
|
}
|
|
79
122
|
}
|
|
80
123
|
function chooser(host) {
|
package/dist/provider-errors.js
CHANGED
|
@@ -1,11 +1,102 @@
|
|
|
1
1
|
// Actionable provider failures for user-facing command and turn surfaces.
|
|
2
|
+
import { redactCredentials } from "./credential-safety.js";
|
|
2
3
|
import { providerLabel } from "./provider-label.js";
|
|
4
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
|
+
import { terminalText } from "./ui/terminal-text.js";
|
|
6
|
+
import { providerFailureDetails, } from "./providers/failure.js";
|
|
3
7
|
const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
|
|
8
|
+
const MAX_MESSAGE_CHARS = 1_000;
|
|
9
|
+
const MAX_REASON_CHARS = 500;
|
|
10
|
+
const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
|
|
4
11
|
export function providerFailure(provider, error, labelProvider = false) {
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
const details = providerFailureDetails(provider.id, error);
|
|
13
|
+
let failure;
|
|
14
|
+
if (provider.id === "ollama" && details.kind === "network" && CONNECTION_FAILURE.test(error.message)) {
|
|
15
|
+
failure = provider.location?.() === "local"
|
|
7
16
|
? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
|
|
8
17
|
: "Ollama is not reachable · check its connection in /providers";
|
|
9
18
|
}
|
|
10
|
-
|
|
19
|
+
else {
|
|
20
|
+
failure = actionableFailure(provider.id, details.kind) ?? rawFailure(error);
|
|
21
|
+
}
|
|
22
|
+
if (details.requestId !== undefined)
|
|
23
|
+
failure += ` · request ${details.requestId}`;
|
|
24
|
+
const label = providerLabel(provider.id);
|
|
25
|
+
return labelProvider && !failure.startsWith(`${label} `) ? `${label}: ${failure}` : failure;
|
|
26
|
+
}
|
|
27
|
+
function actionableFailure(providerId, kind) {
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case "authentication":
|
|
30
|
+
return "authentication failed";
|
|
31
|
+
case "billing":
|
|
32
|
+
return providerId === "openai"
|
|
33
|
+
? "credits exhausted · add credits or choose another provider in /models"
|
|
34
|
+
: "billing limit reached · check provider billing or choose another provider in /models";
|
|
35
|
+
case "quota":
|
|
36
|
+
return "usage quota exhausted · check provider limits or choose another provider in /models";
|
|
37
|
+
case "rate-limit":
|
|
38
|
+
return "rate limit reached · retry later";
|
|
39
|
+
case "overload":
|
|
40
|
+
return "temporarily unavailable · retry later";
|
|
41
|
+
case "context":
|
|
42
|
+
return "context limit reached · compact the conversation or choose a larger-context model";
|
|
43
|
+
case "network":
|
|
44
|
+
return "network request failed · check the connection and retry";
|
|
45
|
+
case "unknown":
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function rawFailure(error) {
|
|
50
|
+
const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
|
|
51
|
+
const reason = providerReason(error);
|
|
52
|
+
const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
|
|
53
|
+
? ` · ${reason}`
|
|
54
|
+
: "";
|
|
55
|
+
return `${message}${detail}`;
|
|
56
|
+
}
|
|
57
|
+
function providerReason(error) {
|
|
58
|
+
const body = error.body;
|
|
59
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
60
|
+
return undefined;
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(body);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
const raw = body.trim();
|
|
67
|
+
if (raw.startsWith("{") || raw.startsWith("[") || HTML.test(raw))
|
|
68
|
+
return undefined;
|
|
69
|
+
return safeReason(raw);
|
|
70
|
+
}
|
|
71
|
+
for (const candidate of reasonCandidates(parsed)) {
|
|
72
|
+
const reason = safeReason(candidate);
|
|
73
|
+
if (reason !== undefined)
|
|
74
|
+
return reason;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
function reasonCandidates(value) {
|
|
79
|
+
if (typeof value === "string")
|
|
80
|
+
return [value];
|
|
81
|
+
if (!record(value))
|
|
82
|
+
return [];
|
|
83
|
+
const error = value["error"];
|
|
84
|
+
return [
|
|
85
|
+
record(error) ? error["message"] : undefined,
|
|
86
|
+
typeof error === "string" ? error : undefined,
|
|
87
|
+
value["message"],
|
|
88
|
+
value["detail"],
|
|
89
|
+
].filter((candidate) => typeof candidate === "string");
|
|
90
|
+
}
|
|
91
|
+
function safeReason(text) {
|
|
92
|
+
if (HTML.test(text))
|
|
93
|
+
return undefined;
|
|
94
|
+
return safeText(text, MAX_REASON_CHARS) || undefined;
|
|
95
|
+
}
|
|
96
|
+
function safeText(text, max) {
|
|
97
|
+
const redacted = redactCredentials(text).trim().replace(/\s+/gu, " ");
|
|
98
|
+
return leadingText(terminalText(redacted), max);
|
|
99
|
+
}
|
|
100
|
+
function record(value) {
|
|
101
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
102
|
}
|
package/dist/provider-label.js
CHANGED
|
@@ -8,3 +8,16 @@ export function providerLabel(id) {
|
|
|
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.auth.kind === "oauth")
|
|
14
|
+
return `${providerLabel(provider.id)} account`;
|
|
15
|
+
if (provider.id === "openai")
|
|
16
|
+
return "OpenAI API · billed usage";
|
|
17
|
+
if (provider.id === "anthropic")
|
|
18
|
+
return "Anthropic API";
|
|
19
|
+
if (provider.id === "ollama") {
|
|
20
|
+
return provider.location?.() === "local" ? "Ollama · local" : "Ollama API";
|
|
21
|
+
}
|
|
22
|
+
return providerLabel(provider.id);
|
|
23
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
94
|
-
cacheWriteInputTokens:
|
|
98
|
+
cachedInputTokens: cached,
|
|
99
|
+
cacheWriteInputTokens: cacheWrite,
|
|
95
100
|
reasoningTokens: 0,
|
|
96
101
|
};
|
|
97
102
|
}
|