dsh-plugin-subscriptions 0.1.0
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 +93 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +51 -0
- package/lib/auth/rpc.js +83 -0
- package/lib/auth/store.d.ts +90 -0
- package/lib/auth/store.js +137 -0
- package/lib/client/SubscriptionsSection.d.ts +30 -0
- package/lib/client/SubscriptionsSection.js +290 -0
- package/lib/client/index.d.ts +31 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.d.ts +45 -0
- package/lib/client/locales.js +43 -0
- package/lib/client.js +546 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +2932 -0
- package/lib/providers/claude.d.ts +60 -0
- package/lib/providers/claude.js +243 -0
- package/lib/providers/codex.d.ts +96 -0
- package/lib/providers/codex.js +391 -0
- package/lib/providers/common.d.ts +185 -0
- package/lib/providers/common.js +302 -0
- package/lib/providers/grok.d.ts +90 -0
- package/lib/providers/grok.js +337 -0
- package/lib/tools/image-generate.d.ts +60 -0
- package/lib/tools/image-generate.js +142 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +195 -0
- package/lib/translate/anthropic.d.ts +120 -0
- package/lib/translate/anthropic.js +370 -0
- package/lib/translate/resolved.d.ts +35 -0
- package/lib/translate/resolved.js +40 -0
- package/lib/translate/responses.d.ts +127 -0
- package/lib/translate/responses.js +352 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +83 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2932 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
8
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
|
+
|
|
10
|
+
//#region src/auth/pkce.ts
|
|
11
|
+
/**
|
|
12
|
+
* Base64url-encode without padding.
|
|
13
|
+
* @param buffer - raw bytes.
|
|
14
|
+
* @returns the RFC 4648 §5 encoding.
|
|
15
|
+
*/
|
|
16
|
+
function base64url(buffer) {
|
|
17
|
+
return buffer.toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mint a fresh PKCE pair (32-byte verifier, S256 challenge).
|
|
21
|
+
* @returns the pair for one authorization attempt.
|
|
22
|
+
*/
|
|
23
|
+
function createPkce() {
|
|
24
|
+
const verifier = base64url(randomBytes(32));
|
|
25
|
+
return {
|
|
26
|
+
verifier,
|
|
27
|
+
challenge: base64url(createHash("sha256").update(verifier).digest())
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Mint a URL-safe random token (default 16 bytes) for OAuth `state`.
|
|
32
|
+
* @param bytes - entropy length.
|
|
33
|
+
* @returns base64url-encoded random bytes.
|
|
34
|
+
*/
|
|
35
|
+
function randomToken(bytes = 16) {
|
|
36
|
+
return base64url(randomBytes(bytes));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Mint lowercase-hex random bytes (for Grok's `nonce` parameter).
|
|
40
|
+
* @param bytes - entropy length; the hex string is twice as long.
|
|
41
|
+
* @returns hex-encoded random bytes.
|
|
42
|
+
*/
|
|
43
|
+
function randomHex(bytes = 8) {
|
|
44
|
+
return randomBytes(bytes).toString("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/auth/oauth-flow.ts
|
|
49
|
+
/** Default attempt lifetime: three minutes for the user to complete login. */
|
|
50
|
+
const DEFAULT_FLOW_TIMEOUT_MS = 18e4;
|
|
51
|
+
const SUCCESS_PAGE = "<!doctype html><html><head><meta charset=\"utf-8\"><title>Login successful</title></head><body style=\"font-family:sans-serif\"><h1>Login successful</h1><p>You can close this tab and return to DeepSeek Harness.</p></body></html>";
|
|
52
|
+
function failurePage(detail) {
|
|
53
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>Login failed</title></head><body style="font-family:sans-serif"><h1>Login failed</h1><p>${detail.replace(/[<>&]/g, "")}</p></body></html>`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Loopback addresses one listen host covers. `localhost` resolves to ::1 or
|
|
57
|
+
* 127.0.0.1 depending on the client, and Node binds exactly one of them per
|
|
58
|
+
* listen call — a browser picking the other family gets connection-refused
|
|
59
|
+
* and the login times out, so both families must serve the callback.
|
|
60
|
+
*/
|
|
61
|
+
function listenHosts(host) {
|
|
62
|
+
return host === "localhost" ? ["127.0.0.1", "::1"] : [host];
|
|
63
|
+
}
|
|
64
|
+
/** True when the address family does not exist on this machine (safe to skip), unlike a taken port. */
|
|
65
|
+
function familyUnavailable(error) {
|
|
66
|
+
const code = error.code;
|
|
67
|
+
return code === "EADDRNOTAVAIL" || code === "EPROTONOSUPPORT";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Listen on the first port of the spec free on every loopback family;
|
|
71
|
+
* rejects when every port fails. Ephemeral ports (0) are retried so each
|
|
72
|
+
* family can be re-bound onto the first family's assigned port.
|
|
73
|
+
*/
|
|
74
|
+
async function listen(handler, spec) {
|
|
75
|
+
const hosts = listenHosts(spec.host);
|
|
76
|
+
const candidates = spec.ports.flatMap((port) => port === 0 ? [
|
|
77
|
+
0,
|
|
78
|
+
0,
|
|
79
|
+
0
|
|
80
|
+
] : [port]);
|
|
81
|
+
let lastError;
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const servers = [];
|
|
84
|
+
let port = candidate;
|
|
85
|
+
let unusable = false;
|
|
86
|
+
for (const host of hosts) {
|
|
87
|
+
const server = createServer(handler);
|
|
88
|
+
try {
|
|
89
|
+
await new Promise((resolve, reject) => {
|
|
90
|
+
const onError = (error) => reject(error);
|
|
91
|
+
server.once("error", onError);
|
|
92
|
+
server.listen(port, host, () => {
|
|
93
|
+
server.removeListener("error", onError);
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
const address = server.address();
|
|
98
|
+
if (address === null) throw new Error(`callback server on ${host}:${port} has no address`);
|
|
99
|
+
if (port === 0) port = address.port;
|
|
100
|
+
servers.push(server);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
server.close();
|
|
103
|
+
if (familyUnavailable(error)) continue;
|
|
104
|
+
lastError = error;
|
|
105
|
+
unusable = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (unusable || servers.length === 0) {
|
|
110
|
+
for (const server of servers) server.close();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
servers,
|
|
115
|
+
port
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`callback server could not listen on ${spec.host} (ports ${spec.ports.join(", ")})`);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Own the set of in-flight login attempts, keyed by provider. One attempt per
|
|
122
|
+
* provider at a time; an attempt removes itself when it settles.
|
|
123
|
+
*/
|
|
124
|
+
var OAuthFlowManager = class {
|
|
125
|
+
attempts = /* @__PURE__ */ new Map();
|
|
126
|
+
/**
|
|
127
|
+
* Whether a login attempt is running for one provider.
|
|
128
|
+
* @param provider - the provider route.
|
|
129
|
+
* @returns true while an attempt is waiting for its code.
|
|
130
|
+
*/
|
|
131
|
+
isBusy(provider) {
|
|
132
|
+
return this.attempts.has(provider);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The pending attempt for one provider, when any.
|
|
136
|
+
* @param provider - the provider route.
|
|
137
|
+
* @returns the in-flight attempt, or `undefined`.
|
|
138
|
+
*/
|
|
139
|
+
pending(provider) {
|
|
140
|
+
return this.attempts.get(provider);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Start a login attempt: mint PKCE/state, open the loopback callback
|
|
144
|
+
* server, and build the authorize URL.
|
|
145
|
+
* @param provider - the provider route (one attempt at a time).
|
|
146
|
+
* @param spec - static flow facts for this provider.
|
|
147
|
+
* @returns the live attempt; its `waitCode()` settles the login.
|
|
148
|
+
* @throws when an attempt is already running or no callback port is free.
|
|
149
|
+
*/
|
|
150
|
+
async start(provider, spec) {
|
|
151
|
+
if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
|
|
152
|
+
const input = {
|
|
153
|
+
redirectUri: "",
|
|
154
|
+
state: randomToken(16),
|
|
155
|
+
pkce: createPkce(),
|
|
156
|
+
nonce: randomHex(8)
|
|
157
|
+
};
|
|
158
|
+
const timeoutMs = spec.timeoutMs ?? DEFAULT_FLOW_TIMEOUT_MS;
|
|
159
|
+
let resolveCode;
|
|
160
|
+
let rejectCode;
|
|
161
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
162
|
+
resolveCode = resolve;
|
|
163
|
+
rejectCode = reject;
|
|
164
|
+
});
|
|
165
|
+
let settled = false;
|
|
166
|
+
let timer;
|
|
167
|
+
let servers = [];
|
|
168
|
+
const handler = (request, response) => {
|
|
169
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
170
|
+
if (url.pathname !== spec.callbackPath) {
|
|
171
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
172
|
+
response.end("not found");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const errorDescription = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
|
176
|
+
if (errorDescription !== null) {
|
|
177
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
178
|
+
response.end(failurePage(errorDescription));
|
|
179
|
+
settle(/* @__PURE__ */ new Error(`authorization failed: ${errorDescription}`));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (url.searchParams.get("state") !== input.state) {
|
|
183
|
+
response.writeHead(400, { "content-type": "text/plain" });
|
|
184
|
+
response.end("state mismatch");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const code = url.searchParams.get("code");
|
|
188
|
+
if (code === null || code.length === 0) {
|
|
189
|
+
response.writeHead(400, { "content-type": "text/plain" });
|
|
190
|
+
response.end("missing authorization code");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
response.writeHead(200, { "content-type": "text/html" });
|
|
194
|
+
response.end(SUCCESS_PAGE);
|
|
195
|
+
settle(void 0, code);
|
|
196
|
+
};
|
|
197
|
+
const settle = (error, code) => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
200
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
201
|
+
for (const server of servers) {
|
|
202
|
+
server.close();
|
|
203
|
+
server.closeAllConnections();
|
|
204
|
+
}
|
|
205
|
+
this.attempts.delete(provider);
|
|
206
|
+
if (error !== void 0) rejectCode(error);
|
|
207
|
+
else if (code !== void 0) resolveCode(code);
|
|
208
|
+
};
|
|
209
|
+
const bound = await listen(handler, spec.listen);
|
|
210
|
+
servers = bound.servers;
|
|
211
|
+
input.redirectUri = `http://${spec.listen.host}:${bound.port}${spec.callbackPath}`;
|
|
212
|
+
timer = setTimeout(() => {
|
|
213
|
+
settle(/* @__PURE__ */ new Error(`login timed out after ${Math.round(timeoutMs / 1e3)}s`));
|
|
214
|
+
}, timeoutMs);
|
|
215
|
+
timer.unref();
|
|
216
|
+
const attempt = {
|
|
217
|
+
authorizeUrl: spec.buildAuthorizeUrl(input),
|
|
218
|
+
redirectUri: input.redirectUri,
|
|
219
|
+
pkce: input.pkce,
|
|
220
|
+
state: input.state,
|
|
221
|
+
waitCode: () => codePromise,
|
|
222
|
+
manual(rawInput) {
|
|
223
|
+
if (settled) throw new Error(`the ${provider} login attempt already finished`);
|
|
224
|
+
const trimmed = rawInput.trim();
|
|
225
|
+
let code;
|
|
226
|
+
let pastedState;
|
|
227
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
228
|
+
const url = new URL(trimmed);
|
|
229
|
+
code = url.searchParams.get("code") ?? void 0;
|
|
230
|
+
pastedState = url.searchParams.get("state") ?? void 0;
|
|
231
|
+
} else if (trimmed.includes("code=")) {
|
|
232
|
+
const params = new URLSearchParams(trimmed);
|
|
233
|
+
code = params.get("code") ?? void 0;
|
|
234
|
+
pastedState = params.get("state") ?? void 0;
|
|
235
|
+
} else if (trimmed.length > 0 && !/\s/.test(trimmed)) code = trimmed;
|
|
236
|
+
if (code === void 0 || code.length === 0) throw new Error("no authorization code found in the pasted input");
|
|
237
|
+
if (pastedState !== void 0 && pastedState !== input.state) throw new Error("state mismatch: the pasted URL belongs to a different login attempt");
|
|
238
|
+
settle(void 0, code);
|
|
239
|
+
},
|
|
240
|
+
cancel() {
|
|
241
|
+
settle(/* @__PURE__ */ new Error("login cancelled"));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
this.attempts.set(provider, attempt);
|
|
245
|
+
return attempt;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/auth/store.ts
|
|
251
|
+
/** Every provider route, in display order. */
|
|
252
|
+
const PROVIDER_IDS = [
|
|
253
|
+
"codex",
|
|
254
|
+
"claude",
|
|
255
|
+
"grok"
|
|
256
|
+
];
|
|
257
|
+
/**
|
|
258
|
+
* Absolute path of the auth store file.
|
|
259
|
+
* @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
|
|
260
|
+
*/
|
|
261
|
+
function authFilePath() {
|
|
262
|
+
return dshHomePath("plugins", "subscriptions", "auth.json");
|
|
263
|
+
}
|
|
264
|
+
/** Store location used before the plugin was renamed; migrated on first read. */
|
|
265
|
+
function legacyAuthFilePath() {
|
|
266
|
+
return dshHomePath("plugins", "router", "auth.json");
|
|
267
|
+
}
|
|
268
|
+
/** Check that one durable entry carries the fields every session needs. */
|
|
269
|
+
function assertSessionShape(provider, value) {
|
|
270
|
+
if (typeof value !== "object" || value === null) throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`);
|
|
271
|
+
const entry = value;
|
|
272
|
+
if (typeof entry.accessToken !== "string" || entry.accessToken.length === 0 || typeof entry.refreshToken !== "string" || entry.refreshToken.length === 0 || typeof entry.expiresAt !== "number" || !Number.isFinite(entry.expiresAt)) throw new Error(`subscriptions auth store: entry "${provider}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Read the whole store. A missing file is an empty store; malformed JSON or a
|
|
276
|
+
* malformed entry throws, because silently discarding tokens would strand the
|
|
277
|
+
* user without a diagnosis.
|
|
278
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
279
|
+
* @returns the parsed session map.
|
|
280
|
+
*/
|
|
281
|
+
async function loadStore(path = authFilePath()) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = await readFile(path, "utf8");
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error.code !== "ENOENT") throw error;
|
|
287
|
+
if (path !== authFilePath()) return {};
|
|
288
|
+
try {
|
|
289
|
+
text = await readFile(legacyAuthFilePath(), "utf8");
|
|
290
|
+
} catch (legacyError) {
|
|
291
|
+
if (legacyError.code === "ENOENT") return {};
|
|
292
|
+
throw legacyError;
|
|
293
|
+
}
|
|
294
|
+
const migrated = parseStore(text, legacyAuthFilePath());
|
|
295
|
+
await writeStore(migrated, path);
|
|
296
|
+
await rm(legacyAuthFilePath(), { force: true });
|
|
297
|
+
return migrated;
|
|
298
|
+
}
|
|
299
|
+
return parseStore(text, path);
|
|
300
|
+
}
|
|
301
|
+
/** Parse and validate store JSON read from `path`. */
|
|
302
|
+
function parseStore(text, path) {
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = JSON.parse(text);
|
|
306
|
+
} catch {
|
|
307
|
+
throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
|
|
308
|
+
}
|
|
309
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
|
|
310
|
+
const store = parsed;
|
|
311
|
+
for (const provider of PROVIDER_IDS) {
|
|
312
|
+
const entry = store[provider];
|
|
313
|
+
if (entry !== void 0) assertSessionShape(provider, entry);
|
|
314
|
+
}
|
|
315
|
+
return store;
|
|
316
|
+
}
|
|
317
|
+
/** Persist the whole store atomically with owner-only permissions. */
|
|
318
|
+
async function writeStore(store, path) {
|
|
319
|
+
await mkdir(dirname(path), { recursive: true });
|
|
320
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
321
|
+
try {
|
|
322
|
+
await writeFile(tmp, JSON.stringify(store, null, 2), { mode: 384 });
|
|
323
|
+
await chmod(tmp, 384);
|
|
324
|
+
await rename(tmp, path);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
await rm(tmp, { force: true });
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Read one provider's session.
|
|
332
|
+
* @param provider - the provider route.
|
|
333
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
334
|
+
* @returns the stored session, or `undefined` when logged out.
|
|
335
|
+
*/
|
|
336
|
+
async function getSession(provider, path = authFilePath()) {
|
|
337
|
+
return (await loadStore(path))[provider];
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Write one provider's session, preserving the others.
|
|
341
|
+
* @param provider - the provider route.
|
|
342
|
+
* @param session - the fresh session from a login or refresh.
|
|
343
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
344
|
+
*/
|
|
345
|
+
async function saveSession(provider, session, path = authFilePath()) {
|
|
346
|
+
const store = await loadStore(path);
|
|
347
|
+
store[provider] = session;
|
|
348
|
+
await writeStore(store, path);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Delete one provider's session (logout).
|
|
352
|
+
* @param provider - the provider route.
|
|
353
|
+
* @param path - store file path; defaults to {@link authFilePath}.
|
|
354
|
+
*/
|
|
355
|
+
async function deleteSession(provider, path = authFilePath()) {
|
|
356
|
+
const store = await loadStore(path);
|
|
357
|
+
if (store[provider] === void 0) return;
|
|
358
|
+
delete store[provider];
|
|
359
|
+
await writeStore(store, path);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/auth/rpc.ts
|
|
364
|
+
/** The RPC channel this plugin registers on the host connection. */
|
|
365
|
+
const SUBSCRIPTIONS_AUTH_CHANNEL = "/subscriptions-auth";
|
|
366
|
+
/** Payload carried no usable provider id — an RPC client bug, not a server failure. */
|
|
367
|
+
var BadRequest = class extends Error {};
|
|
368
|
+
function ok(value) {
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
value
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function failure(error) {
|
|
375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
376
|
+
if (error instanceof BadRequest) return {
|
|
377
|
+
ok: false,
|
|
378
|
+
error: {
|
|
379
|
+
code: "bad-request",
|
|
380
|
+
message,
|
|
381
|
+
details: { issues: [] }
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
error: {
|
|
387
|
+
code: "internal",
|
|
388
|
+
message,
|
|
389
|
+
details: {}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function readProvider(payload) {
|
|
394
|
+
if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
|
|
395
|
+
const provider = payload.provider;
|
|
396
|
+
if (typeof provider !== "string" || !PROVIDER_IDS.includes(provider)) throw new BadRequest(`payload.provider must be one of ${PROVIDER_IDS.join(", ")}`);
|
|
397
|
+
return provider;
|
|
398
|
+
}
|
|
399
|
+
function readString(payload, field) {
|
|
400
|
+
const value = payload[field];
|
|
401
|
+
if (typeof value !== "string" || value.length === 0) throw new BadRequest(`payload.${field} must be a non-empty string`);
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
async function dispatch(controller, endpoint, payload) {
|
|
405
|
+
switch (endpoint) {
|
|
406
|
+
case "status": {
|
|
407
|
+
const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
|
|
408
|
+
return ok({ providers: Object.fromEntries(entries) });
|
|
409
|
+
}
|
|
410
|
+
case "login": return ok(await controller.login(readProvider(payload)));
|
|
411
|
+
case "manual": {
|
|
412
|
+
const provider = readProvider(payload);
|
|
413
|
+
await controller.manual(provider, readString(payload, "input"));
|
|
414
|
+
return ok({ ok: true });
|
|
415
|
+
}
|
|
416
|
+
case "cancel":
|
|
417
|
+
await controller.cancel(readProvider(payload));
|
|
418
|
+
return ok({ ok: true });
|
|
419
|
+
case "logout":
|
|
420
|
+
await controller.logout(readProvider(payload));
|
|
421
|
+
return ok({ ok: true });
|
|
422
|
+
default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Register the `/subscriptions-auth` RPC channel when a host connection exists.
|
|
427
|
+
* @param ctx - the plugin context (headless profiles have no `connection`).
|
|
428
|
+
* @param controller - the auth operations backing the endpoints.
|
|
429
|
+
*/
|
|
430
|
+
function registerAuthRpc(ctx, controller) {
|
|
431
|
+
ctx.inject(["connection"], (ctx$1) => {
|
|
432
|
+
const connection = ctx$1.get("connection");
|
|
433
|
+
ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload) => {
|
|
434
|
+
try {
|
|
435
|
+
return await dispatch(controller, endpoint, payload);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return failure(error);
|
|
438
|
+
}
|
|
439
|
+
}, { authority: "loopback" }), "dsh-plugin-subscriptions: /subscriptions-auth rpc channel");
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/providers/common.ts
|
|
445
|
+
/**
|
|
446
|
+
* Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
|
|
447
|
+
* @param models - raw configured entries.
|
|
448
|
+
* @param label - diagnostic prefix naming the provider.
|
|
449
|
+
* @returns the validated entries.
|
|
450
|
+
*/
|
|
451
|
+
function validateModels(models, label) {
|
|
452
|
+
const seen = /* @__PURE__ */ new Set();
|
|
453
|
+
return models.map((model) => {
|
|
454
|
+
if (model.id.length === 0) throw new Error(`${label}: catalog model ids must be non-empty`);
|
|
455
|
+
if (model.name !== void 0 && model.name.length === 0) throw new Error(`${label}: catalog model "${model.id}" has an empty name`);
|
|
456
|
+
if (model.contextWindow !== void 0 && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) throw new Error(`${label}: catalog model "${model.id}" contextWindow must be a positive integer`);
|
|
457
|
+
if (model.maxTokens !== void 0 && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) throw new Error(`${label}: catalog model "${model.id}" maxTokens must be a positive integer`);
|
|
458
|
+
if (model.inputModalities !== void 0 && (model.inputModalities.length === 0 || model.inputModalities.some((modality) => modality !== "text" && modality !== "image"))) throw new Error(`${label}: catalog model "${model.id}" inputModalities must be a non-empty list of "text"/"image"`);
|
|
459
|
+
if (seen.has(model.id)) throw new Error(`${label}: duplicate catalog model "${model.id}"`);
|
|
460
|
+
seen.add(model.id);
|
|
461
|
+
return {
|
|
462
|
+
id: model.id,
|
|
463
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
464
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
465
|
+
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens },
|
|
466
|
+
...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] }
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Build an LlmError from a non-2xx provider response, reading and truncating
|
|
472
|
+
* the body for the message and mapping the status to a stable code.
|
|
473
|
+
* @param response - the failed response.
|
|
474
|
+
* @param label - diagnostic prefix naming the provider API.
|
|
475
|
+
* @returns the classified error.
|
|
476
|
+
*/
|
|
477
|
+
async function httpLlmError(response, label) {
|
|
478
|
+
let body = "";
|
|
479
|
+
try {
|
|
480
|
+
body = (await response.text()).slice(0, 500);
|
|
481
|
+
} catch {}
|
|
482
|
+
const message = body.length > 0 ? `${label} error (HTTP ${String(response.status)}): ${body}` : `${label} error (HTTP ${String(response.status)})`;
|
|
483
|
+
let code;
|
|
484
|
+
if (response.status === 401 || response.status === 403) code = "AUTH";
|
|
485
|
+
else if (isQuotaExceededError(body)) code = QUOTA_EXCEEDED_CODE;
|
|
486
|
+
else if (response.status === 429) code = "RATE_LIMIT";
|
|
487
|
+
else if (response.status === 400 && isContextWindowExceededError(body)) code = CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
488
|
+
else if (response.status === 408 || response.status === 504) code = "TIMEOUT";
|
|
489
|
+
else if (response.status >= 500) code = "SERVER";
|
|
490
|
+
else code = `HTTP_${String(response.status)}`;
|
|
491
|
+
const retryAfter = response.headers.get("retry-after");
|
|
492
|
+
let providerRetryAfterMs;
|
|
493
|
+
if (retryAfter !== null) {
|
|
494
|
+
const seconds = Number(retryAfter);
|
|
495
|
+
if (Number.isFinite(seconds) && seconds > 0) providerRetryAfterMs = seconds * 1e3;
|
|
496
|
+
}
|
|
497
|
+
return new LlmError(message, code, {
|
|
498
|
+
status: response.status,
|
|
499
|
+
...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs }
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Create an idle watchdog chained to the caller's signal.
|
|
504
|
+
* @param caller - the request's own abort signal, when present.
|
|
505
|
+
* @param timeoutMs - maximum idle interval while a stream read is outstanding.
|
|
506
|
+
* @returns the watchdog; always {@link IdleWatchdog.stop} it when the stream ends.
|
|
507
|
+
*/
|
|
508
|
+
function idleWatchdog(caller, timeoutMs) {
|
|
509
|
+
const controller = new AbortController();
|
|
510
|
+
let expired = false;
|
|
511
|
+
let timer;
|
|
512
|
+
const arm = () => {
|
|
513
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
514
|
+
timer = setTimeout(() => {
|
|
515
|
+
expired = true;
|
|
516
|
+
controller.abort(/* @__PURE__ */ new Error(`stream idle timeout after ${String(timeoutMs)}ms`));
|
|
517
|
+
}, timeoutMs);
|
|
518
|
+
timer.unref();
|
|
519
|
+
};
|
|
520
|
+
const onCallerAbort = () => controller.abort(caller?.reason);
|
|
521
|
+
if (caller?.aborted === true) controller.abort(caller.reason);
|
|
522
|
+
else caller?.addEventListener("abort", onCallerAbort, { once: true });
|
|
523
|
+
arm();
|
|
524
|
+
return {
|
|
525
|
+
signal: controller.signal,
|
|
526
|
+
pulse: arm,
|
|
527
|
+
stop() {
|
|
528
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
529
|
+
caller?.removeEventListener("abort", onCallerAbort);
|
|
530
|
+
},
|
|
531
|
+
timedOut: () => expired
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Classify a thrown fetch failure. Caller cancellation maps to ABORTED, idle
|
|
536
|
+
* expiry to TIMEOUT, and everything else (DNS, TLS, refused connection) to
|
|
537
|
+
* TRANSPORT with the cause chained.
|
|
538
|
+
* @param label - diagnostic prefix naming the provider API.
|
|
539
|
+
* @param error - the thrown value.
|
|
540
|
+
* @param watchdog - the request's idle watchdog.
|
|
541
|
+
* @param caller - the request's own abort signal, when present.
|
|
542
|
+
* @returns the classified error.
|
|
543
|
+
*/
|
|
544
|
+
function mapFetchFailure(label, error, watchdog, caller) {
|
|
545
|
+
if (watchdog.timedOut()) return new LlmError(`${label} stream idle timeout`, "TIMEOUT", { cause: error });
|
|
546
|
+
if (caller?.aborted === true) return new LlmError(`${label} request aborted by caller`, "ABORTED", { cause: error });
|
|
547
|
+
if (error instanceof LlmError) return error;
|
|
548
|
+
return new LlmError(`${label} request failed`, "TRANSPORT", { cause: error });
|
|
549
|
+
}
|
|
550
|
+
/** OAuth token-endpoint failure carrying the provider's `error` code when it sent one. */
|
|
551
|
+
var OAuthEndpointError = class extends Error {
|
|
552
|
+
/** HTTP status of the token endpoint response. */
|
|
553
|
+
status;
|
|
554
|
+
/** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
|
|
555
|
+
oauthCode;
|
|
556
|
+
constructor(message, status, oauthCode) {
|
|
557
|
+
super(message);
|
|
558
|
+
this.name = "OAuthEndpointError";
|
|
559
|
+
this.status = status;
|
|
560
|
+
this.oauthCode = oauthCode;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* Read an OAuth JSON error body into an {@link OAuthEndpointError}.
|
|
565
|
+
* @param response - the failed token-endpoint response.
|
|
566
|
+
* @param label - diagnostic prefix naming the provider.
|
|
567
|
+
* @returns the error to throw.
|
|
568
|
+
*/
|
|
569
|
+
async function oauthEndpointError(response, label) {
|
|
570
|
+
let oauthCode;
|
|
571
|
+
let detail = "";
|
|
572
|
+
try {
|
|
573
|
+
const parsed = await response.json();
|
|
574
|
+
oauthCode = typeof parsed.error === "string" ? parsed.error : void 0;
|
|
575
|
+
detail = typeof parsed.error_description === "string" ? parsed.error_description : oauthCode ?? "";
|
|
576
|
+
} catch {}
|
|
577
|
+
return new OAuthEndpointError(detail.length > 0 ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}` : `${label} token endpoint error (HTTP ${String(response.status)})`, response.status, oauthCode);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Per-provider session freshness: loads the stored session, refreshes
|
|
581
|
+
* proactively inside the preempt window or on demand after a 401, and
|
|
582
|
+
* coalesces concurrent refreshes behind one in-flight promise. Permanent
|
|
583
|
+
* refresh failures delete the stored session and surface INVALID_CREDENTIAL
|
|
584
|
+
* with a re-login hint; transient failures fall back to a still-valid token.
|
|
585
|
+
*/
|
|
586
|
+
var TokenManager = class {
|
|
587
|
+
inflight;
|
|
588
|
+
constructor(options) {
|
|
589
|
+
this.options = options;
|
|
590
|
+
this.options = options;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Read the stored session without any refresh side effect. Catalog queries
|
|
594
|
+
* (`listModels`) use this to decide whether the provider is logged in.
|
|
595
|
+
* @returns the stored session, or `undefined` when logged out.
|
|
596
|
+
*/
|
|
597
|
+
peek() {
|
|
598
|
+
return this.options.load();
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Whether a session is currently stored (cheap; never refreshes).
|
|
602
|
+
* @returns true when logged in.
|
|
603
|
+
*/
|
|
604
|
+
async hasSession() {
|
|
605
|
+
return await this.options.load() !== void 0;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Resolve a usable session, refreshing proactively or on demand.
|
|
609
|
+
* @param forceRefresh - refresh regardless of expiry (used after a 401).
|
|
610
|
+
* @returns the persisted session to send.
|
|
611
|
+
* @throws LlmError MISSING_CREDENTIAL when logged out, INVALID_CREDENTIAL
|
|
612
|
+
* when the refresh grant is permanently rejected.
|
|
613
|
+
*/
|
|
614
|
+
async session(forceRefresh = false) {
|
|
615
|
+
const session = await this.options.load();
|
|
616
|
+
if (session === void 0) throw new LlmError(`dsh-plugin-subscriptions: not logged in to ${this.options.displayName}; log in via Settings → Subscriptions in the dsh web app`, "MISSING_CREDENTIAL");
|
|
617
|
+
if (!forceRefresh && session.expiresAt - Date.now() > this.options.preemptMs) return session;
|
|
618
|
+
this.inflight ??= this.doRefresh(session).finally(() => {
|
|
619
|
+
this.inflight = void 0;
|
|
620
|
+
});
|
|
621
|
+
try {
|
|
622
|
+
return await this.inflight;
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (this.options.isPermanent(error)) {
|
|
625
|
+
await this.options.remove();
|
|
626
|
+
this.options.onRemoved?.();
|
|
627
|
+
throw new LlmError(`${this.options.displayName} login expired or was revoked; log in again via Settings → Subscriptions`, "INVALID_CREDENTIAL", { cause: error });
|
|
628
|
+
}
|
|
629
|
+
if (!forceRefresh && session.expiresAt > Date.now()) return session;
|
|
630
|
+
throw error instanceof LlmError ? error : new LlmError(`${this.options.displayName} token refresh failed`, "AUTH", { cause: error });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async doRefresh(session) {
|
|
634
|
+
const current = await this.options.load();
|
|
635
|
+
if (current !== void 0 && current.accessToken !== session.accessToken && current.expiresAt - Date.now() > this.options.preemptMs) return current;
|
|
636
|
+
const next = await this.options.refresh(current ?? session);
|
|
637
|
+
await this.options.save(next);
|
|
638
|
+
return next;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
/** How long a discovered catalog is trusted before re-fetching. */
|
|
642
|
+
const DISCOVERY_TTL_MS = 5 * 6e4;
|
|
643
|
+
/**
|
|
644
|
+
* TTL cache for one provider's discovered model catalog. Only `listModels`
|
|
645
|
+
* populates it (via {@link get}); `resolveModel` reads {@link cached} so it
|
|
646
|
+
* never performs network I/O. A 401 during a fetch must call
|
|
647
|
+
* {@link invalidate}.
|
|
648
|
+
*/
|
|
649
|
+
var ModelCatalogCache = class {
|
|
650
|
+
entry;
|
|
651
|
+
constructor(ttlMs = DISCOVERY_TTL_MS) {
|
|
652
|
+
this.ttlMs = ttlMs;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The cached catalog when fresh, without fetching.
|
|
656
|
+
* @returns the cached models, or `undefined` when absent or stale.
|
|
657
|
+
*/
|
|
658
|
+
cached() {
|
|
659
|
+
if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
|
|
660
|
+
return this.entry.models;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Return the cached catalog when fresh, otherwise fetch and cache it.
|
|
664
|
+
* @param fetcher - performs the provider's model-list request.
|
|
665
|
+
* @returns the discovered models.
|
|
666
|
+
*/
|
|
667
|
+
async get(fetcher) {
|
|
668
|
+
const cached = this.cached();
|
|
669
|
+
if (cached !== void 0) return cached;
|
|
670
|
+
const models = await fetcher();
|
|
671
|
+
this.entry = {
|
|
672
|
+
at: Date.now(),
|
|
673
|
+
models
|
|
674
|
+
};
|
|
675
|
+
return models;
|
|
676
|
+
}
|
|
677
|
+
/** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
|
|
678
|
+
invalidate() {
|
|
679
|
+
this.entry = void 0;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/auth/jwt.ts
|
|
685
|
+
/** Minimal JWT payload decoding for claims extraction (no signature verification). */
|
|
686
|
+
/**
|
|
687
|
+
* Decode a JWT payload without verifying the signature. Used only to read
|
|
688
|
+
* account claims from `id_token`s issued over the provider's own TLS channel
|
|
689
|
+
* during a code exchange we initiated — never to authorize anything.
|
|
690
|
+
* @param token - the compact JWT string.
|
|
691
|
+
* @returns the parsed payload object, or `undefined` when the token is not a
|
|
692
|
+
* well-formed JWT with a JSON object payload.
|
|
693
|
+
*/
|
|
694
|
+
function decodeJwtPayload(token) {
|
|
695
|
+
const parts = token.split(".");
|
|
696
|
+
if (parts.length < 2) return void 0;
|
|
697
|
+
let parsed;
|
|
698
|
+
try {
|
|
699
|
+
parsed = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
700
|
+
} catch {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
704
|
+
return parsed;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/translate/resolved.ts
|
|
709
|
+
/**
|
|
710
|
+
* Resolve every ImageBlock's attachment reference to inline base64 bytes.
|
|
711
|
+
* Messages without images pass through unchanged. A request carrying an image
|
|
712
|
+
* with no attachment service available fails loudly rather than silently
|
|
713
|
+
* dropping the image.
|
|
714
|
+
* @param messages - the request's conversation messages.
|
|
715
|
+
* @param attachments - the deployment's attachment service, when mounted.
|
|
716
|
+
* @param signal - cancellation for the storage reads.
|
|
717
|
+
* @returns the same messages with image blocks resolved for the translators.
|
|
718
|
+
*/
|
|
719
|
+
async function resolveImages(messages, attachments, signal) {
|
|
720
|
+
if (!messages.some((message) => message.content.some((block) => block.type === "image"))) return messages;
|
|
721
|
+
if (attachments === void 0) throw new LlmError("dsh-plugin-subscriptions: the request carries an image but no attachments service is mounted; image input requires the harness attachment store", "UNSUPPORTED");
|
|
722
|
+
return Promise.all(messages.map(async (message) => ({
|
|
723
|
+
role: message.role,
|
|
724
|
+
content: await Promise.all(message.content.map(async (block) => {
|
|
725
|
+
if (block.type !== "image") return block;
|
|
726
|
+
const stored = await attachments.readImage(block.attachment, signal);
|
|
727
|
+
return {
|
|
728
|
+
type: "image",
|
|
729
|
+
mediaType: stored.ref.mediaType,
|
|
730
|
+
dataBase64: Buffer.from(stored.data).toString("base64")
|
|
731
|
+
};
|
|
732
|
+
}))
|
|
733
|
+
})));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/translate/sse.ts
|
|
738
|
+
/**
|
|
739
|
+
* Decode an SSE byte stream into events.
|
|
740
|
+
* @param stream - raw response bytes; reads may split anywhere, including mid-UTF-8 sequence.
|
|
741
|
+
* @param onActivity - called on every received chunk and comment line; drives the idle watchdog.
|
|
742
|
+
* @returns events in arrival order.
|
|
743
|
+
*/
|
|
744
|
+
async function* parseSse(stream, onActivity) {
|
|
745
|
+
const reader = stream.getReader();
|
|
746
|
+
const decoder = new TextDecoder();
|
|
747
|
+
let pending = "";
|
|
748
|
+
let dataLines = [];
|
|
749
|
+
let eventName;
|
|
750
|
+
try {
|
|
751
|
+
while (true) {
|
|
752
|
+
const { done, value } = await reader.read();
|
|
753
|
+
if (done) return;
|
|
754
|
+
onActivity?.();
|
|
755
|
+
pending += decoder.decode(value, { stream: true });
|
|
756
|
+
let newline = pending.indexOf("\n");
|
|
757
|
+
while (newline >= 0) {
|
|
758
|
+
let line = pending.slice(0, newline);
|
|
759
|
+
pending = pending.slice(newline + 1);
|
|
760
|
+
newline = pending.indexOf("\n");
|
|
761
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
762
|
+
if (line.length === 0) {
|
|
763
|
+
if (dataLines.length > 0) yield {
|
|
764
|
+
data: dataLines.join("\n"),
|
|
765
|
+
...eventName === void 0 ? {} : { event: eventName }
|
|
766
|
+
};
|
|
767
|
+
dataLines = [];
|
|
768
|
+
eventName = void 0;
|
|
769
|
+
} else if (line.startsWith(":")) onActivity?.();
|
|
770
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
771
|
+
else if (line.startsWith("event:")) eventName = line.slice(6).replace(/^ /, "");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
} finally {
|
|
775
|
+
reader.releaseLock();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/translate/responses.ts
|
|
781
|
+
/** Flatten a tool result's content to plain text for `function_call_output`. */
|
|
782
|
+
function toolResultText$1(block) {
|
|
783
|
+
return block.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Convert harness messages into Responses `instructions` + `input` items.
|
|
787
|
+
* System-role messages become `instructions`; an explicit `system` argument
|
|
788
|
+
* wins over them when both exist. Reasoning blocks are not replayed (v1).
|
|
789
|
+
* Images must arrive pre-resolved ({@link TranslatableMessage}); an unresolved
|
|
790
|
+
* ImageBlock is skipped because its bytes are unreachable here.
|
|
791
|
+
* @param messages - ordered conversation messages with resolved images.
|
|
792
|
+
* @param system - explicit system prompt, which takes precedence.
|
|
793
|
+
* @returns request fields ready to merge into the request body.
|
|
794
|
+
*/
|
|
795
|
+
function toResponsesInput(messages, system) {
|
|
796
|
+
const input = [];
|
|
797
|
+
const systemTexts = [];
|
|
798
|
+
for (const message of messages) {
|
|
799
|
+
if (message.role === "system") {
|
|
800
|
+
for (const block of message.content) if (block.type === "text") systemTexts.push(block.text);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const role = message.role;
|
|
804
|
+
let content = [];
|
|
805
|
+
const flushMessage = () => {
|
|
806
|
+
if (content.length === 0) return;
|
|
807
|
+
input.push({
|
|
808
|
+
type: "message",
|
|
809
|
+
role,
|
|
810
|
+
content
|
|
811
|
+
});
|
|
812
|
+
content = [];
|
|
813
|
+
};
|
|
814
|
+
for (const block of message.content) switch (block.type) {
|
|
815
|
+
case "text":
|
|
816
|
+
content.push({
|
|
817
|
+
type: role === "assistant" ? "output_text" : "input_text",
|
|
818
|
+
text: block.text
|
|
819
|
+
});
|
|
820
|
+
break;
|
|
821
|
+
case "tool-call":
|
|
822
|
+
flushMessage();
|
|
823
|
+
input.push({
|
|
824
|
+
type: "function_call",
|
|
825
|
+
call_id: String(block.id),
|
|
826
|
+
name: block.name,
|
|
827
|
+
arguments: block.arguments
|
|
828
|
+
});
|
|
829
|
+
break;
|
|
830
|
+
case "tool-result":
|
|
831
|
+
flushMessage();
|
|
832
|
+
input.push({
|
|
833
|
+
type: "function_call_output",
|
|
834
|
+
call_id: String(block.toolCallId),
|
|
835
|
+
output: toolResultText$1(block)
|
|
836
|
+
});
|
|
837
|
+
break;
|
|
838
|
+
case "image":
|
|
839
|
+
if ("dataBase64" in block) content.push({
|
|
840
|
+
type: "input_image",
|
|
841
|
+
image_url: `data:${block.mediaType};base64,${block.dataBase64}`
|
|
842
|
+
});
|
|
843
|
+
break;
|
|
844
|
+
default: break;
|
|
845
|
+
}
|
|
846
|
+
flushMessage();
|
|
847
|
+
}
|
|
848
|
+
const instructions = system ?? (systemTexts.length > 0 ? systemTexts.join("\n\n") : void 0);
|
|
849
|
+
return {
|
|
850
|
+
...instructions === void 0 ? {} : { instructions },
|
|
851
|
+
input
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Map harness tool schemas to Responses function tools.
|
|
856
|
+
* @param tools - tool schemas from the request.
|
|
857
|
+
* @returns Responses `tools` array entries.
|
|
858
|
+
*/
|
|
859
|
+
function toResponsesTools(tools) {
|
|
860
|
+
return tools.map((tool) => ({
|
|
861
|
+
type: "function",
|
|
862
|
+
name: tool.name,
|
|
863
|
+
description: tool.description,
|
|
864
|
+
parameters: tool.parameters
|
|
865
|
+
}));
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Map Responses usage to disjoint harness counts (cached input is subtracted
|
|
869
|
+
* out of `inputTokens` and reported as `cacheReadTokens`).
|
|
870
|
+
* @param usage - wire usage from `response.completed`.
|
|
871
|
+
* @returns harness token usage.
|
|
872
|
+
*/
|
|
873
|
+
function mapResponsesUsage(usage) {
|
|
874
|
+
const cached = usage.input_tokens_details?.cached_tokens;
|
|
875
|
+
const reasoning = usage.output_tokens_details?.reasoning_tokens;
|
|
876
|
+
return {
|
|
877
|
+
inputTokens: usage.input_tokens - (cached ?? 0),
|
|
878
|
+
outputTokens: usage.output_tokens,
|
|
879
|
+
...cached !== void 0 ? { cacheReadTokens: cached } : {},
|
|
880
|
+
...reasoning !== void 0 ? { reasoningTokens: reasoning } : {}
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Classify a Responses failure payload into a thrown LlmError.
|
|
885
|
+
* @param code - provider error code, when present.
|
|
886
|
+
* @param message - provider error message, when present.
|
|
887
|
+
* @returns the mapped error (context overflow, quota, otherwise SERVER).
|
|
888
|
+
*/
|
|
889
|
+
function responsesFailure(code, message) {
|
|
890
|
+
const text = message ?? code ?? "the provider reported a failed response";
|
|
891
|
+
const detail = `${code ?? ""} ${message ?? ""}`;
|
|
892
|
+
if (code === "context_window_exceeded" || isContextWindowExceededError(detail)) return new LlmError(text, CONTEXT_WINDOW_EXCEEDED_CODE);
|
|
893
|
+
if (code !== void 0 && /insufficient|quota/i.test(code) || isQuotaExceededError(detail)) return new LlmError(text, QUOTA_EXCEEDED_CODE);
|
|
894
|
+
return new LlmError(text, "SERVER");
|
|
895
|
+
}
|
|
896
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
897
|
+
function closeBlock$1(block) {
|
|
898
|
+
switch (block.kind) {
|
|
899
|
+
case "text": return {
|
|
900
|
+
type: "text",
|
|
901
|
+
text: block.text
|
|
902
|
+
};
|
|
903
|
+
case "reasoning": return {
|
|
904
|
+
type: "reasoning",
|
|
905
|
+
text: block.text
|
|
906
|
+
};
|
|
907
|
+
case "tool-call": return {
|
|
908
|
+
type: "tool-call",
|
|
909
|
+
id: CallId(block.callId),
|
|
910
|
+
name: block.name ?? "",
|
|
911
|
+
arguments: block.text
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Push-model Responses SSE translator: feed each parsed event object to
|
|
917
|
+
* {@link push} and collect the emitted harness StreamChunks. Block indexes
|
|
918
|
+
* are allocated in first-seen order; `usage` is emitted before the terminal
|
|
919
|
+
* `finish`, and nothing is emitted after it. Terminal provider failures
|
|
920
|
+
* throw {@link LlmError}.
|
|
921
|
+
*/
|
|
922
|
+
var ResponsesStreamTranslator = class {
|
|
923
|
+
blocks = /* @__PURE__ */ new Map();
|
|
924
|
+
order = [];
|
|
925
|
+
nextIndex = 0;
|
|
926
|
+
sawToolCall = false;
|
|
927
|
+
/** Set once `response.completed` produced the terminal finish chunk. */
|
|
928
|
+
terminated = false;
|
|
929
|
+
open(key, kind, chunks, callId = "", name$1) {
|
|
930
|
+
const block = {
|
|
931
|
+
index: this.nextIndex++,
|
|
932
|
+
kind,
|
|
933
|
+
text: "",
|
|
934
|
+
callId,
|
|
935
|
+
...name$1 === void 0 ? {} : { name: name$1 }
|
|
936
|
+
};
|
|
937
|
+
this.blocks.set(key, block);
|
|
938
|
+
this.order.push(block);
|
|
939
|
+
chunks.push({
|
|
940
|
+
type: "block-start",
|
|
941
|
+
index: block.index,
|
|
942
|
+
blockType: kind
|
|
943
|
+
});
|
|
944
|
+
return block;
|
|
945
|
+
}
|
|
946
|
+
textBlock(key, chunks) {
|
|
947
|
+
return this.blocks.get(key) ?? this.open(key, "text", chunks);
|
|
948
|
+
}
|
|
949
|
+
reasoningBlock(key, chunks) {
|
|
950
|
+
return this.blocks.get(key) ?? this.open(key, "reasoning", chunks);
|
|
951
|
+
}
|
|
952
|
+
close(key, chunks) {
|
|
953
|
+
const block = this.blocks.get(key);
|
|
954
|
+
if (block === void 0) return;
|
|
955
|
+
this.blocks.delete(key);
|
|
956
|
+
chunks.push({
|
|
957
|
+
type: "block-end",
|
|
958
|
+
index: block.index,
|
|
959
|
+
block: closeBlock$1(block)
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
/** Close every still-open block for one output item (prefix match on the key). */
|
|
963
|
+
closeItem(itemId, chunks) {
|
|
964
|
+
for (const key of [...this.blocks.keys()]) if (key.startsWith(`${itemId}:`)) this.close(key, chunks);
|
|
965
|
+
}
|
|
966
|
+
/** Close every still-open block (provider ended the response without done events). */
|
|
967
|
+
closeAll(chunks) {
|
|
968
|
+
for (const block of this.order) this.closeKeyIfOpen(block, chunks);
|
|
969
|
+
}
|
|
970
|
+
closeKeyIfOpen(block, chunks) {
|
|
971
|
+
for (const [key, candidate] of this.blocks) if (candidate === block) {
|
|
972
|
+
this.blocks.delete(key);
|
|
973
|
+
chunks.push({
|
|
974
|
+
type: "block-end",
|
|
975
|
+
index: block.index,
|
|
976
|
+
block: closeBlock$1(block)
|
|
977
|
+
});
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Process one parsed Responses SSE event.
|
|
983
|
+
* @param event - the parsed event object.
|
|
984
|
+
* @returns the StreamChunks this event produced (possibly none).
|
|
985
|
+
*/
|
|
986
|
+
push(event) {
|
|
987
|
+
if (this.terminated) return [];
|
|
988
|
+
const chunks = [];
|
|
989
|
+
switch (event.type) {
|
|
990
|
+
case "response.output_item.added": {
|
|
991
|
+
const item = event.item;
|
|
992
|
+
if (item?.type === "function_call" && item.id !== void 0) {
|
|
993
|
+
this.sawToolCall = true;
|
|
994
|
+
const callId = item.call_id ?? "";
|
|
995
|
+
const block = this.open(`${item.id}:call`, "tool-call", chunks, callId, item.name);
|
|
996
|
+
chunks.push({
|
|
997
|
+
type: "tool-call-delta",
|
|
998
|
+
index: block.index,
|
|
999
|
+
id: CallId(callId),
|
|
1000
|
+
...item.name === void 0 ? {} : { name: item.name },
|
|
1001
|
+
argumentsDelta: ""
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
return chunks;
|
|
1005
|
+
}
|
|
1006
|
+
case "response.output_text.delta": {
|
|
1007
|
+
const key = `${event.item_id ?? ""}:text:${String(event.content_index ?? 0)}`;
|
|
1008
|
+
const block = this.textBlock(key, chunks);
|
|
1009
|
+
block.text += event.delta ?? "";
|
|
1010
|
+
chunks.push({
|
|
1011
|
+
type: "text-delta",
|
|
1012
|
+
index: block.index,
|
|
1013
|
+
text: event.delta ?? ""
|
|
1014
|
+
});
|
|
1015
|
+
return chunks;
|
|
1016
|
+
}
|
|
1017
|
+
case "response.reasoning_summary_text.delta":
|
|
1018
|
+
case "response.reasoning_text.delta": {
|
|
1019
|
+
const sub = event.summary_index ?? event.content_index ?? 0;
|
|
1020
|
+
const key = `${event.item_id ?? ""}:reason:${String(sub)}`;
|
|
1021
|
+
const block = this.reasoningBlock(key, chunks);
|
|
1022
|
+
block.text += event.delta ?? "";
|
|
1023
|
+
chunks.push({
|
|
1024
|
+
type: "reasoning-delta",
|
|
1025
|
+
index: block.index,
|
|
1026
|
+
text: event.delta ?? ""
|
|
1027
|
+
});
|
|
1028
|
+
return chunks;
|
|
1029
|
+
}
|
|
1030
|
+
case "response.function_call_arguments.delta": {
|
|
1031
|
+
const key = `${event.item_id ?? ""}:call`;
|
|
1032
|
+
let block = this.blocks.get(key);
|
|
1033
|
+
if (block === void 0) {
|
|
1034
|
+
this.sawToolCall = true;
|
|
1035
|
+
block = this.open(key, "tool-call", chunks);
|
|
1036
|
+
}
|
|
1037
|
+
block.text += event.delta ?? "";
|
|
1038
|
+
chunks.push({
|
|
1039
|
+
type: "tool-call-delta",
|
|
1040
|
+
index: block.index,
|
|
1041
|
+
id: CallId(block.callId),
|
|
1042
|
+
...block.name === void 0 ? {} : { name: block.name },
|
|
1043
|
+
argumentsDelta: event.delta ?? ""
|
|
1044
|
+
});
|
|
1045
|
+
return chunks;
|
|
1046
|
+
}
|
|
1047
|
+
case "response.output_item.done": {
|
|
1048
|
+
const item = event.item;
|
|
1049
|
+
if (item === void 0 || item.id === void 0) return chunks;
|
|
1050
|
+
if (item.type === "function_call") {
|
|
1051
|
+
const key = `${item.id}:call`;
|
|
1052
|
+
const block = this.blocks.get(key);
|
|
1053
|
+
if (block !== void 0 && block.text.length === 0 && item.arguments !== void 0) block.text = item.arguments;
|
|
1054
|
+
this.close(key, chunks);
|
|
1055
|
+
} else if (item.type === "message") {
|
|
1056
|
+
if (![...this.blocks.keys()].some((key) => key.startsWith(`${item.id}:text:`))) for (const [partIndex, part] of (item.content ?? []).entries()) {
|
|
1057
|
+
if (part?.type !== "output_text" || typeof part.text !== "string" || part.text.length === 0) continue;
|
|
1058
|
+
const block = this.open(`${item.id}:text:${partIndex}`, "text", chunks);
|
|
1059
|
+
block.text = part.text;
|
|
1060
|
+
this.close(`${item.id}:text:${partIndex}`, chunks);
|
|
1061
|
+
}
|
|
1062
|
+
this.closeItem(item.id, chunks);
|
|
1063
|
+
} else this.closeItem(item.id, chunks);
|
|
1064
|
+
return chunks;
|
|
1065
|
+
}
|
|
1066
|
+
case "response.completed": {
|
|
1067
|
+
this.terminated = true;
|
|
1068
|
+
this.closeAll(chunks);
|
|
1069
|
+
const usage = event.response?.usage;
|
|
1070
|
+
if (usage !== void 0) chunks.push({
|
|
1071
|
+
type: "usage",
|
|
1072
|
+
usage: mapResponsesUsage(usage)
|
|
1073
|
+
});
|
|
1074
|
+
if (this.order.length === 0) chunks.push({
|
|
1075
|
+
type: "finish",
|
|
1076
|
+
reason: {
|
|
1077
|
+
kind: "error",
|
|
1078
|
+
failure: {
|
|
1079
|
+
message: "model returned a completed response with no content",
|
|
1080
|
+
code: EMPTY_RESPONSE_CODE
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
else chunks.push({
|
|
1085
|
+
type: "finish",
|
|
1086
|
+
reason: { kind: this.sawToolCall ? "tool-calls" : "stop" }
|
|
1087
|
+
});
|
|
1088
|
+
return chunks;
|
|
1089
|
+
}
|
|
1090
|
+
case "response.failed": throw responsesFailure(event.response?.error?.code, event.response?.error?.message);
|
|
1091
|
+
case "response.incomplete": throw responsesFailure(event.response?.incomplete_details?.reason, event.response?.error?.message ?? `the provider reported an incomplete response (${event.response?.incomplete_details?.reason ?? "unknown reason"})`);
|
|
1092
|
+
case "error": throw responsesFailure(event.code, event.message);
|
|
1093
|
+
default: return chunks;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
/**
|
|
1098
|
+
* Consume a Responses SSE byte stream and yield harness StreamChunks.
|
|
1099
|
+
* @param stream - raw response body.
|
|
1100
|
+
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
1101
|
+
* @returns the chunk stream; throws when the stream ends before `response.completed`.
|
|
1102
|
+
*/
|
|
1103
|
+
async function* streamResponses(stream, onActivity) {
|
|
1104
|
+
const translator = new ResponsesStreamTranslator();
|
|
1105
|
+
for await (const sseEvent of parseSse(stream, onActivity)) {
|
|
1106
|
+
let event;
|
|
1107
|
+
try {
|
|
1108
|
+
event = JSON.parse(sseEvent.data);
|
|
1109
|
+
} catch {
|
|
1110
|
+
throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
1111
|
+
}
|
|
1112
|
+
yield* translator.push(event);
|
|
1113
|
+
if (translator.terminated) return;
|
|
1114
|
+
}
|
|
1115
|
+
throw new LlmError("Responses SSE stream ended before response.completed", "STREAM_CLOSED");
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/providers/codex.ts
|
|
1120
|
+
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
1121
|
+
const CODEX_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
1122
|
+
const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
1123
|
+
const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
1124
|
+
const CODEX_SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
1125
|
+
const CODEX_CALLBACK_PATH = "/auth/callback";
|
|
1126
|
+
const CODEX_CONTEXT_WINDOW = 4e5;
|
|
1127
|
+
const CODEX_DEFAULT_MAX_TOKENS = 128e3;
|
|
1128
|
+
/** Refresh when the access token has less than this much life left. */
|
|
1129
|
+
const CODEX_PREEMPT_MS = 5 * 6e4;
|
|
1130
|
+
/** Default instruction when the request carries no system prompt. */
|
|
1131
|
+
const DEFAULT_CODEX_INSTRUCTIONS = "You are Codex, a coding agent based on GPT-5. Help the user with their software engineering tasks.";
|
|
1132
|
+
/** Refresh-grant rejections that mean the login is gone for good. */
|
|
1133
|
+
const PERMANENT_REFRESH_CODES = new Set([
|
|
1134
|
+
"refresh_token_expired",
|
|
1135
|
+
"refresh_token_reused",
|
|
1136
|
+
"refresh_token_invalidated",
|
|
1137
|
+
"invalid_grant"
|
|
1138
|
+
]);
|
|
1139
|
+
const CODEX_EFFORTS = [
|
|
1140
|
+
{
|
|
1141
|
+
id: ReasoningEffortId("minimal"),
|
|
1142
|
+
name: "Minimal"
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
id: ReasoningEffortId("low"),
|
|
1146
|
+
name: "Low"
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
id: ReasoningEffortId("medium"),
|
|
1150
|
+
name: "Medium"
|
|
1151
|
+
},
|
|
1152
|
+
{
|
|
1153
|
+
id: ReasoningEffortId("high"),
|
|
1154
|
+
name: "High"
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
id: ReasoningEffortId("xhigh"),
|
|
1158
|
+
name: "Extra High"
|
|
1159
|
+
}
|
|
1160
|
+
];
|
|
1161
|
+
const CODEX_DEFAULT_EFFORT = ReasoningEffortId("high");
|
|
1162
|
+
/** Every gpt-5.x codex model accepts image input. */
|
|
1163
|
+
const CODEX_MODALITIES = ["text", "image"];
|
|
1164
|
+
/** Static codex flow facts for the OAuth flow engine. */
|
|
1165
|
+
const codexFlow = {
|
|
1166
|
+
callbackPath: CODEX_CALLBACK_PATH,
|
|
1167
|
+
listen: {
|
|
1168
|
+
host: "localhost",
|
|
1169
|
+
ports: [1455, 1457]
|
|
1170
|
+
},
|
|
1171
|
+
buildAuthorizeUrl({ redirectUri, state, pkce }) {
|
|
1172
|
+
return `${CODEX_AUTHORIZE_URL}?${new URLSearchParams({
|
|
1173
|
+
response_type: "code",
|
|
1174
|
+
client_id: CODEX_CLIENT_ID,
|
|
1175
|
+
redirect_uri: redirectUri,
|
|
1176
|
+
scope: CODEX_SCOPE,
|
|
1177
|
+
code_challenge: pkce.challenge,
|
|
1178
|
+
code_challenge_method: "S256",
|
|
1179
|
+
state,
|
|
1180
|
+
id_token_add_organizations: "true",
|
|
1181
|
+
codex_cli_simplified_flow: "true",
|
|
1182
|
+
originator: "codex_cli_rs"
|
|
1183
|
+
}).toString()}`;
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
/** Pull `chatgpt_account_id` out of an id token payload. */
|
|
1187
|
+
function accountIdOf(idToken) {
|
|
1188
|
+
const auth = (idToken === void 0 ? void 0 : decodeJwtPayload(idToken))?.["https://api.openai.com/auth"];
|
|
1189
|
+
const accountId = typeof auth === "object" && auth !== null ? auth.chatgpt_account_id : void 0;
|
|
1190
|
+
if (typeof accountId !== "string" || accountId.length === 0) throw new Error("codex login did not return a chatgpt account id; cannot use the subscription");
|
|
1191
|
+
return accountId;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Decode the user-identity claims of a codex id token (pure, cheap — no
|
|
1195
|
+
* verification, same trust posture as {@link accountIdOf}). Claim paths
|
|
1196
|
+
* mirror codex-rs `login/src/token_data.rs`: the email is the top-level
|
|
1197
|
+
* `email` claim, falling back to `https://api.openai.com/profile`.email; the
|
|
1198
|
+
* plan is `https://api.openai.com/auth`.chatgpt_plan_type.
|
|
1199
|
+
* @param idToken - a stored or freshly issued id token, when present.
|
|
1200
|
+
* @returns whichever claims the token carried; empty when undecodable.
|
|
1201
|
+
*/
|
|
1202
|
+
function codexProfileClaims(idToken) {
|
|
1203
|
+
const payload = idToken === void 0 ? void 0 : decodeJwtPayload(idToken);
|
|
1204
|
+
if (payload === void 0) return {};
|
|
1205
|
+
const profile = payload["https://api.openai.com/profile"];
|
|
1206
|
+
const profileEmail = typeof profile === "object" && profile !== null ? profile.email : void 0;
|
|
1207
|
+
const email = payload.email ?? profileEmail;
|
|
1208
|
+
const auth = payload["https://api.openai.com/auth"];
|
|
1209
|
+
const plan = typeof auth === "object" && auth !== null ? auth.chatgpt_plan_type : void 0;
|
|
1210
|
+
return {
|
|
1211
|
+
...typeof email === "string" && email.length > 0 ? { emailAddress: email } : {},
|
|
1212
|
+
...typeof plan === "string" && plan.length > 0 ? { planType: plan } : {}
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** Build a session from a token response; expires_in wins, JWT exp is the fallback. */
|
|
1216
|
+
function codexSession(tokens, fallback) {
|
|
1217
|
+
if (typeof tokens.access_token !== "string" || tokens.access_token.length === 0) throw new Error("codex token endpoint returned no access token");
|
|
1218
|
+
const refreshToken = tokens.refresh_token ?? fallback?.refreshToken;
|
|
1219
|
+
if (refreshToken === void 0) throw new Error("codex token endpoint returned no refresh token");
|
|
1220
|
+
let expiresAt;
|
|
1221
|
+
if (typeof tokens.expires_in === "number" && tokens.expires_in > 0) expiresAt = Date.now() + tokens.expires_in * 1e3;
|
|
1222
|
+
else {
|
|
1223
|
+
const exp = decodeJwtPayload(tokens.access_token)?.exp;
|
|
1224
|
+
if (typeof exp === "number" && exp > 0) expiresAt = exp * 1e3;
|
|
1225
|
+
}
|
|
1226
|
+
if (expiresAt === void 0) throw new Error("codex token endpoint returned no usable expiry");
|
|
1227
|
+
const idToken = tokens.id_token ?? fallback?.idToken;
|
|
1228
|
+
const claims = {
|
|
1229
|
+
...fallback?.emailAddress === void 0 ? {} : { emailAddress: fallback.emailAddress },
|
|
1230
|
+
...fallback?.planType === void 0 ? {} : { planType: fallback.planType },
|
|
1231
|
+
...codexProfileClaims(tokens.id_token)
|
|
1232
|
+
};
|
|
1233
|
+
return {
|
|
1234
|
+
accessToken: tokens.access_token,
|
|
1235
|
+
refreshToken,
|
|
1236
|
+
expiresAt,
|
|
1237
|
+
accountId: tokens.id_token === void 0 && fallback !== void 0 ? fallback.accountId : accountIdOf(tokens.id_token),
|
|
1238
|
+
...idToken === void 0 ? {} : { idToken },
|
|
1239
|
+
...claims
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Exchange an authorization code for a codex session (form-encoded grant).
|
|
1244
|
+
* @param code - the authorization code from the callback.
|
|
1245
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
1246
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
1247
|
+
* @returns the session to store.
|
|
1248
|
+
*/
|
|
1249
|
+
async function exchangeCodexCode(code, verifier, redirectUri) {
|
|
1250
|
+
const response = await fetch(CODEX_TOKEN_URL, {
|
|
1251
|
+
method: "POST",
|
|
1252
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1253
|
+
body: new URLSearchParams({
|
|
1254
|
+
grant_type: "authorization_code",
|
|
1255
|
+
code,
|
|
1256
|
+
redirect_uri: redirectUri,
|
|
1257
|
+
client_id: CODEX_CLIENT_ID,
|
|
1258
|
+
code_verifier: verifier
|
|
1259
|
+
}).toString()
|
|
1260
|
+
});
|
|
1261
|
+
if (!response.ok) throw await oauthEndpointError(response, "codex");
|
|
1262
|
+
return codexSession(await response.json());
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Refresh a codex session (JSON grant — unlike the code exchange).
|
|
1266
|
+
* @param session - the stored session.
|
|
1267
|
+
* @returns the fresh session to store.
|
|
1268
|
+
*/
|
|
1269
|
+
async function refreshCodex(session) {
|
|
1270
|
+
const response = await fetch(CODEX_TOKEN_URL, {
|
|
1271
|
+
method: "POST",
|
|
1272
|
+
headers: { "content-type": "application/json" },
|
|
1273
|
+
body: JSON.stringify({
|
|
1274
|
+
client_id: CODEX_CLIENT_ID,
|
|
1275
|
+
grant_type: "refresh_token",
|
|
1276
|
+
refresh_token: session.refreshToken
|
|
1277
|
+
})
|
|
1278
|
+
});
|
|
1279
|
+
if (!response.ok) throw await oauthEndpointError(response, "codex");
|
|
1280
|
+
return codexSession(await response.json(), session);
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Whether a codex refresh failure means the login is permanently gone.
|
|
1284
|
+
* @param error - the thrown refresh error.
|
|
1285
|
+
* @returns true when re-login is the only fix.
|
|
1286
|
+
*/
|
|
1287
|
+
function isCodexPermanentRefreshError(error) {
|
|
1288
|
+
return error instanceof OAuthEndpointError && error.oauthCode !== void 0 && PERMANENT_REFRESH_CODES.has(error.oauthCode);
|
|
1289
|
+
}
|
|
1290
|
+
const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
|
|
1291
|
+
/**
|
|
1292
|
+
* Client version sent on the /models catalog request. The backend gates the
|
|
1293
|
+
* visible model list by client version: versions below ~0.101 get an empty
|
|
1294
|
+
* list, while current codex CLI releases get the full catalog — keep this in
|
|
1295
|
+
* the range of current codex CLI releases.
|
|
1296
|
+
*/
|
|
1297
|
+
const CODEX_CLIENT_VERSION = "0.147.0";
|
|
1298
|
+
/** Display name for a wire reasoning-effort value. */
|
|
1299
|
+
function effortName(effort) {
|
|
1300
|
+
return effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Fetch the live codex model catalog with the session's auth headers.
|
|
1304
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
1305
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
1306
|
+
* @returns discovered models: hidden entries dropped, sorted by priority.
|
|
1307
|
+
*/
|
|
1308
|
+
async function fetchCodexModels(session, fetchFn = fetch) {
|
|
1309
|
+
const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, { headers: {
|
|
1310
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
1311
|
+
"chatgpt-account-id": session.accountId,
|
|
1312
|
+
"originator": "codex_cli_rs",
|
|
1313
|
+
"accept": "application/json",
|
|
1314
|
+
...attributionHeaders()
|
|
1315
|
+
} });
|
|
1316
|
+
if (!response.ok) throw await oauthEndpointError(response, "codex models");
|
|
1317
|
+
const payload = await response.json();
|
|
1318
|
+
if (!Array.isArray(payload.models)) throw new Error("codex models endpoint returned no models array");
|
|
1319
|
+
const discovered = [];
|
|
1320
|
+
for (const entry of payload.models) {
|
|
1321
|
+
if (typeof entry.slug !== "string" || entry.slug.length === 0) continue;
|
|
1322
|
+
if (entry.visibility === "hide" || entry.visibility === "none") continue;
|
|
1323
|
+
const efforts = (entry.supported_reasoning_levels ?? []).filter((level) => typeof level.effort === "string" && level.effort.length > 0).map((level) => ({
|
|
1324
|
+
id: ReasoningEffortId(level.effort),
|
|
1325
|
+
name: effortName(level.effort),
|
|
1326
|
+
...level.description === void 0 ? {} : { description: level.description }
|
|
1327
|
+
}));
|
|
1328
|
+
const defaultEffort = typeof entry.default_reasoning_level === "string" && entry.default_reasoning_level.length > 0 && efforts.some((effort) => effort.id === ReasoningEffortId(entry.default_reasoning_level)) ? ReasoningEffortId(entry.default_reasoning_level) : void 0;
|
|
1329
|
+
discovered.push({
|
|
1330
|
+
id: entry.slug,
|
|
1331
|
+
name: typeof entry.display_name === "string" && entry.display_name.length > 0 ? entry.display_name : entry.slug,
|
|
1332
|
+
...typeof entry.description === "string" && entry.description.length > 0 ? { description: entry.description } : {},
|
|
1333
|
+
...typeof entry.context_window === "number" && entry.context_window > 0 ? { contextWindow: entry.context_window } : {},
|
|
1334
|
+
...typeof entry.priority === "number" ? { priority: entry.priority } : {},
|
|
1335
|
+
...efforts.length > 0 ? { reasoning: {
|
|
1336
|
+
efforts,
|
|
1337
|
+
...defaultEffort === void 0 ? {} : { defaultEffort }
|
|
1338
|
+
} } : {}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
discovered.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
|
|
1342
|
+
if (discovered.length === 0) throw new Error(`codex models endpoint returned an empty catalog (client_version ${CODEX_CLIENT_VERSION})`);
|
|
1343
|
+
return discovered;
|
|
1344
|
+
}
|
|
1345
|
+
/** Codex wire adapter: one instance serves the `codex` provider route. */
|
|
1346
|
+
var CodexAdapter = class extends LlmAdapter {
|
|
1347
|
+
catalog = new ModelCatalogCache();
|
|
1348
|
+
constructor(options) {
|
|
1349
|
+
super();
|
|
1350
|
+
this.options = options;
|
|
1351
|
+
}
|
|
1352
|
+
providerInfo(provider) {
|
|
1353
|
+
return {
|
|
1354
|
+
id: provider,
|
|
1355
|
+
name: "ChatGPT (Codex)"
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
staticModels(provider) {
|
|
1359
|
+
return this.options.models.map((model) => ({
|
|
1360
|
+
provider,
|
|
1361
|
+
id: model.id,
|
|
1362
|
+
name: model.name ?? model.id,
|
|
1363
|
+
inputModalities: model.inputModalities ?? CODEX_MODALITIES
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
async listModels(provider) {
|
|
1367
|
+
const session = await this.options.tokens.peek();
|
|
1368
|
+
if (session === void 0) return [];
|
|
1369
|
+
if (!this.options.discovery) return this.staticModels(provider);
|
|
1370
|
+
try {
|
|
1371
|
+
return (await this.catalog.get(() => fetchCodexModels(session, this.options.fetchFn))).map((model) => ({
|
|
1372
|
+
provider,
|
|
1373
|
+
id: model.id,
|
|
1374
|
+
name: model.name,
|
|
1375
|
+
...model.description === void 0 ? {} : { description: model.description },
|
|
1376
|
+
inputModalities: CODEX_MODALITIES
|
|
1377
|
+
}));
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
|
|
1380
|
+
this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
1381
|
+
return this.staticModels(provider);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
resolveModel(provider, model) {
|
|
1385
|
+
const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
|
|
1386
|
+
const configured = this.options.models.find((entry) => entry.id === model);
|
|
1387
|
+
return Promise.resolve({
|
|
1388
|
+
provider,
|
|
1389
|
+
id: model,
|
|
1390
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
1391
|
+
...discovered?.description === void 0 ? {} : { description: discovered.description },
|
|
1392
|
+
inputModalities: configured?.inputModalities ?? CODEX_MODALITIES,
|
|
1393
|
+
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? CODEX_CONTEXT_WINDOW },
|
|
1394
|
+
defaultMaxTokens: configured?.maxTokens ?? CODEX_DEFAULT_MAX_TOKENS,
|
|
1395
|
+
reasoning: discovered?.reasoning ?? {
|
|
1396
|
+
efforts: CODEX_EFFORTS,
|
|
1397
|
+
defaultEffort: CODEX_DEFAULT_EFFORT
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
async *stream(options) {
|
|
1402
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
1403
|
+
try {
|
|
1404
|
+
let session = await this.options.tokens.session();
|
|
1405
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
1406
|
+
if (response.status === 401) {
|
|
1407
|
+
session = await this.options.tokens.session(true);
|
|
1408
|
+
response = await this.request(options, session, watchdog.signal);
|
|
1409
|
+
}
|
|
1410
|
+
if (!response.ok) throw await httpLlmError(response, "codex API");
|
|
1411
|
+
if (response.body === null) throw new LlmError("codex API returned no response body", EMPTY_RESPONSE_CODE);
|
|
1412
|
+
yield* streamResponses(response.body, () => {
|
|
1413
|
+
watchdog.pulse();
|
|
1414
|
+
});
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
throw mapFetchFailure("codex API", error, watchdog, options.signal);
|
|
1417
|
+
} finally {
|
|
1418
|
+
watchdog.stop();
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
async request(options, session, signal) {
|
|
1422
|
+
const { instructions, input } = toResponsesInput(await resolveImages(options.messages, this.options.resolveAttachments?.(), signal), options.system);
|
|
1423
|
+
const body = {
|
|
1424
|
+
model: options.model,
|
|
1425
|
+
instructions: instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
|
|
1426
|
+
input,
|
|
1427
|
+
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
|
|
1428
|
+
tool_choice: "auto",
|
|
1429
|
+
parallel_tool_calls: true,
|
|
1430
|
+
...options.reasoningEffort !== void 0 ? { reasoning: {
|
|
1431
|
+
effort: String(options.reasoningEffort),
|
|
1432
|
+
summary: "auto"
|
|
1433
|
+
} } : {},
|
|
1434
|
+
store: false,
|
|
1435
|
+
stream: true,
|
|
1436
|
+
include: ["reasoning.encrypted_content"],
|
|
1437
|
+
...options.sessionId !== void 0 ? { prompt_cache_key: String(options.sessionId) } : {}
|
|
1438
|
+
};
|
|
1439
|
+
return fetch(CODEX_API_URL, {
|
|
1440
|
+
method: "POST",
|
|
1441
|
+
headers: {
|
|
1442
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
1443
|
+
"chatgpt-account-id": session.accountId,
|
|
1444
|
+
"originator": "codex_cli_rs",
|
|
1445
|
+
"session-id": randomUUID(),
|
|
1446
|
+
"accept": "text/event-stream",
|
|
1447
|
+
"content-type": "application/json",
|
|
1448
|
+
...attributionHeaders()
|
|
1449
|
+
},
|
|
1450
|
+
body: JSON.stringify(body),
|
|
1451
|
+
signal
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/translate/anthropic.ts
|
|
1458
|
+
/**
|
|
1459
|
+
* The Claude Code identity block. The subscription endpoint rejects requests
|
|
1460
|
+
* that do not present as Claude Code, so this block is REQUIRED as the first
|
|
1461
|
+
* system entry on every request.
|
|
1462
|
+
*/
|
|
1463
|
+
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
1464
|
+
/** Flatten a tool result's content to plain text for `tool_result`. */
|
|
1465
|
+
function toolResultText(block) {
|
|
1466
|
+
return block.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
1467
|
+
}
|
|
1468
|
+
/** Parse a tool call's raw JSON arguments into Anthropic's object-shaped `input`. */
|
|
1469
|
+
function parseToolInput(raw) {
|
|
1470
|
+
try {
|
|
1471
|
+
const parsed = JSON.parse(raw);
|
|
1472
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
1473
|
+
return {};
|
|
1474
|
+
} catch {
|
|
1475
|
+
return {};
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Convert harness messages into Anthropic messages. Consecutive same-role
|
|
1480
|
+
* messages merge into one message with multiple content blocks; tool results
|
|
1481
|
+
* arrive as user messages with `tool_result` blocks; system-role messages are
|
|
1482
|
+
* handled by {@link toAnthropicSystem} and skipped here. Reasoning blocks are
|
|
1483
|
+
* not replayed (v1). Images must arrive pre-resolved
|
|
1484
|
+
* ({@link TranslatableMessage}); an unresolved ImageBlock is skipped because
|
|
1485
|
+
* its bytes are unreachable here.
|
|
1486
|
+
* @param messages - ordered conversation messages with resolved images.
|
|
1487
|
+
* @returns Anthropic messages in conversation order.
|
|
1488
|
+
*/
|
|
1489
|
+
function toAnthropicMessages(messages) {
|
|
1490
|
+
const out = [];
|
|
1491
|
+
for (const message of messages) {
|
|
1492
|
+
if (message.role === "system") continue;
|
|
1493
|
+
const role = message.role;
|
|
1494
|
+
const blocks = [];
|
|
1495
|
+
for (const block of message.content) switch (block.type) {
|
|
1496
|
+
case "text":
|
|
1497
|
+
blocks.push({
|
|
1498
|
+
type: "text",
|
|
1499
|
+
text: block.text
|
|
1500
|
+
});
|
|
1501
|
+
break;
|
|
1502
|
+
case "tool-call":
|
|
1503
|
+
blocks.push({
|
|
1504
|
+
type: "tool_use",
|
|
1505
|
+
id: String(block.id),
|
|
1506
|
+
name: block.name,
|
|
1507
|
+
input: parseToolInput(block.arguments)
|
|
1508
|
+
});
|
|
1509
|
+
break;
|
|
1510
|
+
case "tool-result":
|
|
1511
|
+
blocks.push({
|
|
1512
|
+
type: "tool_result",
|
|
1513
|
+
tool_use_id: String(block.toolCallId),
|
|
1514
|
+
content: toolResultText(block),
|
|
1515
|
+
...block.isError === true ? { is_error: true } : {}
|
|
1516
|
+
});
|
|
1517
|
+
break;
|
|
1518
|
+
case "image":
|
|
1519
|
+
if ("dataBase64" in block) blocks.push({
|
|
1520
|
+
type: "image",
|
|
1521
|
+
source: {
|
|
1522
|
+
type: "base64",
|
|
1523
|
+
media_type: block.mediaType,
|
|
1524
|
+
data: block.dataBase64
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
break;
|
|
1528
|
+
default: break;
|
|
1529
|
+
}
|
|
1530
|
+
if (blocks.length === 0) continue;
|
|
1531
|
+
const last = out[out.length - 1];
|
|
1532
|
+
if (last !== void 0 && last.role === role) last.content.push(...blocks);
|
|
1533
|
+
else out.push({
|
|
1534
|
+
role,
|
|
1535
|
+
content: blocks
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
return out;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Build the Anthropic `system` array: the mandatory Claude Code identity
|
|
1542
|
+
* block, then the explicit system prompt, then any system-role messages.
|
|
1543
|
+
* @param system - explicit system prompt, when set.
|
|
1544
|
+
* @param messages - conversation messages; their system-role text is appended.
|
|
1545
|
+
* @returns the system content blocks.
|
|
1546
|
+
*/
|
|
1547
|
+
function toAnthropicSystem(system, messages) {
|
|
1548
|
+
const blocks = [{
|
|
1549
|
+
type: "text",
|
|
1550
|
+
text: CLAUDE_CODE_IDENTITY
|
|
1551
|
+
}];
|
|
1552
|
+
if (system !== void 0 && system.length > 0) blocks.push({
|
|
1553
|
+
type: "text",
|
|
1554
|
+
text: system
|
|
1555
|
+
});
|
|
1556
|
+
for (const message of messages ?? []) {
|
|
1557
|
+
if (message.role !== "system") continue;
|
|
1558
|
+
for (const block of message.content) if (block.type === "text") blocks.push({
|
|
1559
|
+
type: "text",
|
|
1560
|
+
text: block.text
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
return blocks;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Map harness tool schemas to Anthropic tools.
|
|
1567
|
+
* @param tools - tool schemas from the request.
|
|
1568
|
+
* @returns Anthropic `tools` array entries.
|
|
1569
|
+
*/
|
|
1570
|
+
function toAnthropicTools(tools) {
|
|
1571
|
+
return tools.map((tool) => ({
|
|
1572
|
+
name: tool.name,
|
|
1573
|
+
description: tool.description,
|
|
1574
|
+
input_schema: tool.parameters
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
1578
|
+
function closeBlock(block) {
|
|
1579
|
+
switch (block.kind) {
|
|
1580
|
+
case "text": return {
|
|
1581
|
+
type: "text",
|
|
1582
|
+
text: block.text
|
|
1583
|
+
};
|
|
1584
|
+
case "reasoning": return {
|
|
1585
|
+
type: "reasoning",
|
|
1586
|
+
text: block.text
|
|
1587
|
+
};
|
|
1588
|
+
case "tool-call": return {
|
|
1589
|
+
type: "tool-call",
|
|
1590
|
+
id: CallId(block.callId),
|
|
1591
|
+
name: block.name ?? "",
|
|
1592
|
+
arguments: block.text
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Classify an Anthropic `error` event into a thrown LlmError.
|
|
1598
|
+
* @param error - the wire error object.
|
|
1599
|
+
* @returns the mapped error.
|
|
1600
|
+
*/
|
|
1601
|
+
function anthropicFailure(error) {
|
|
1602
|
+
const type = error?.type ?? "unknown_error";
|
|
1603
|
+
const message = error?.message ?? `Anthropic reported ${type}`;
|
|
1604
|
+
if (type === "invalid_request_error" && /prompt is too long/i.test(message)) return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE);
|
|
1605
|
+
if (type === "rate_limit_error") return new LlmError(message, "RATE_LIMIT");
|
|
1606
|
+
if (type === "authentication_error") return new LlmError(message, "AUTH");
|
|
1607
|
+
return new LlmError(message, "SERVER");
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Push-model Anthropic SSE translator: feed each parsed event object to
|
|
1611
|
+
* {@link push} and collect the emitted harness StreamChunks. Block indexes
|
|
1612
|
+
* are allocated in first-seen order; `usage` is emitted before the terminal
|
|
1613
|
+
* `finish`, and nothing is emitted after it. `error` events throw
|
|
1614
|
+
* {@link LlmError}.
|
|
1615
|
+
*/
|
|
1616
|
+
var AnthropicStreamTranslator = class {
|
|
1617
|
+
blocks = /* @__PURE__ */ new Map();
|
|
1618
|
+
nextIndex = 0;
|
|
1619
|
+
sawAnyBlock = false;
|
|
1620
|
+
pendingUsage;
|
|
1621
|
+
outputTokens;
|
|
1622
|
+
stopReason = "stop";
|
|
1623
|
+
usageEmitted = false;
|
|
1624
|
+
/** Set once `message_stop` produced the terminal finish chunk. */
|
|
1625
|
+
terminated = false;
|
|
1626
|
+
open(wireIndex, kind, chunks, callId = "", name$1) {
|
|
1627
|
+
const block = {
|
|
1628
|
+
index: this.nextIndex++,
|
|
1629
|
+
kind,
|
|
1630
|
+
text: "",
|
|
1631
|
+
callId,
|
|
1632
|
+
...name$1 === void 0 ? {} : { name: name$1 }
|
|
1633
|
+
};
|
|
1634
|
+
this.blocks.set(wireIndex, block);
|
|
1635
|
+
this.sawAnyBlock = true;
|
|
1636
|
+
chunks.push({
|
|
1637
|
+
type: "block-start",
|
|
1638
|
+
index: block.index,
|
|
1639
|
+
blockType: kind
|
|
1640
|
+
});
|
|
1641
|
+
return block;
|
|
1642
|
+
}
|
|
1643
|
+
emitUsage(chunks) {
|
|
1644
|
+
if (this.usageEmitted) return;
|
|
1645
|
+
this.usageEmitted = true;
|
|
1646
|
+
const usage = {
|
|
1647
|
+
inputTokens: this.pendingUsage?.inputTokens ?? 0,
|
|
1648
|
+
outputTokens: this.outputTokens ?? 0,
|
|
1649
|
+
...this.pendingUsage?.cacheReadTokens !== void 0 ? { cacheReadTokens: this.pendingUsage.cacheReadTokens } : {},
|
|
1650
|
+
...this.pendingUsage?.cacheWriteTokens !== void 0 ? { cacheWriteTokens: this.pendingUsage.cacheWriteTokens } : {}
|
|
1651
|
+
};
|
|
1652
|
+
chunks.push({
|
|
1653
|
+
type: "usage",
|
|
1654
|
+
usage
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Process one parsed Anthropic SSE event.
|
|
1659
|
+
* @param event - the parsed event object.
|
|
1660
|
+
* @returns the StreamChunks this event produced (possibly none).
|
|
1661
|
+
*/
|
|
1662
|
+
push(event) {
|
|
1663
|
+
if (this.terminated) return [];
|
|
1664
|
+
const chunks = [];
|
|
1665
|
+
switch (event.type) {
|
|
1666
|
+
case "message_start": {
|
|
1667
|
+
const usage = event.message?.usage;
|
|
1668
|
+
if (usage !== void 0) {
|
|
1669
|
+
this.pendingUsage = {
|
|
1670
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
1671
|
+
...usage.cache_read_input_tokens !== void 0 ? { cacheReadTokens: usage.cache_read_input_tokens } : {},
|
|
1672
|
+
...usage.cache_creation_input_tokens !== void 0 ? { cacheWriteTokens: usage.cache_creation_input_tokens } : {}
|
|
1673
|
+
};
|
|
1674
|
+
this.outputTokens = usage.output_tokens ?? this.outputTokens;
|
|
1675
|
+
}
|
|
1676
|
+
return chunks;
|
|
1677
|
+
}
|
|
1678
|
+
case "content_block_start": {
|
|
1679
|
+
const wireIndex = event.index ?? 0;
|
|
1680
|
+
const block = event.content_block;
|
|
1681
|
+
switch (block?.type) {
|
|
1682
|
+
case "text":
|
|
1683
|
+
this.open(wireIndex, "text", chunks);
|
|
1684
|
+
break;
|
|
1685
|
+
case "thinking":
|
|
1686
|
+
this.open(wireIndex, "reasoning", chunks);
|
|
1687
|
+
break;
|
|
1688
|
+
case "tool_use": {
|
|
1689
|
+
const opened = this.open(wireIndex, "tool-call", chunks, block.id ?? "", block.name);
|
|
1690
|
+
chunks.push({
|
|
1691
|
+
type: "tool-call-delta",
|
|
1692
|
+
index: opened.index,
|
|
1693
|
+
id: CallId(opened.callId),
|
|
1694
|
+
...block.name === void 0 ? {} : { name: block.name },
|
|
1695
|
+
argumentsDelta: ""
|
|
1696
|
+
});
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
default: break;
|
|
1700
|
+
}
|
|
1701
|
+
return chunks;
|
|
1702
|
+
}
|
|
1703
|
+
case "content_block_delta": {
|
|
1704
|
+
const wireIndex = event.index ?? 0;
|
|
1705
|
+
const block = this.blocks.get(wireIndex);
|
|
1706
|
+
const delta = event.delta;
|
|
1707
|
+
if (block === void 0 || delta === void 0) return chunks;
|
|
1708
|
+
switch (delta.type) {
|
|
1709
|
+
case "text_delta":
|
|
1710
|
+
block.text += delta.text ?? "";
|
|
1711
|
+
chunks.push({
|
|
1712
|
+
type: "text-delta",
|
|
1713
|
+
index: block.index,
|
|
1714
|
+
text: delta.text ?? ""
|
|
1715
|
+
});
|
|
1716
|
+
break;
|
|
1717
|
+
case "thinking_delta":
|
|
1718
|
+
block.text += delta.thinking ?? "";
|
|
1719
|
+
chunks.push({
|
|
1720
|
+
type: "reasoning-delta",
|
|
1721
|
+
index: block.index,
|
|
1722
|
+
text: delta.thinking ?? ""
|
|
1723
|
+
});
|
|
1724
|
+
break;
|
|
1725
|
+
case "input_json_delta":
|
|
1726
|
+
block.text += delta.partial_json ?? "";
|
|
1727
|
+
chunks.push({
|
|
1728
|
+
type: "tool-call-delta",
|
|
1729
|
+
index: block.index,
|
|
1730
|
+
id: CallId(block.callId),
|
|
1731
|
+
...block.name === void 0 ? {} : { name: block.name },
|
|
1732
|
+
argumentsDelta: delta.partial_json ?? ""
|
|
1733
|
+
});
|
|
1734
|
+
break;
|
|
1735
|
+
default: break;
|
|
1736
|
+
}
|
|
1737
|
+
return chunks;
|
|
1738
|
+
}
|
|
1739
|
+
case "content_block_stop": {
|
|
1740
|
+
const wireIndex = event.index ?? 0;
|
|
1741
|
+
const block = this.blocks.get(wireIndex);
|
|
1742
|
+
if (block === void 0) return chunks;
|
|
1743
|
+
this.blocks.delete(wireIndex);
|
|
1744
|
+
chunks.push({
|
|
1745
|
+
type: "block-end",
|
|
1746
|
+
index: block.index,
|
|
1747
|
+
block: closeBlock(block)
|
|
1748
|
+
});
|
|
1749
|
+
return chunks;
|
|
1750
|
+
}
|
|
1751
|
+
case "message_delta":
|
|
1752
|
+
if (event.usage?.output_tokens !== void 0) this.outputTokens = event.usage.output_tokens;
|
|
1753
|
+
switch (event.delta?.stop_reason) {
|
|
1754
|
+
case "end_turn":
|
|
1755
|
+
case "stop_sequence":
|
|
1756
|
+
this.stopReason = "stop";
|
|
1757
|
+
break;
|
|
1758
|
+
case "tool_use":
|
|
1759
|
+
this.stopReason = "tool-calls";
|
|
1760
|
+
break;
|
|
1761
|
+
case "max_tokens":
|
|
1762
|
+
this.stopReason = "max-tokens";
|
|
1763
|
+
break;
|
|
1764
|
+
default: break;
|
|
1765
|
+
}
|
|
1766
|
+
return chunks;
|
|
1767
|
+
case "message_stop":
|
|
1768
|
+
this.terminated = true;
|
|
1769
|
+
for (const [wireIndex, block] of [...this.blocks]) {
|
|
1770
|
+
this.blocks.delete(wireIndex);
|
|
1771
|
+
chunks.push({
|
|
1772
|
+
type: "block-end",
|
|
1773
|
+
index: block.index,
|
|
1774
|
+
block: closeBlock(block)
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
this.emitUsage(chunks);
|
|
1778
|
+
if (this.stopReason === "stop" && !this.sawAnyBlock) chunks.push({
|
|
1779
|
+
type: "finish",
|
|
1780
|
+
reason: {
|
|
1781
|
+
kind: "error",
|
|
1782
|
+
failure: {
|
|
1783
|
+
message: "model returned a completed response with no content",
|
|
1784
|
+
code: EMPTY_RESPONSE_CODE
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
else chunks.push({
|
|
1789
|
+
type: "finish",
|
|
1790
|
+
reason: { kind: this.stopReason }
|
|
1791
|
+
});
|
|
1792
|
+
return chunks;
|
|
1793
|
+
case "error": throw anthropicFailure(event.error);
|
|
1794
|
+
default: return chunks;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
/**
|
|
1799
|
+
* Consume an Anthropic SSE byte stream and yield harness StreamChunks.
|
|
1800
|
+
* @param stream - raw response body.
|
|
1801
|
+
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
1802
|
+
* @returns the chunk stream; throws when the stream ends before `message_stop`.
|
|
1803
|
+
*/
|
|
1804
|
+
async function* streamAnthropic(stream, onActivity) {
|
|
1805
|
+
const translator = new AnthropicStreamTranslator();
|
|
1806
|
+
for await (const sseEvent of parseSse(stream, onActivity)) {
|
|
1807
|
+
let event;
|
|
1808
|
+
try {
|
|
1809
|
+
event = JSON.parse(sseEvent.data);
|
|
1810
|
+
} catch {
|
|
1811
|
+
throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
1812
|
+
}
|
|
1813
|
+
yield* translator.push(event);
|
|
1814
|
+
if (translator.terminated) return;
|
|
1815
|
+
}
|
|
1816
|
+
throw new LlmError("Anthropic SSE stream ended before message_stop", "STREAM_CLOSED");
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
//#endregion
|
|
1820
|
+
//#region src/providers/claude.ts
|
|
1821
|
+
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
1822
|
+
const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
1823
|
+
const CLAUDE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
1824
|
+
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
|
|
1825
|
+
const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
1826
|
+
const CLAUDE_SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
1827
|
+
const CLAUDE_CALLBACK_PATH = "/callback";
|
|
1828
|
+
const CLAUDE_CONTEXT_WINDOW = 2e5;
|
|
1829
|
+
const CLAUDE_DEFAULT_MAX_TOKENS = 32e3;
|
|
1830
|
+
/** Refresh when the access token has less than this much life left. */
|
|
1831
|
+
const CLAUDE_PREEMPT_MS = 5 * 6e4;
|
|
1832
|
+
/**
|
|
1833
|
+
* The subscription endpoint only serves requests presenting as Claude Code,
|
|
1834
|
+
* so these headers impersonate the CLI; the harness attribution user-agent
|
|
1835
|
+
* cannot be sent here (one user-agent slot, and the CLI's wins).
|
|
1836
|
+
*/
|
|
1837
|
+
const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.97 (external, cli)";
|
|
1838
|
+
const CLAUDE_BETA_FLAGS = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27";
|
|
1839
|
+
/** Static claude flow facts for the OAuth flow engine. */
|
|
1840
|
+
const claudeFlow = {
|
|
1841
|
+
callbackPath: CLAUDE_CALLBACK_PATH,
|
|
1842
|
+
listen: {
|
|
1843
|
+
host: "localhost",
|
|
1844
|
+
ports: [0]
|
|
1845
|
+
},
|
|
1846
|
+
buildAuthorizeUrl({ redirectUri, state, pkce }) {
|
|
1847
|
+
return `${CLAUDE_AUTHORIZE_URL}?${new URLSearchParams({
|
|
1848
|
+
code: "true",
|
|
1849
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
1850
|
+
response_type: "code",
|
|
1851
|
+
redirect_uri: redirectUri,
|
|
1852
|
+
scope: CLAUDE_SCOPE,
|
|
1853
|
+
code_challenge: pkce.challenge,
|
|
1854
|
+
code_challenge_method: "S256",
|
|
1855
|
+
state
|
|
1856
|
+
}).toString()}`;
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
/** Best-effort account profile; login must not fail when this does. */
|
|
1860
|
+
async function fetchClaudeProfile(accessToken) {
|
|
1861
|
+
try {
|
|
1862
|
+
const response = await fetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
1863
|
+
if (!response.ok) return {};
|
|
1864
|
+
const profile = await response.json();
|
|
1865
|
+
const account = typeof profile.account === "object" && profile.account !== null ? profile.account : {};
|
|
1866
|
+
const email = profile.emailAddress ?? profile.email ?? account.email_address ?? account.email;
|
|
1867
|
+
const subscription = profile.subscriptionType ?? profile.subscription_type ?? account.subscription_type;
|
|
1868
|
+
return {
|
|
1869
|
+
...typeof email === "string" && email.length > 0 ? { emailAddress: email } : {},
|
|
1870
|
+
...typeof subscription === "string" && subscription.length > 0 ? { subscriptionType: subscription } : {}
|
|
1871
|
+
};
|
|
1872
|
+
} catch {
|
|
1873
|
+
return {};
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
/** Build a session from a token response. */
|
|
1877
|
+
async function claudeSession(tokens, fallbackRefreshToken, withProfile) {
|
|
1878
|
+
if (typeof tokens.access_token !== "string" || tokens.access_token.length === 0) throw new Error("claude token endpoint returned no access token");
|
|
1879
|
+
const refreshToken = tokens.refresh_token ?? fallbackRefreshToken;
|
|
1880
|
+
if (refreshToken === void 0) throw new Error("claude token endpoint returned no refresh token");
|
|
1881
|
+
if (typeof tokens.expires_in !== "number" || tokens.expires_in <= 0) throw new Error("claude token endpoint returned no usable expiry");
|
|
1882
|
+
const profile = withProfile ? await fetchClaudeProfile(tokens.access_token) : {};
|
|
1883
|
+
return {
|
|
1884
|
+
accessToken: tokens.access_token,
|
|
1885
|
+
refreshToken,
|
|
1886
|
+
expiresAt: Date.now() + tokens.expires_in * 1e3,
|
|
1887
|
+
scopes: tokens.scope ?? CLAUDE_SCOPE,
|
|
1888
|
+
...profile
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Exchange an authorization code for a claude session (JSON grant).
|
|
1893
|
+
* @param code - the authorization code from the callback.
|
|
1894
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
1895
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
1896
|
+
* @param state - the attempt's state (echoed to the token endpoint).
|
|
1897
|
+
* @returns the session to store.
|
|
1898
|
+
*/
|
|
1899
|
+
async function exchangeClaudeCode(code, verifier, redirectUri, state) {
|
|
1900
|
+
const response = await fetch(CLAUDE_TOKEN_URL, {
|
|
1901
|
+
method: "POST",
|
|
1902
|
+
headers: { "content-type": "application/json" },
|
|
1903
|
+
body: JSON.stringify({
|
|
1904
|
+
grant_type: "authorization_code",
|
|
1905
|
+
code,
|
|
1906
|
+
redirect_uri: redirectUri,
|
|
1907
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
1908
|
+
code_verifier: verifier,
|
|
1909
|
+
state
|
|
1910
|
+
})
|
|
1911
|
+
});
|
|
1912
|
+
if (!response.ok) throw await oauthEndpointError(response, "claude");
|
|
1913
|
+
return claudeSession(await response.json(), void 0, true);
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Refresh a claude session (JSON grant echoing the issued scope).
|
|
1917
|
+
* @param session - the stored session.
|
|
1918
|
+
* @returns the fresh session to store.
|
|
1919
|
+
*/
|
|
1920
|
+
async function refreshClaude(session) {
|
|
1921
|
+
const response = await fetch(CLAUDE_TOKEN_URL, {
|
|
1922
|
+
method: "POST",
|
|
1923
|
+
headers: { "content-type": "application/json" },
|
|
1924
|
+
body: JSON.stringify({
|
|
1925
|
+
grant_type: "refresh_token",
|
|
1926
|
+
refresh_token: session.refreshToken,
|
|
1927
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
1928
|
+
scope: session.scopes
|
|
1929
|
+
})
|
|
1930
|
+
});
|
|
1931
|
+
if (!response.ok) throw await oauthEndpointError(response, "claude");
|
|
1932
|
+
return {
|
|
1933
|
+
...await claudeSession(await response.json(), session.refreshToken, false),
|
|
1934
|
+
...session.emailAddress === void 0 ? {} : { emailAddress: session.emailAddress },
|
|
1935
|
+
...session.subscriptionType === void 0 ? {} : { subscriptionType: session.subscriptionType }
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Whether a claude refresh failure means the login is permanently gone.
|
|
1940
|
+
* @param error - the thrown refresh error.
|
|
1941
|
+
* @returns true when re-login is the only fix.
|
|
1942
|
+
*/
|
|
1943
|
+
function isClaudePermanentRefreshError(error) {
|
|
1944
|
+
return error instanceof OAuthEndpointError && (error.oauthCode === "invalid_grant" || error.oauthCode === "invalid_token");
|
|
1945
|
+
}
|
|
1946
|
+
/** The Claude 4.5 family accepts image input. */
|
|
1947
|
+
const CLAUDE_MODALITIES = ["text", "image"];
|
|
1948
|
+
/** Claude wire adapter: one instance serves the `claude` provider route. */
|
|
1949
|
+
var ClaudeAdapter = class extends LlmAdapter {
|
|
1950
|
+
constructor(options) {
|
|
1951
|
+
super();
|
|
1952
|
+
this.options = options;
|
|
1953
|
+
}
|
|
1954
|
+
providerInfo(provider) {
|
|
1955
|
+
return {
|
|
1956
|
+
id: provider,
|
|
1957
|
+
name: "Claude (Subscription)"
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
async listModels(provider) {
|
|
1961
|
+
if (!await this.options.tokens.hasSession()) return [];
|
|
1962
|
+
return this.options.models.map((model) => ({
|
|
1963
|
+
provider,
|
|
1964
|
+
id: model.id,
|
|
1965
|
+
name: model.name ?? model.id,
|
|
1966
|
+
inputModalities: model.inputModalities ?? CLAUDE_MODALITIES
|
|
1967
|
+
}));
|
|
1968
|
+
}
|
|
1969
|
+
resolveModel(provider, model) {
|
|
1970
|
+
const configured = this.options.models.find((entry) => entry.id === model);
|
|
1971
|
+
return Promise.resolve({
|
|
1972
|
+
provider,
|
|
1973
|
+
id: model,
|
|
1974
|
+
name: configured?.name ?? model,
|
|
1975
|
+
inputModalities: configured?.inputModalities ?? CLAUDE_MODALITIES,
|
|
1976
|
+
context: { contextWindow: configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW },
|
|
1977
|
+
defaultMaxTokens: configured?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
async *stream(options) {
|
|
1981
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
1982
|
+
try {
|
|
1983
|
+
let session = await this.options.tokens.session();
|
|
1984
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
1985
|
+
if (response.status === 401) {
|
|
1986
|
+
session = await this.options.tokens.session(true);
|
|
1987
|
+
response = await this.request(options, session, watchdog.signal);
|
|
1988
|
+
}
|
|
1989
|
+
if (!response.ok) throw await httpLlmError(response, "claude API");
|
|
1990
|
+
if (response.body === null) throw new LlmError("claude API returned no response body", EMPTY_RESPONSE_CODE);
|
|
1991
|
+
yield* streamAnthropic(response.body, () => {
|
|
1992
|
+
watchdog.pulse();
|
|
1993
|
+
});
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
throw mapFetchFailure("claude API", error, watchdog, options.signal);
|
|
1996
|
+
} finally {
|
|
1997
|
+
watchdog.stop();
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
async request(options, session, signal) {
|
|
2001
|
+
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
2002
|
+
const body = {
|
|
2003
|
+
model: options.model,
|
|
2004
|
+
max_tokens: options.maxTokens ?? this.options.models.find((entry) => entry.id === options.model)?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS,
|
|
2005
|
+
system: toAnthropicSystem(options.system, messages),
|
|
2006
|
+
messages: toAnthropicMessages(messages),
|
|
2007
|
+
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toAnthropicTools(options.tools) } : {},
|
|
2008
|
+
stream: true,
|
|
2009
|
+
...options.sessionId !== void 0 ? { metadata: { user_id: String(options.sessionId) } } : {}
|
|
2010
|
+
};
|
|
2011
|
+
return fetch(CLAUDE_API_URL, {
|
|
2012
|
+
method: "POST",
|
|
2013
|
+
headers: {
|
|
2014
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
2015
|
+
"anthropic-version": "2023-06-01",
|
|
2016
|
+
"anthropic-beta": CLAUDE_BETA_FLAGS,
|
|
2017
|
+
"user-agent": CLAUDE_CLI_USER_AGENT,
|
|
2018
|
+
"x-app": "cli",
|
|
2019
|
+
"anthropic-dangerous-direct-browser-access": "true",
|
|
2020
|
+
"accept": "text/event-stream",
|
|
2021
|
+
"content-type": "application/json"
|
|
2022
|
+
},
|
|
2023
|
+
body: JSON.stringify(body),
|
|
2024
|
+
signal
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
|
|
2029
|
+
//#endregion
|
|
2030
|
+
//#region src/providers/grok.ts
|
|
2031
|
+
const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
2032
|
+
const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
|
|
2033
|
+
const GROK_API_URL = "https://api.x.ai/v1/responses";
|
|
2034
|
+
const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
2035
|
+
const GROK_CALLBACK_PATH = "/callback";
|
|
2036
|
+
const GROK_CONTEXT_WINDOW = 256e3;
|
|
2037
|
+
const GROK_DEFAULT_MAX_TOKENS = 32e3;
|
|
2038
|
+
/** Refresh when the access token has less than this much life left. */
|
|
2039
|
+
const GROK_PREEMPT_MS = 2 * 6e4;
|
|
2040
|
+
/** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
|
|
2041
|
+
function assertXaiEndpoint(url, field) {
|
|
2042
|
+
let parsed;
|
|
2043
|
+
try {
|
|
2044
|
+
parsed = new URL(url);
|
|
2045
|
+
} catch {
|
|
2046
|
+
throw new Error(`grok OIDC discovery returned an invalid ${field}`);
|
|
2047
|
+
}
|
|
2048
|
+
if (parsed.protocol !== "https:" || parsed.hostname !== "x.ai" && !parsed.hostname.endsWith(".x.ai")) throw new Error(`grok OIDC discovery returned a non-x.ai ${field}: ${url}`);
|
|
2049
|
+
return url;
|
|
2050
|
+
}
|
|
2051
|
+
let discoveryCache;
|
|
2052
|
+
/**
|
|
2053
|
+
* Resolve the xAI OIDC endpoints (cached after the first fetch).
|
|
2054
|
+
* @returns validated authorization and token endpoints.
|
|
2055
|
+
*/
|
|
2056
|
+
async function grokDiscovery() {
|
|
2057
|
+
if (discoveryCache !== void 0) return discoveryCache;
|
|
2058
|
+
const response = await fetch(GROK_DISCOVERY_URL);
|
|
2059
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok OIDC discovery");
|
|
2060
|
+
const document = await response.json();
|
|
2061
|
+
if (typeof document.authorization_endpoint !== "string" || typeof document.token_endpoint !== "string") throw new Error("grok OIDC discovery document is missing endpoints");
|
|
2062
|
+
discoveryCache = {
|
|
2063
|
+
authorizationEndpoint: assertXaiEndpoint(document.authorization_endpoint, "authorization_endpoint"),
|
|
2064
|
+
tokenEndpoint: assertXaiEndpoint(document.token_endpoint, "token_endpoint")
|
|
2065
|
+
};
|
|
2066
|
+
return discoveryCache;
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Build the grok flow facts for the OAuth flow engine (async because the
|
|
2070
|
+
* authorize URL comes from OIDC discovery).
|
|
2071
|
+
* @returns the flow spec for one attempt.
|
|
2072
|
+
*/
|
|
2073
|
+
async function grokFlow() {
|
|
2074
|
+
const discovery = await grokDiscovery();
|
|
2075
|
+
return {
|
|
2076
|
+
callbackPath: GROK_CALLBACK_PATH,
|
|
2077
|
+
listen: {
|
|
2078
|
+
host: "127.0.0.1",
|
|
2079
|
+
ports: [56121]
|
|
2080
|
+
},
|
|
2081
|
+
buildAuthorizeUrl({ redirectUri, state, pkce, nonce }) {
|
|
2082
|
+
const params = new URLSearchParams({
|
|
2083
|
+
response_type: "code",
|
|
2084
|
+
client_id: GROK_CLIENT_ID,
|
|
2085
|
+
redirect_uri: redirectUri,
|
|
2086
|
+
scope: GROK_SCOPE,
|
|
2087
|
+
code_challenge: pkce.challenge,
|
|
2088
|
+
code_challenge_method: "S256",
|
|
2089
|
+
state,
|
|
2090
|
+
nonce,
|
|
2091
|
+
plan: "generic",
|
|
2092
|
+
referrer: "dsh-plugin-subscriptions"
|
|
2093
|
+
});
|
|
2094
|
+
return `${discovery.authorizationEndpoint}?${params.toString()}`;
|
|
2095
|
+
}
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
/** Pick a display account from an id token's claims. */
|
|
2099
|
+
function grokAccount(idToken) {
|
|
2100
|
+
const payload = idToken === void 0 ? void 0 : decodeJwtPayload(idToken);
|
|
2101
|
+
const claim = payload?.email ?? payload?.preferred_username ?? payload?.name ?? payload?.sub;
|
|
2102
|
+
return typeof claim === "string" && claim.length > 0 ? claim : void 0;
|
|
2103
|
+
}
|
|
2104
|
+
/** Build a session from a token response. */
|
|
2105
|
+
function grokSession(tokens, tokenEndpoint, fallbackRefreshToken) {
|
|
2106
|
+
if (typeof tokens.access_token !== "string" || tokens.access_token.length === 0) throw new Error("grok token endpoint returned no access token");
|
|
2107
|
+
const refreshToken = tokens.refresh_token ?? fallbackRefreshToken;
|
|
2108
|
+
if (refreshToken === void 0) throw new Error("grok token endpoint returned no refresh token");
|
|
2109
|
+
if (typeof tokens.expires_in !== "number" || tokens.expires_in <= 0) throw new Error("grok token endpoint returned no usable expiry");
|
|
2110
|
+
const account = grokAccount(tokens.id_token);
|
|
2111
|
+
return {
|
|
2112
|
+
accessToken: tokens.access_token,
|
|
2113
|
+
refreshToken,
|
|
2114
|
+
expiresAt: Date.now() + tokens.expires_in * 1e3,
|
|
2115
|
+
tokenEndpoint,
|
|
2116
|
+
...typeof tokens.scope === "string" ? { scopes: tokens.scope } : {},
|
|
2117
|
+
...account === void 0 ? {} : { account }
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Exchange an authorization code for a grok session (form-encoded grant that
|
|
2122
|
+
* echoes the PKCE challenge as well as the verifier, per the xAI flow).
|
|
2123
|
+
* A 403 here means the X plan lacks the API OAuth entitlement.
|
|
2124
|
+
* @param code - the authorization code from the callback.
|
|
2125
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
2126
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
2127
|
+
* @param challenge - the PKCE challenge sent at authorize time.
|
|
2128
|
+
* @returns the session to store.
|
|
2129
|
+
*/
|
|
2130
|
+
async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
|
|
2131
|
+
const discovery = await grokDiscovery();
|
|
2132
|
+
const response = await fetch(discovery.tokenEndpoint, {
|
|
2133
|
+
method: "POST",
|
|
2134
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2135
|
+
body: new URLSearchParams({
|
|
2136
|
+
grant_type: "authorization_code",
|
|
2137
|
+
client_id: GROK_CLIENT_ID,
|
|
2138
|
+
code,
|
|
2139
|
+
redirect_uri: redirectUri,
|
|
2140
|
+
code_verifier: verifier,
|
|
2141
|
+
code_challenge: challenge,
|
|
2142
|
+
code_challenge_method: "S256"
|
|
2143
|
+
}).toString()
|
|
2144
|
+
});
|
|
2145
|
+
if (response.status === 403) throw new OAuthEndpointError("grok token endpoint refused the exchange (HTTP 403): your X plan does not include the API OAuth entitlement; an X Premium or xAI subscription with API access is required", 403);
|
|
2146
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok");
|
|
2147
|
+
return grokSession(await response.json(), discovery.tokenEndpoint);
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Refresh a grok session (form-encoded grant).
|
|
2151
|
+
* @param session - the stored session.
|
|
2152
|
+
* @returns the fresh session to store.
|
|
2153
|
+
*/
|
|
2154
|
+
async function refreshGrok(session) {
|
|
2155
|
+
const response = await fetch(session.tokenEndpoint, {
|
|
2156
|
+
method: "POST",
|
|
2157
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2158
|
+
body: new URLSearchParams({
|
|
2159
|
+
grant_type: "refresh_token",
|
|
2160
|
+
client_id: GROK_CLIENT_ID,
|
|
2161
|
+
refresh_token: session.refreshToken
|
|
2162
|
+
}).toString()
|
|
2163
|
+
});
|
|
2164
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok");
|
|
2165
|
+
const next = grokSession(await response.json(), session.tokenEndpoint, session.refreshToken);
|
|
2166
|
+
return {
|
|
2167
|
+
...next,
|
|
2168
|
+
...session.account === void 0 ? {} : { account: session.account },
|
|
2169
|
+
...next.scopes === void 0 && session.scopes !== void 0 ? { scopes: session.scopes } : {}
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Whether a grok refresh failure means the login is permanently gone.
|
|
2174
|
+
* @param error - the thrown refresh error.
|
|
2175
|
+
* @returns true when re-login is the only fix.
|
|
2176
|
+
*/
|
|
2177
|
+
function isGrokPermanentRefreshError(error) {
|
|
2178
|
+
return error instanceof OAuthEndpointError && error.oauthCode === "invalid_grant";
|
|
2179
|
+
}
|
|
2180
|
+
const GROK_MODELS_URL = "https://api.x.ai/v1/models";
|
|
2181
|
+
/**
|
|
2182
|
+
* Input modalities for one grok model: chat models (grok-4 family) accept
|
|
2183
|
+
* images; code and embedding models are text-only.
|
|
2184
|
+
*/
|
|
2185
|
+
function grokModalities(id) {
|
|
2186
|
+
return /code|embed/i.test(id) ? ["text"] : ["text", "image"];
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* The /v1/models list also serves generation models that cannot chat
|
|
2190
|
+
* (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
|
|
2191
|
+
* must not offer them. Heuristic over the id substring, verified against the
|
|
2192
|
+
* live catalog (grok-build-0.1 and the grok-4 family pass).
|
|
2193
|
+
*/
|
|
2194
|
+
function isChatModel(id) {
|
|
2195
|
+
return !/imagine|image-|video|embed/i.test(id);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Fetch the live grok model list.
|
|
2199
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
2200
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
2201
|
+
* @returns discovered chat models in endpoint order (id doubles as the name).
|
|
2202
|
+
*/
|
|
2203
|
+
async function fetchGrokModels(session, fetchFn = fetch) {
|
|
2204
|
+
const response = await fetchFn(GROK_MODELS_URL, { headers: {
|
|
2205
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
2206
|
+
"accept": "application/json",
|
|
2207
|
+
...attributionHeaders()
|
|
2208
|
+
} });
|
|
2209
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok models");
|
|
2210
|
+
const payload = await response.json();
|
|
2211
|
+
if (!Array.isArray(payload.data)) throw new Error("grok models endpoint returned no data array");
|
|
2212
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2213
|
+
const discovered = [];
|
|
2214
|
+
for (const entry of payload.data) {
|
|
2215
|
+
if (typeof entry.id !== "string" || entry.id.length === 0 || seen.has(entry.id)) continue;
|
|
2216
|
+
if (!isChatModel(entry.id)) continue;
|
|
2217
|
+
seen.add(entry.id);
|
|
2218
|
+
discovered.push({
|
|
2219
|
+
id: entry.id,
|
|
2220
|
+
name: entry.id
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
if (discovered.length === 0) throw new Error("grok models endpoint returned an empty catalog");
|
|
2224
|
+
return discovered;
|
|
2225
|
+
}
|
|
2226
|
+
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
2227
|
+
var GrokAdapter = class extends LlmAdapter {
|
|
2228
|
+
catalog = new ModelCatalogCache();
|
|
2229
|
+
constructor(options) {
|
|
2230
|
+
super();
|
|
2231
|
+
this.options = options;
|
|
2232
|
+
}
|
|
2233
|
+
providerInfo(provider) {
|
|
2234
|
+
return {
|
|
2235
|
+
id: provider,
|
|
2236
|
+
name: "Grok (Subscription)"
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
staticModels(provider) {
|
|
2240
|
+
return this.options.models.map((model) => ({
|
|
2241
|
+
provider,
|
|
2242
|
+
id: model.id,
|
|
2243
|
+
name: model.name ?? model.id,
|
|
2244
|
+
inputModalities: model.inputModalities ?? grokModalities(model.id)
|
|
2245
|
+
}));
|
|
2246
|
+
}
|
|
2247
|
+
async listModels(provider) {
|
|
2248
|
+
const session = await this.options.tokens.peek();
|
|
2249
|
+
if (session === void 0) return [];
|
|
2250
|
+
if (!this.options.discovery) return this.staticModels(provider);
|
|
2251
|
+
try {
|
|
2252
|
+
return (await this.catalog.get(() => fetchGrokModels(session, this.options.fetchFn))).map((model) => ({
|
|
2253
|
+
provider,
|
|
2254
|
+
id: model.id,
|
|
2255
|
+
name: model.name,
|
|
2256
|
+
inputModalities: grokModalities(model.id)
|
|
2257
|
+
}));
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
|
|
2260
|
+
this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
2261
|
+
return this.staticModels(provider);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
resolveModel(provider, model) {
|
|
2265
|
+
const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
|
|
2266
|
+
const configured = this.options.models.find((entry) => entry.id === model);
|
|
2267
|
+
return Promise.resolve({
|
|
2268
|
+
provider,
|
|
2269
|
+
id: model,
|
|
2270
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
2271
|
+
inputModalities: configured?.inputModalities ?? grokModalities(model),
|
|
2272
|
+
context: { contextWindow: configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
|
|
2273
|
+
defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
async *stream(options) {
|
|
2277
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
2278
|
+
try {
|
|
2279
|
+
let session = await this.options.tokens.session();
|
|
2280
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
2281
|
+
if (response.status === 401) {
|
|
2282
|
+
session = await this.options.tokens.session(true);
|
|
2283
|
+
response = await this.request(options, session, watchdog.signal);
|
|
2284
|
+
}
|
|
2285
|
+
if (!response.ok) throw await httpLlmError(response, "grok API");
|
|
2286
|
+
if (response.body === null) throw new LlmError("grok API returned no response body", EMPTY_RESPONSE_CODE);
|
|
2287
|
+
yield* streamResponses(response.body, () => {
|
|
2288
|
+
watchdog.pulse();
|
|
2289
|
+
});
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
throw mapFetchFailure("grok API", error, watchdog, options.signal);
|
|
2292
|
+
} finally {
|
|
2293
|
+
watchdog.stop();
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
async request(options, session, signal) {
|
|
2297
|
+
const { instructions, input } = toResponsesInput(await resolveImages(options.messages, this.options.resolveAttachments?.(), signal), options.system);
|
|
2298
|
+
const body = {
|
|
2299
|
+
model: options.model,
|
|
2300
|
+
...instructions === void 0 ? {} : { instructions },
|
|
2301
|
+
input,
|
|
2302
|
+
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
|
|
2303
|
+
tool_choice: "auto",
|
|
2304
|
+
parallel_tool_calls: true,
|
|
2305
|
+
...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
|
|
2306
|
+
store: false,
|
|
2307
|
+
stream: true
|
|
2308
|
+
};
|
|
2309
|
+
return fetch(GROK_API_URL, {
|
|
2310
|
+
method: "POST",
|
|
2311
|
+
headers: {
|
|
2312
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
2313
|
+
"accept": "text/event-stream",
|
|
2314
|
+
"content-type": "application/json",
|
|
2315
|
+
...attributionHeaders()
|
|
2316
|
+
},
|
|
2317
|
+
body: JSON.stringify(body),
|
|
2318
|
+
signal
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
//#endregion
|
|
2324
|
+
//#region src/tools/x-search.ts
|
|
2325
|
+
/** Endpoint the search request is posted to. */
|
|
2326
|
+
const X_SEARCH_URL = "https://api.x.ai/v1/responses";
|
|
2327
|
+
/** Grok model the search runs on (a catalog model of the grok provider). */
|
|
2328
|
+
const X_SEARCH_MODEL = "grok-4";
|
|
2329
|
+
/** xAI caps each handle filter list at ten entries. */
|
|
2330
|
+
const MAX_HANDLES = 10;
|
|
2331
|
+
/**
|
|
2332
|
+
* Validate and assemble the request facts from tool arguments. Throws plain
|
|
2333
|
+
* Errors for argument problems the schema DSL cannot express (non-empty
|
|
2334
|
+
* query, handle caps, mutually exclusive filters).
|
|
2335
|
+
*/
|
|
2336
|
+
function buildXSearchRequest(args) {
|
|
2337
|
+
const query = args.query.trim();
|
|
2338
|
+
if (query.length === 0) throw new Error("x_search: query must be a non-empty string");
|
|
2339
|
+
const allowed = normalizeHandles(args.allowed_x_handles, "allowed_x_handles");
|
|
2340
|
+
const excluded = normalizeHandles(args.excluded_x_handles, "excluded_x_handles");
|
|
2341
|
+
if (allowed.length > 0 && excluded.length > 0) throw new Error("x_search: allowed_x_handles and excluded_x_handles cannot be used together");
|
|
2342
|
+
const tool = { type: "x_search" };
|
|
2343
|
+
if (allowed.length > 0) tool.allowed_x_handles = allowed;
|
|
2344
|
+
if (excluded.length > 0) tool.excluded_x_handles = excluded;
|
|
2345
|
+
if (args.from_date !== void 0 && args.from_date.trim().length > 0) tool.from_date = args.from_date.trim();
|
|
2346
|
+
if (args.to_date !== void 0 && args.to_date.trim().length > 0) tool.to_date = args.to_date.trim();
|
|
2347
|
+
if (args.enable_image_understanding === true) tool.enable_image_understanding = true;
|
|
2348
|
+
if (args.enable_video_understanding === true) tool.enable_video_understanding = true;
|
|
2349
|
+
return {
|
|
2350
|
+
query,
|
|
2351
|
+
tool
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
/** Strip `@` prefixes, drop blanks, and enforce the provider's handle cap. */
|
|
2355
|
+
function normalizeHandles(value, field) {
|
|
2356
|
+
if (value === void 0) return [];
|
|
2357
|
+
const handles = value.map((handle) => handle.trim().replace(/^@+/, "")).filter((handle) => handle.length > 0);
|
|
2358
|
+
if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
|
|
2359
|
+
return handles;
|
|
2360
|
+
}
|
|
2361
|
+
function isRecord(value) {
|
|
2362
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Extract the answer text and citation URLs from a Responses payload: the
|
|
2366
|
+
* `output_text` shortcut or message output parts for the answer, and both
|
|
2367
|
+
* top-level `citations` and inline `url_citation` annotations for sources.
|
|
2368
|
+
*/
|
|
2369
|
+
function parseXSearchResponse(payload) {
|
|
2370
|
+
const body = isRecord(payload) ? payload : {};
|
|
2371
|
+
let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
|
|
2372
|
+
const citations = [];
|
|
2373
|
+
const push = (url) => {
|
|
2374
|
+
if (typeof url === "string" && url.length > 0 && !citations.includes(url)) citations.push(url);
|
|
2375
|
+
};
|
|
2376
|
+
if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
|
|
2377
|
+
const parts = [];
|
|
2378
|
+
if (Array.isArray(body.output)) for (const item of body.output) {
|
|
2379
|
+
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
|
|
2380
|
+
for (const part of item.content) {
|
|
2381
|
+
if (!isRecord(part)) continue;
|
|
2382
|
+
if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
|
|
2383
|
+
if (Array.isArray(part.annotations)) {
|
|
2384
|
+
for (const annotation of part.annotations) if (isRecord(annotation) && annotation.type === "url_citation") push(annotation.url);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
if (answer.length === 0) answer = parts.join("\n\n");
|
|
2389
|
+
return {
|
|
2390
|
+
answer,
|
|
2391
|
+
citations
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
/** Bound a call-card title's query. */
|
|
2395
|
+
function truncate$1(text, max = 60) {
|
|
2396
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Build the `x_search` tool definition.
|
|
2400
|
+
* @param options - grok session source and fetch implementation.
|
|
2401
|
+
* @returns the tool to register on `ctx.tools`.
|
|
2402
|
+
*/
|
|
2403
|
+
function createXSearchTool(options) {
|
|
2404
|
+
return defineTool({
|
|
2405
|
+
name: "x_search",
|
|
2406
|
+
description: "Search X (Twitter) posts, profiles, and threads using the grok subscription's hosted xAI x_search. Use this for current discussion, reactions, or claims on X rather than general web pages.",
|
|
2407
|
+
parameters: {
|
|
2408
|
+
query: {
|
|
2409
|
+
type: "string",
|
|
2410
|
+
required: true,
|
|
2411
|
+
description: "What to look up on X."
|
|
2412
|
+
},
|
|
2413
|
+
allowed_x_handles: {
|
|
2414
|
+
type: "array",
|
|
2415
|
+
items: { type: "string" },
|
|
2416
|
+
description: "X handles to include exclusively (max 10)."
|
|
2417
|
+
},
|
|
2418
|
+
excluded_x_handles: {
|
|
2419
|
+
type: "array",
|
|
2420
|
+
items: { type: "string" },
|
|
2421
|
+
description: "X handles to exclude (max 10)."
|
|
2422
|
+
},
|
|
2423
|
+
from_date: {
|
|
2424
|
+
type: "string",
|
|
2425
|
+
description: "Optional start date in YYYY-MM-DD format."
|
|
2426
|
+
},
|
|
2427
|
+
to_date: {
|
|
2428
|
+
type: "string",
|
|
2429
|
+
description: "Optional end date in YYYY-MM-DD format."
|
|
2430
|
+
},
|
|
2431
|
+
enable_image_understanding: {
|
|
2432
|
+
type: "boolean",
|
|
2433
|
+
description: "Whether xAI should analyze images attached to matching posts."
|
|
2434
|
+
},
|
|
2435
|
+
enable_video_understanding: {
|
|
2436
|
+
type: "boolean",
|
|
2437
|
+
description: "Whether xAI should analyze videos attached to matching posts."
|
|
2438
|
+
}
|
|
2439
|
+
},
|
|
2440
|
+
output: {
|
|
2441
|
+
schema: {
|
|
2442
|
+
type: "object",
|
|
2443
|
+
properties: {
|
|
2444
|
+
answer: {
|
|
2445
|
+
type: "string",
|
|
2446
|
+
required: true
|
|
2447
|
+
},
|
|
2448
|
+
citations: {
|
|
2449
|
+
type: "array",
|
|
2450
|
+
items: { type: "string" },
|
|
2451
|
+
required: true
|
|
2452
|
+
}
|
|
2453
|
+
},
|
|
2454
|
+
additionalProperties: false
|
|
2455
|
+
},
|
|
2456
|
+
render: (_args, value) => [{
|
|
2457
|
+
type: "text",
|
|
2458
|
+
text: value.citations.length > 0 ? `${value.answer}\n\nSources:\n${value.citations.map((citation) => `- ${citation}`).join("\n")}` : value.answer
|
|
2459
|
+
}],
|
|
2460
|
+
presentationMeta: (_args, value) => ({
|
|
2461
|
+
answer: value.answer,
|
|
2462
|
+
citations: value.citations
|
|
2463
|
+
})
|
|
2464
|
+
},
|
|
2465
|
+
presentCall: (args) => ({
|
|
2466
|
+
card: "generic",
|
|
2467
|
+
title: `x_search: ${truncate$1(args.query)}`,
|
|
2468
|
+
kind: "search"
|
|
2469
|
+
}),
|
|
2470
|
+
presentResult: (_args, result) => {
|
|
2471
|
+
if (result.isError || !isRecord(result.meta)) return void 0;
|
|
2472
|
+
return {
|
|
2473
|
+
card: "web",
|
|
2474
|
+
kind: "search",
|
|
2475
|
+
sources: (Array.isArray(result.meta.citations) ? result.meta.citations : []).filter((citation) => typeof citation === "string").map((url) => ({ url })),
|
|
2476
|
+
...typeof result.meta.answer === "string" && result.meta.answer.length > 0 ? { answer: result.meta.answer } : {},
|
|
2477
|
+
truncated: false
|
|
2478
|
+
};
|
|
2479
|
+
},
|
|
2480
|
+
async execute(args, exec) {
|
|
2481
|
+
const request = buildXSearchRequest(args);
|
|
2482
|
+
const session = await options.tokens.session();
|
|
2483
|
+
const response = await (options.fetchFn ?? fetch)(X_SEARCH_URL, {
|
|
2484
|
+
method: "POST",
|
|
2485
|
+
headers: {
|
|
2486
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
2487
|
+
"content-type": "application/json",
|
|
2488
|
+
"accept": "application/json"
|
|
2489
|
+
},
|
|
2490
|
+
body: JSON.stringify({
|
|
2491
|
+
model: X_SEARCH_MODEL,
|
|
2492
|
+
input: [{
|
|
2493
|
+
role: "user",
|
|
2494
|
+
content: request.query
|
|
2495
|
+
}],
|
|
2496
|
+
tools: [request.tool],
|
|
2497
|
+
store: false
|
|
2498
|
+
}),
|
|
2499
|
+
signal: exec.signal
|
|
2500
|
+
});
|
|
2501
|
+
if (!response.ok) throw await httpLlmError(response, "x_search");
|
|
2502
|
+
return parseXSearchResponse(await response.json());
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
//#endregion
|
|
2508
|
+
//#region src/tools/image-generate.ts
|
|
2509
|
+
/** Endpoint the generation request is posted to. */
|
|
2510
|
+
const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
|
|
2511
|
+
/** The image model the codex subscription endpoint serves. */
|
|
2512
|
+
const IMAGE_GENERATE_MODEL = "gpt-image-2";
|
|
2513
|
+
/**
|
|
2514
|
+
* Assemble the request body from tool arguments (hand-checks the non-empty
|
|
2515
|
+
* prompt the schema DSL cannot express).
|
|
2516
|
+
*/
|
|
2517
|
+
function buildImageGenerateBody(args) {
|
|
2518
|
+
const prompt = args.prompt.trim();
|
|
2519
|
+
if (prompt.length === 0) throw new Error("image_generate: prompt must be a non-empty string");
|
|
2520
|
+
return {
|
|
2521
|
+
prompt,
|
|
2522
|
+
model: IMAGE_GENERATE_MODEL,
|
|
2523
|
+
...args.size === void 0 ? {} : { size: args.size },
|
|
2524
|
+
...args.quality === void 0 ? {} : { quality: args.quality }
|
|
2525
|
+
};
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Parse the generations response into decodable images. Throws when the
|
|
2529
|
+
* payload carries no usable `b64_json` entries.
|
|
2530
|
+
*/
|
|
2531
|
+
function parseImageGenerateResponse(payload) {
|
|
2532
|
+
const body = typeof payload === "object" && payload !== null ? payload : {};
|
|
2533
|
+
const entries = Array.isArray(body.data) ? body.data : [];
|
|
2534
|
+
const images = [];
|
|
2535
|
+
for (const entry of entries) {
|
|
2536
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
2537
|
+
const record = entry;
|
|
2538
|
+
if (typeof record.b64_json !== "string" || record.b64_json.length === 0) continue;
|
|
2539
|
+
images.push({
|
|
2540
|
+
data: Buffer.from(record.b64_json, "base64"),
|
|
2541
|
+
...typeof record.revised_prompt === "string" && record.revised_prompt.length > 0 ? { revisedPrompt: record.revised_prompt } : {}
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
if (images.length === 0) throw new Error("image_generate: the response carried no image data");
|
|
2545
|
+
return images;
|
|
2546
|
+
}
|
|
2547
|
+
/** Directory the generated PNG files are written to. */
|
|
2548
|
+
function imagesDirectory() {
|
|
2549
|
+
return dshHomePath("plugins", "subscriptions", "images");
|
|
2550
|
+
}
|
|
2551
|
+
/** Timestamped, collision-safe file name for one generated image. */
|
|
2552
|
+
function imageFileName(index) {
|
|
2553
|
+
return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
|
|
2554
|
+
}
|
|
2555
|
+
/** Bound a call-card title's prompt. */
|
|
2556
|
+
function truncate(text, max = 60) {
|
|
2557
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Build the `image_generate` tool definition.
|
|
2561
|
+
* @param options - codex session source, fetch implementation, and image directory.
|
|
2562
|
+
* @returns the tool to register on `ctx.tools`.
|
|
2563
|
+
*/
|
|
2564
|
+
function createImageGenerateTool(options) {
|
|
2565
|
+
return defineTool({
|
|
2566
|
+
name: "image_generate",
|
|
2567
|
+
description: "Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. Returns the saved file paths.",
|
|
2568
|
+
parameters: {
|
|
2569
|
+
prompt: {
|
|
2570
|
+
type: "string",
|
|
2571
|
+
required: true,
|
|
2572
|
+
description: "What the image should show."
|
|
2573
|
+
},
|
|
2574
|
+
size: {
|
|
2575
|
+
type: "string",
|
|
2576
|
+
enum: [
|
|
2577
|
+
"1024x1024",
|
|
2578
|
+
"1024x1536",
|
|
2579
|
+
"1536x1024",
|
|
2580
|
+
"auto"
|
|
2581
|
+
],
|
|
2582
|
+
description: "Image dimensions; omit for the provider default."
|
|
2583
|
+
},
|
|
2584
|
+
quality: {
|
|
2585
|
+
type: "string",
|
|
2586
|
+
enum: [
|
|
2587
|
+
"low",
|
|
2588
|
+
"medium",
|
|
2589
|
+
"high",
|
|
2590
|
+
"auto"
|
|
2591
|
+
],
|
|
2592
|
+
description: "Rendering quality; omit for the provider default."
|
|
2593
|
+
}
|
|
2594
|
+
},
|
|
2595
|
+
output: {
|
|
2596
|
+
schema: {
|
|
2597
|
+
type: "object",
|
|
2598
|
+
properties: {
|
|
2599
|
+
paths: {
|
|
2600
|
+
type: "array",
|
|
2601
|
+
items: { type: "string" },
|
|
2602
|
+
required: true
|
|
2603
|
+
},
|
|
2604
|
+
revisedPrompt: { type: "string" }
|
|
2605
|
+
},
|
|
2606
|
+
additionalProperties: false
|
|
2607
|
+
},
|
|
2608
|
+
render: (_args, value) => [{
|
|
2609
|
+
type: "text",
|
|
2610
|
+
text: `Saved ${value.paths.length} image(s):\n${value.paths.map((path) => `- ${path}`).join("\n")}` + (value.revisedPrompt === void 0 ? "" : `\n\nRevised prompt: ${value.revisedPrompt}`)
|
|
2611
|
+
}]
|
|
2612
|
+
},
|
|
2613
|
+
presentCall: (args) => ({
|
|
2614
|
+
card: "generic",
|
|
2615
|
+
title: `image_generate: ${truncate(args.prompt)}`
|
|
2616
|
+
}),
|
|
2617
|
+
async execute(args, exec) {
|
|
2618
|
+
const body = buildImageGenerateBody(args);
|
|
2619
|
+
const session = await options.tokens.session();
|
|
2620
|
+
const response = await (options.fetchFn ?? fetch)(IMAGE_GENERATE_URL, {
|
|
2621
|
+
method: "POST",
|
|
2622
|
+
headers: {
|
|
2623
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
2624
|
+
"chatgpt-account-id": session.accountId,
|
|
2625
|
+
"originator": "codex_cli_rs",
|
|
2626
|
+
"content-type": "application/json",
|
|
2627
|
+
"accept": "application/json"
|
|
2628
|
+
},
|
|
2629
|
+
body: JSON.stringify(body),
|
|
2630
|
+
signal: exec.signal
|
|
2631
|
+
});
|
|
2632
|
+
if (!response.ok) throw await httpLlmError(response, "image_generate");
|
|
2633
|
+
const images = parseImageGenerateResponse(await response.json());
|
|
2634
|
+
const directory = options.imagesDir ?? imagesDirectory();
|
|
2635
|
+
await mkdir(directory, { recursive: true });
|
|
2636
|
+
const paths = [];
|
|
2637
|
+
for (const [index, image] of images.entries()) {
|
|
2638
|
+
const path = join(directory, imageFileName(index));
|
|
2639
|
+
await writeFile(path, image.data);
|
|
2640
|
+
paths.push(path);
|
|
2641
|
+
}
|
|
2642
|
+
const revisedPrompt = images.find((image) => image.revisedPrompt !== void 0)?.revisedPrompt;
|
|
2643
|
+
return {
|
|
2644
|
+
paths,
|
|
2645
|
+
...revisedPrompt === void 0 ? {} : { revisedPrompt }
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
//#endregion
|
|
2652
|
+
//#region src/index.ts
|
|
2653
|
+
const name = "dsh-plugin-subscriptions";
|
|
2654
|
+
const inject = ["llm"];
|
|
2655
|
+
/** Default maximum provider idle time while one stream read is outstanding. */
|
|
2656
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
2657
|
+
const providerIdSchema = z.union([
|
|
2658
|
+
"codex",
|
|
2659
|
+
"claude",
|
|
2660
|
+
"grok"
|
|
2661
|
+
]);
|
|
2662
|
+
const modelEntrySchema = z.object({
|
|
2663
|
+
id: z.string().required(),
|
|
2664
|
+
name: z.string(),
|
|
2665
|
+
contextWindow: z.number().step(1).min(1),
|
|
2666
|
+
maxTokens: z.number().step(1).min(1),
|
|
2667
|
+
inputModalities: z.array(z.union(["text", "image"]))
|
|
2668
|
+
});
|
|
2669
|
+
const Config = z.object({
|
|
2670
|
+
providers: z.array(providerIdSchema).default([
|
|
2671
|
+
"codex",
|
|
2672
|
+
"claude",
|
|
2673
|
+
"grok"
|
|
2674
|
+
]),
|
|
2675
|
+
streamIdleTimeoutMs: z.number().min(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
2676
|
+
models: z.object({
|
|
2677
|
+
codex: z.array(modelEntrySchema),
|
|
2678
|
+
claude: z.array(modelEntrySchema),
|
|
2679
|
+
grok: z.array(modelEntrySchema)
|
|
2680
|
+
})
|
|
2681
|
+
});
|
|
2682
|
+
/** Built-in catalogs used when the config does not override a provider's models. */
|
|
2683
|
+
const DEFAULT_MODELS = {
|
|
2684
|
+
codex: [
|
|
2685
|
+
{
|
|
2686
|
+
id: "gpt-5.1-codex",
|
|
2687
|
+
name: "GPT-5.1 Codex"
|
|
2688
|
+
},
|
|
2689
|
+
{
|
|
2690
|
+
id: "gpt-5.1-codex-mini",
|
|
2691
|
+
name: "GPT-5.1 Codex Mini"
|
|
2692
|
+
},
|
|
2693
|
+
{
|
|
2694
|
+
id: "gpt-5.1",
|
|
2695
|
+
name: "GPT-5.1"
|
|
2696
|
+
}
|
|
2697
|
+
],
|
|
2698
|
+
claude: [
|
|
2699
|
+
{
|
|
2700
|
+
id: "claude-opus-4-5",
|
|
2701
|
+
name: "Claude Opus 4.5",
|
|
2702
|
+
maxTokens: 64e3
|
|
2703
|
+
},
|
|
2704
|
+
{
|
|
2705
|
+
id: "claude-sonnet-4-5",
|
|
2706
|
+
name: "Claude Sonnet 4.5"
|
|
2707
|
+
},
|
|
2708
|
+
{
|
|
2709
|
+
id: "claude-haiku-4-5",
|
|
2710
|
+
name: "Claude Haiku 4.5"
|
|
2711
|
+
}
|
|
2712
|
+
],
|
|
2713
|
+
grok: [
|
|
2714
|
+
{
|
|
2715
|
+
id: "grok-4",
|
|
2716
|
+
name: "Grok 4"
|
|
2717
|
+
},
|
|
2718
|
+
{
|
|
2719
|
+
id: "grok-4-fast-reasoning",
|
|
2720
|
+
name: "Grok 4 Fast Reasoning"
|
|
2721
|
+
},
|
|
2722
|
+
{
|
|
2723
|
+
id: "grok-code-fast-1",
|
|
2724
|
+
name: "Grok Code Fast 1"
|
|
2725
|
+
}
|
|
2726
|
+
]
|
|
2727
|
+
};
|
|
2728
|
+
/** Validate and detach the model catalog for every provider. */
|
|
2729
|
+
function resolveCatalog(models) {
|
|
2730
|
+
const resolve = (provider) => {
|
|
2731
|
+
const configured = models?.[provider];
|
|
2732
|
+
return validateModels(configured !== void 0 && configured.length > 0 ? configured : DEFAULT_MODELS[provider], `${name}: models.${provider}`);
|
|
2733
|
+
};
|
|
2734
|
+
return {
|
|
2735
|
+
codex: resolve("codex"),
|
|
2736
|
+
claude: resolve("claude"),
|
|
2737
|
+
grok: resolve("grok")
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
/** The display account of a stored session, for the status endpoint. */
|
|
2741
|
+
function accountOf(provider, session) {
|
|
2742
|
+
if (session === void 0) return void 0;
|
|
2743
|
+
switch (provider) {
|
|
2744
|
+
case "codex": {
|
|
2745
|
+
const codex = session;
|
|
2746
|
+
return codex.emailAddress ?? codexProfileClaims(codex.idToken).emailAddress ?? codex.accountId;
|
|
2747
|
+
}
|
|
2748
|
+
case "claude": return session.emailAddress;
|
|
2749
|
+
case "grok": return session.account;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
/** The subscription detail of a stored session (plan type), for the status endpoint. */
|
|
2753
|
+
function planOf(provider, session) {
|
|
2754
|
+
if (session === void 0) return void 0;
|
|
2755
|
+
switch (provider) {
|
|
2756
|
+
case "codex": {
|
|
2757
|
+
const codex = session;
|
|
2758
|
+
return codex.planType ?? codexProfileClaims(codex.idToken).planType;
|
|
2759
|
+
}
|
|
2760
|
+
case "claude": return session.subscriptionType;
|
|
2761
|
+
case "grok": return;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
|
|
2766
|
+
* OAuth attempts in the background, feed pasted codes, cancel, and log out.
|
|
2767
|
+
*/
|
|
2768
|
+
var SubscriptionsAuthController = class {
|
|
2769
|
+
/** Last login failure per provider, surfaced as `detail` until the next success. */
|
|
2770
|
+
lastError = /* @__PURE__ */ new Map();
|
|
2771
|
+
constructor(flows, onAuthChanged) {
|
|
2772
|
+
this.flows = flows;
|
|
2773
|
+
this.onAuthChanged = onAuthChanged;
|
|
2774
|
+
}
|
|
2775
|
+
async status(provider) {
|
|
2776
|
+
const session = await getSession(provider);
|
|
2777
|
+
const account = accountOf(provider, session);
|
|
2778
|
+
const detail = this.lastError.get(provider) ?? planOf(provider, session);
|
|
2779
|
+
return {
|
|
2780
|
+
loggedIn: session !== void 0,
|
|
2781
|
+
busy: this.flows.isBusy(provider),
|
|
2782
|
+
...session === void 0 ? {} : { expiresAt: session.expiresAt },
|
|
2783
|
+
...account === void 0 ? {} : { account },
|
|
2784
|
+
...detail === void 0 ? {} : { detail }
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
async login(provider) {
|
|
2788
|
+
const spec = provider === "grok" ? await grokFlow() : provider === "claude" ? claudeFlow : codexFlow;
|
|
2789
|
+
const attempt = await this.flows.start(provider, spec);
|
|
2790
|
+
this.complete(provider, attempt);
|
|
2791
|
+
return { authorizeUrl: attempt.authorizeUrl };
|
|
2792
|
+
}
|
|
2793
|
+
/** Drive one attempt to a stored session; records failures for the status endpoint. */
|
|
2794
|
+
async complete(provider, attempt) {
|
|
2795
|
+
try {
|
|
2796
|
+
const code = await attempt.waitCode();
|
|
2797
|
+
const session = await this.exchange(provider, code, attempt);
|
|
2798
|
+
await this.persist(provider, session);
|
|
2799
|
+
this.lastError.delete(provider);
|
|
2800
|
+
this.onAuthChanged(provider);
|
|
2801
|
+
} catch (error) {
|
|
2802
|
+
if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
exchange(provider, code, attempt) {
|
|
2806
|
+
switch (provider) {
|
|
2807
|
+
case "codex": return exchangeCodexCode(code, attempt.pkce.verifier, attempt.redirectUri);
|
|
2808
|
+
case "claude": return exchangeClaudeCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.state);
|
|
2809
|
+
case "grok": return exchangeGrokCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.pkce.challenge);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
persist(provider, session) {
|
|
2813
|
+
switch (provider) {
|
|
2814
|
+
case "codex": return saveSession("codex", session);
|
|
2815
|
+
case "claude": return saveSession("claude", session);
|
|
2816
|
+
case "grok": return saveSession("grok", session);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
manual(provider, input) {
|
|
2820
|
+
const attempt = this.flows.pending(provider);
|
|
2821
|
+
if (attempt === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no ${provider} login attempt is in progress`));
|
|
2822
|
+
attempt.manual(input);
|
|
2823
|
+
return Promise.resolve();
|
|
2824
|
+
}
|
|
2825
|
+
cancel(provider) {
|
|
2826
|
+
this.flows.pending(provider)?.cancel();
|
|
2827
|
+
return Promise.resolve();
|
|
2828
|
+
}
|
|
2829
|
+
async logout(provider) {
|
|
2830
|
+
this.flows.pending(provider)?.cancel();
|
|
2831
|
+
await deleteSession(provider);
|
|
2832
|
+
this.lastError.delete(provider);
|
|
2833
|
+
this.onAuthChanged(provider);
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
function apply(ctx, config) {
|
|
2837
|
+
const providers = [...new Set(config.providers ?? [...PROVIDER_IDS])];
|
|
2838
|
+
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
2839
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0) throw new Error(`${name}: streamIdleTimeoutMs must be a positive finite number`);
|
|
2840
|
+
const catalog = resolveCatalog(config.models);
|
|
2841
|
+
const overridden = new Set(PROVIDER_IDS.filter((provider) => (config.models?.[provider]?.length ?? 0) > 0));
|
|
2842
|
+
const flows = new OAuthFlowManager();
|
|
2843
|
+
const onWarn = (message) => {
|
|
2844
|
+
ctx.logger.warn(`dsh-plugin-subscriptions: ${message}`);
|
|
2845
|
+
};
|
|
2846
|
+
const resolveAttachments = () => ctx.get("attachments");
|
|
2847
|
+
const handles = /* @__PURE__ */ new Map();
|
|
2848
|
+
const authChanged = (provider) => {
|
|
2849
|
+
handles.get(provider)?.replace([provider]);
|
|
2850
|
+
};
|
|
2851
|
+
let codexTokens;
|
|
2852
|
+
let grokTokens;
|
|
2853
|
+
for (const provider of providers) switch (provider) {
|
|
2854
|
+
case "codex": {
|
|
2855
|
+
const tokens = new TokenManager({
|
|
2856
|
+
displayName: "ChatGPT (Codex)",
|
|
2857
|
+
preemptMs: CODEX_PREEMPT_MS,
|
|
2858
|
+
load: () => getSession("codex"),
|
|
2859
|
+
save: (session) => saveSession("codex", session),
|
|
2860
|
+
remove: () => deleteSession("codex"),
|
|
2861
|
+
refresh: refreshCodex,
|
|
2862
|
+
isPermanent: isCodexPermanentRefreshError,
|
|
2863
|
+
onRemoved: () => {
|
|
2864
|
+
authChanged("codex");
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
2867
|
+
codexTokens = tokens;
|
|
2868
|
+
handles.set("codex", ctx.llm.registerAdapter(["codex"], new CodexAdapter({
|
|
2869
|
+
models: catalog.codex,
|
|
2870
|
+
streamIdleTimeoutMs,
|
|
2871
|
+
tokens,
|
|
2872
|
+
discovery: !overridden.has("codex"),
|
|
2873
|
+
onWarn,
|
|
2874
|
+
resolveAttachments
|
|
2875
|
+
})));
|
|
2876
|
+
break;
|
|
2877
|
+
}
|
|
2878
|
+
case "claude": {
|
|
2879
|
+
const tokens = new TokenManager({
|
|
2880
|
+
displayName: "Claude (Subscription)",
|
|
2881
|
+
preemptMs: CLAUDE_PREEMPT_MS,
|
|
2882
|
+
load: () => getSession("claude"),
|
|
2883
|
+
save: (session) => saveSession("claude", session),
|
|
2884
|
+
remove: () => deleteSession("claude"),
|
|
2885
|
+
refresh: refreshClaude,
|
|
2886
|
+
isPermanent: isClaudePermanentRefreshError,
|
|
2887
|
+
onRemoved: () => {
|
|
2888
|
+
authChanged("claude");
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
handles.set("claude", ctx.llm.registerAdapter(["claude"], new ClaudeAdapter({
|
|
2892
|
+
models: catalog.claude,
|
|
2893
|
+
streamIdleTimeoutMs,
|
|
2894
|
+
tokens,
|
|
2895
|
+
resolveAttachments
|
|
2896
|
+
})));
|
|
2897
|
+
break;
|
|
2898
|
+
}
|
|
2899
|
+
case "grok": {
|
|
2900
|
+
const tokens = new TokenManager({
|
|
2901
|
+
displayName: "Grok (Subscription)",
|
|
2902
|
+
preemptMs: GROK_PREEMPT_MS,
|
|
2903
|
+
load: () => getSession("grok"),
|
|
2904
|
+
save: (session) => saveSession("grok", session),
|
|
2905
|
+
remove: () => deleteSession("grok"),
|
|
2906
|
+
refresh: refreshGrok,
|
|
2907
|
+
isPermanent: isGrokPermanentRefreshError,
|
|
2908
|
+
onRemoved: () => {
|
|
2909
|
+
authChanged("grok");
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
grokTokens = tokens;
|
|
2913
|
+
handles.set("grok", ctx.llm.registerAdapter(["grok"], new GrokAdapter({
|
|
2914
|
+
models: catalog.grok,
|
|
2915
|
+
streamIdleTimeoutMs,
|
|
2916
|
+
tokens,
|
|
2917
|
+
discovery: !overridden.has("grok"),
|
|
2918
|
+
onWarn,
|
|
2919
|
+
resolveAttachments
|
|
2920
|
+
})));
|
|
2921
|
+
break;
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged));
|
|
2925
|
+
ctx.inject(["tools"], (toolsCtx) => {
|
|
2926
|
+
if (grokTokens !== void 0) toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
|
|
2927
|
+
if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({ tokens: codexTokens }));
|
|
2928
|
+
});
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
//#endregion
|
|
2932
|
+
export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, apply, inject, name };
|