dsh-claude-subscription 0.2.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/AGENTS.md +85 -0
- package/LICENSE +21 -0
- package/README.en.md +98 -0
- package/README.md +93 -0
- package/SECURITY.md +31 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/README.md +12 -0
- package/lib/client.js +769 -0
- package/lib/index.js +724 -0
- package/package.json +89 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
2
|
+
import { LlmError } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
4
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { anthropicProvider as createAnthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
|
8
|
+
import { createModels } from "@earendil-works/pi-ai";
|
|
9
|
+
//#region src/credential-store.js
|
|
10
|
+
const PROVIDER$1 = "anthropic";
|
|
11
|
+
const abortIfNeeded = (options) => options?.signal?.throwIfAborted();
|
|
12
|
+
const clone = (value) => value === void 0 ? void 0 : structuredClone(value);
|
|
13
|
+
function assertProvider(providerId) {
|
|
14
|
+
if (providerId !== PROVIDER$1) throw new Error(`Claude credential store does not own provider ${JSON.stringify(providerId)}`);
|
|
15
|
+
}
|
|
16
|
+
function assertOAuthCredential(value) {
|
|
17
|
+
if (value === void 0) return void 0;
|
|
18
|
+
if (value === null || typeof value !== "object" || value.type !== "oauth" || typeof value.access !== "string" || value.access.length === 0 || typeof value.refresh !== "string" || value.refresh.length === 0 || typeof value.expires !== "number" || !Number.isFinite(value.expires)) throw new Error("Claude credential store received a malformed OAuth credential");
|
|
19
|
+
return clone(value);
|
|
20
|
+
}
|
|
21
|
+
function parseOAuthCredential(value) {
|
|
22
|
+
try {
|
|
23
|
+
return assertOAuthCredential(JSON.parse(value));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.message === "Claude credential store received a malformed OAuth credential") throw error;
|
|
26
|
+
throw new Error("Claude credential store contains malformed OAuth JSON", { cause: error });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Adapt DSH's managed string credential service to pi-ai's typed OAuth store.
|
|
31
|
+
* Refresh/login/logout operations are serialized so an older refresh response
|
|
32
|
+
* cannot overwrite a newer rotated token.
|
|
33
|
+
*/
|
|
34
|
+
var DshOAuthCredentialStore = class {
|
|
35
|
+
#chains = /* @__PURE__ */ new Map();
|
|
36
|
+
constructor(credentials, ref, legacyRefs = []) {
|
|
37
|
+
if (credentials === void 0 || credentials === null) throw new Error("Claude OAuth requires the DSH credentials service");
|
|
38
|
+
this.credentials = credentials;
|
|
39
|
+
this.ref = ref;
|
|
40
|
+
this.legacyRefs = Object.freeze([...legacyRefs]);
|
|
41
|
+
}
|
|
42
|
+
#enqueue(providerId, operation, options) {
|
|
43
|
+
assertProvider(providerId);
|
|
44
|
+
const current = (this.#chains.get(providerId) ?? Promise.resolve()).catch(() => void 0).then(async () => {
|
|
45
|
+
abortIfNeeded(options);
|
|
46
|
+
return operation();
|
|
47
|
+
});
|
|
48
|
+
const tail = current.catch(() => void 0);
|
|
49
|
+
this.#chains.set(providerId, tail);
|
|
50
|
+
tail.finally(() => {
|
|
51
|
+
if (this.#chains.get(providerId) === tail) this.#chains.delete(providerId);
|
|
52
|
+
});
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
async read(providerId, options) {
|
|
56
|
+
assertProvider(providerId);
|
|
57
|
+
abortIfNeeded(options);
|
|
58
|
+
let hit = await this.credentials.resolve(this.ref);
|
|
59
|
+
if (hit?.value === void 0 || hit.value === "") for (const legacyRef of this.legacyRefs) {
|
|
60
|
+
const legacy = await this.credentials.resolve(legacyRef);
|
|
61
|
+
if (legacy?.value === void 0 || legacy.value === "") continue;
|
|
62
|
+
const migrated = parseOAuthCredential(legacy.value);
|
|
63
|
+
await this.credentials.set(this.ref, JSON.stringify(migrated));
|
|
64
|
+
await this.credentials.unset(legacyRef);
|
|
65
|
+
hit = { value: JSON.stringify(migrated) };
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
abortIfNeeded(options);
|
|
69
|
+
if (hit?.value === void 0 || hit.value === "") return void 0;
|
|
70
|
+
return parseOAuthCredential(hit.value);
|
|
71
|
+
}
|
|
72
|
+
async list(options) {
|
|
73
|
+
abortIfNeeded(options);
|
|
74
|
+
return await this.read(PROVIDER$1, options) === void 0 ? [] : [{
|
|
75
|
+
providerId: PROVIDER$1,
|
|
76
|
+
type: "oauth"
|
|
77
|
+
}];
|
|
78
|
+
}
|
|
79
|
+
modify(providerId, update, options) {
|
|
80
|
+
return this.#enqueue(providerId, async () => {
|
|
81
|
+
const current = await this.read(providerId, options);
|
|
82
|
+
const next = await update(clone(current));
|
|
83
|
+
abortIfNeeded(options);
|
|
84
|
+
if (next === void 0) return current;
|
|
85
|
+
const validated = assertOAuthCredential(next);
|
|
86
|
+
await this.credentials.set(this.ref, JSON.stringify(validated));
|
|
87
|
+
for (const legacyRef of this.legacyRefs) await this.credentials.unset(legacyRef);
|
|
88
|
+
abortIfNeeded(options);
|
|
89
|
+
return clone(validated);
|
|
90
|
+
}, options);
|
|
91
|
+
}
|
|
92
|
+
delete(providerId, options) {
|
|
93
|
+
return this.#enqueue(providerId, async () => {
|
|
94
|
+
await this.credentials.unset(this.ref);
|
|
95
|
+
for (const legacyRef of this.legacyRefs) await this.credentials.unset(legacyRef);
|
|
96
|
+
abortIfNeeded(options);
|
|
97
|
+
}, options);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/** Return only account state that is safe to expose to the browser client. */
|
|
101
|
+
function createClaudeAuthService(models, store) {
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
async status(options) {
|
|
104
|
+
const current = await store.read(PROVIDER$1, options);
|
|
105
|
+
if (current === void 0) return {
|
|
106
|
+
authenticated: false,
|
|
107
|
+
provider: PROVIDER$1
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
authenticated: true,
|
|
111
|
+
provider: PROVIDER$1,
|
|
112
|
+
type: "oauth",
|
|
113
|
+
expiresAt: current.expires
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
login(interaction) {
|
|
117
|
+
return models.login(PROVIDER$1, "oauth", interaction);
|
|
118
|
+
},
|
|
119
|
+
logout(options) {
|
|
120
|
+
return models.logout(PROVIDER$1, options);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/external-url.js
|
|
126
|
+
const CLAUDE_AUTH_ORIGIN = "https://claude.ai";
|
|
127
|
+
/** Validate the only external origin this plugin may launch. */
|
|
128
|
+
function assertClaudeAuthUrl(value) {
|
|
129
|
+
let url;
|
|
130
|
+
try {
|
|
131
|
+
url = new URL(value);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error("Claude auth URL is invalid");
|
|
134
|
+
}
|
|
135
|
+
if (url.protocol !== "https:") throw new Error("Claude auth URL must use HTTPS");
|
|
136
|
+
if (url.origin !== CLAUDE_AUTH_ORIGIN || url.username !== "" || url.password !== "") throw new Error("Claude auth URL must use the claude.ai origin");
|
|
137
|
+
return url.href;
|
|
138
|
+
}
|
|
139
|
+
/** Return a shell-free native opener command for the current desktop. */
|
|
140
|
+
function commandForClaudeAuthUrl(value, platform = process.platform) {
|
|
141
|
+
const url = assertClaudeAuthUrl(value);
|
|
142
|
+
if (platform === "win32") return {
|
|
143
|
+
file: "rundll32.exe",
|
|
144
|
+
args: ["url.dll,FileProtocolHandler", url],
|
|
145
|
+
shell: false
|
|
146
|
+
};
|
|
147
|
+
if (platform === "darwin") return {
|
|
148
|
+
file: "open",
|
|
149
|
+
args: [url],
|
|
150
|
+
shell: false
|
|
151
|
+
};
|
|
152
|
+
if (platform === "linux") return {
|
|
153
|
+
file: "xdg-open",
|
|
154
|
+
args: [url],
|
|
155
|
+
shell: false
|
|
156
|
+
};
|
|
157
|
+
throw new Error(`Claude auth URL opener is unsupported on ${platform}`);
|
|
158
|
+
}
|
|
159
|
+
function openClaudeAuthUrl(value, options = {}) {
|
|
160
|
+
const command = commandForClaudeAuthUrl(value, options.platform);
|
|
161
|
+
const spawnProcess = options.spawn ?? spawn;
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const child = spawnProcess(command.file, command.args, {
|
|
164
|
+
detached: true,
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
windowsHide: true,
|
|
167
|
+
shell: command.shell
|
|
168
|
+
});
|
|
169
|
+
child.once("error", reject);
|
|
170
|
+
child.once("spawn", () => {
|
|
171
|
+
child.unref();
|
|
172
|
+
resolve();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/login-coordinator.js
|
|
178
|
+
const LOGIN_METHODS = /* @__PURE__ */ new Set(["browser"]);
|
|
179
|
+
const TERMINAL_PHASES = /* @__PURE__ */ new Set([
|
|
180
|
+
"authenticated",
|
|
181
|
+
"failed",
|
|
182
|
+
"cancelled"
|
|
183
|
+
]);
|
|
184
|
+
const publicClone = (value) => structuredClone(value);
|
|
185
|
+
const asObject = (value) => value !== null && typeof value === "object" ? value : {};
|
|
186
|
+
const ok = (value) => ({
|
|
187
|
+
ok: true,
|
|
188
|
+
value
|
|
189
|
+
});
|
|
190
|
+
const badRequest = (message) => ({
|
|
191
|
+
ok: false,
|
|
192
|
+
error: {
|
|
193
|
+
code: "bad-request",
|
|
194
|
+
message,
|
|
195
|
+
details: { issues: [] }
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
const deferred = () => {
|
|
199
|
+
let resolve;
|
|
200
|
+
let reject;
|
|
201
|
+
return {
|
|
202
|
+
promise: new Promise((onResolve, onReject) => {
|
|
203
|
+
resolve = onResolve;
|
|
204
|
+
reject = onReject;
|
|
205
|
+
}),
|
|
206
|
+
resolve,
|
|
207
|
+
reject
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
const publicPrompt = (prompt) => ({
|
|
211
|
+
type: prompt.type,
|
|
212
|
+
message: String(prompt.message ?? ""),
|
|
213
|
+
...typeof prompt.placeholder === "string" ? { placeholder: prompt.placeholder } : {}
|
|
214
|
+
});
|
|
215
|
+
/** Own one host-side login without exposing tokens to the browser client. */
|
|
216
|
+
var ClaudeLoginCoordinator = class {
|
|
217
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
218
|
+
#activeId;
|
|
219
|
+
constructor(auth, options = {}) {
|
|
220
|
+
this.auth = auth;
|
|
221
|
+
this.createId = options.createId ?? (() => crypto.randomUUID());
|
|
222
|
+
}
|
|
223
|
+
async accountStatus(options) {
|
|
224
|
+
return publicClone(await this.auth.status(options));
|
|
225
|
+
}
|
|
226
|
+
async start({ method }) {
|
|
227
|
+
if (!LOGIN_METHODS.has(method)) throw new Error(`unsupported Claude login method: ${String(method)}`);
|
|
228
|
+
const active = this.#activeId === void 0 ? void 0 : this.#sessions.get(this.#activeId);
|
|
229
|
+
if (active !== void 0 && !TERMINAL_PHASES.has(active.view.phase)) throw new Error("a Claude login is already active");
|
|
230
|
+
if (active !== void 0) this.#sessions.delete(active.view.id);
|
|
231
|
+
const id = this.createId();
|
|
232
|
+
const ready = deferred();
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const session = {
|
|
235
|
+
controller,
|
|
236
|
+
prompt: void 0,
|
|
237
|
+
ready,
|
|
238
|
+
view: {
|
|
239
|
+
id,
|
|
240
|
+
provider: "anthropic",
|
|
241
|
+
method,
|
|
242
|
+
phase: "starting",
|
|
243
|
+
authenticated: false
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
this.#sessions.set(id, session);
|
|
247
|
+
this.#activeId = id;
|
|
248
|
+
const publishReady = () => ready.resolve(this.read(id));
|
|
249
|
+
const interaction = {
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
prompt: async (prompt) => {
|
|
252
|
+
controller.signal.throwIfAborted();
|
|
253
|
+
if (prompt.type === "select") return method;
|
|
254
|
+
if (![
|
|
255
|
+
"manual_code",
|
|
256
|
+
"text",
|
|
257
|
+
"secret"
|
|
258
|
+
].includes(prompt.type)) throw new Error(`unsupported Claude auth prompt: ${String(prompt.type)}`);
|
|
259
|
+
const answer = deferred();
|
|
260
|
+
session.prompt = answer;
|
|
261
|
+
session.view = {
|
|
262
|
+
...session.view,
|
|
263
|
+
phase: "waiting_input",
|
|
264
|
+
prompt: publicPrompt(prompt)
|
|
265
|
+
};
|
|
266
|
+
const abortPrompt = () => answer.reject(controller.signal.reason ?? /* @__PURE__ */ new Error("login cancelled"));
|
|
267
|
+
controller.signal.addEventListener("abort", abortPrompt, { once: true });
|
|
268
|
+
prompt.signal?.addEventListener("abort", abortPrompt, { once: true });
|
|
269
|
+
publishReady();
|
|
270
|
+
try {
|
|
271
|
+
return await answer.promise;
|
|
272
|
+
} finally {
|
|
273
|
+
controller.signal.removeEventListener("abort", abortPrompt);
|
|
274
|
+
prompt.signal?.removeEventListener("abort", abortPrompt);
|
|
275
|
+
if (session.prompt === answer) session.prompt = void 0;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
notify: (event) => {
|
|
279
|
+
if (controller.signal.aborted) return;
|
|
280
|
+
if (event.type === "auth_url") session.view = {
|
|
281
|
+
...session.view,
|
|
282
|
+
phase: "waiting_browser",
|
|
283
|
+
authUrl: assertClaudeAuthUrl(event.url),
|
|
284
|
+
...typeof event.instructions === "string" ? { instructions: event.instructions } : {}
|
|
285
|
+
};
|
|
286
|
+
else session.view = {
|
|
287
|
+
...session.view,
|
|
288
|
+
message: String(event.message ?? "")
|
|
289
|
+
};
|
|
290
|
+
publishReady();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
session.run = Promise.resolve().then(() => this.auth.login(interaction)).then(async () => {
|
|
294
|
+
if (controller.signal.aborted) return;
|
|
295
|
+
const status = await this.auth.status();
|
|
296
|
+
session.view = {
|
|
297
|
+
id,
|
|
298
|
+
provider: "anthropic",
|
|
299
|
+
method,
|
|
300
|
+
phase: "authenticated",
|
|
301
|
+
authenticated: status.authenticated === true,
|
|
302
|
+
...typeof status.expiresAt === "number" ? { expiresAt: status.expiresAt } : {}
|
|
303
|
+
};
|
|
304
|
+
}).catch((error) => {
|
|
305
|
+
if (controller.signal.aborted) {
|
|
306
|
+
session.view = {
|
|
307
|
+
id,
|
|
308
|
+
provider: "anthropic",
|
|
309
|
+
method,
|
|
310
|
+
phase: "cancelled",
|
|
311
|
+
authenticated: false
|
|
312
|
+
};
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
session.view = {
|
|
316
|
+
id,
|
|
317
|
+
provider: "anthropic",
|
|
318
|
+
method,
|
|
319
|
+
phase: "failed",
|
|
320
|
+
authenticated: false,
|
|
321
|
+
error: "Claude login failed"
|
|
322
|
+
};
|
|
323
|
+
session.hostError = error;
|
|
324
|
+
}).finally(publishReady);
|
|
325
|
+
return ready.promise;
|
|
326
|
+
}
|
|
327
|
+
read(id) {
|
|
328
|
+
const session = this.#sessions.get(id);
|
|
329
|
+
if (session === void 0) throw new Error("unknown Claude login");
|
|
330
|
+
return publicClone(session.view);
|
|
331
|
+
}
|
|
332
|
+
async submit({ id, value }) {
|
|
333
|
+
const session = this.#sessions.get(id);
|
|
334
|
+
if (session === void 0) throw new Error("unknown Claude login");
|
|
335
|
+
if (session.prompt === void 0 || session.view.phase !== "waiting_input") throw new Error("Claude login is not waiting for input");
|
|
336
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error("Claude login input is empty");
|
|
337
|
+
const answer = session.prompt;
|
|
338
|
+
session.prompt = void 0;
|
|
339
|
+
session.view = {
|
|
340
|
+
...session.view,
|
|
341
|
+
phase: session.view.authUrl === void 0 ? "starting" : "waiting_browser",
|
|
342
|
+
prompt: void 0
|
|
343
|
+
};
|
|
344
|
+
answer.resolve(value);
|
|
345
|
+
return this.read(id);
|
|
346
|
+
}
|
|
347
|
+
async cancel(id) {
|
|
348
|
+
const session = this.#sessions.get(id);
|
|
349
|
+
if (session === void 0) throw new Error("unknown Claude login");
|
|
350
|
+
if (!TERMINAL_PHASES.has(session.view.phase)) {
|
|
351
|
+
session.view = {
|
|
352
|
+
id,
|
|
353
|
+
provider: "anthropic",
|
|
354
|
+
method: session.view.method,
|
|
355
|
+
phase: "cancelled",
|
|
356
|
+
authenticated: false
|
|
357
|
+
};
|
|
358
|
+
session.controller.abort(/* @__PURE__ */ new Error("Claude login cancelled"));
|
|
359
|
+
}
|
|
360
|
+
await Promise.resolve(session.run).catch(() => void 0);
|
|
361
|
+
return this.read(id);
|
|
362
|
+
}
|
|
363
|
+
async logout(options) {
|
|
364
|
+
if (this.#activeId !== void 0) {
|
|
365
|
+
const active = this.#sessions.get(this.#activeId);
|
|
366
|
+
if (active !== void 0 && !TERMINAL_PHASES.has(active.view.phase)) await this.cancel(active.view.id);
|
|
367
|
+
}
|
|
368
|
+
await this.auth.logout(options);
|
|
369
|
+
return this.accountStatus(options);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
/** Map the loopback-only DSH Connection channel onto the coordinator. */
|
|
373
|
+
function createClaudeRpcHandler(coordinator, options = {}) {
|
|
374
|
+
const openExternal = options.openExternal;
|
|
375
|
+
return async (endpoint, payload, signal) => {
|
|
376
|
+
try {
|
|
377
|
+
signal.throwIfAborted();
|
|
378
|
+
const input = asObject(payload);
|
|
379
|
+
if (endpoint === "status") return ok(await coordinator.accountStatus({ signal }));
|
|
380
|
+
if (endpoint === "login/start") {
|
|
381
|
+
const started = await coordinator.start({ method: input.method });
|
|
382
|
+
if (input.openExternal !== true) return ok(started);
|
|
383
|
+
const url = started.authUrl;
|
|
384
|
+
if (typeof url !== "string" || openExternal === void 0) return ok({
|
|
385
|
+
...started,
|
|
386
|
+
externalOpened: false
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
await openExternal(url);
|
|
390
|
+
return ok({
|
|
391
|
+
...started,
|
|
392
|
+
externalOpened: true
|
|
393
|
+
});
|
|
394
|
+
} catch {
|
|
395
|
+
return ok({
|
|
396
|
+
...started,
|
|
397
|
+
externalOpened: false
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (endpoint === "login/status") return ok(coordinator.read(input.id));
|
|
402
|
+
if (endpoint === "login/submit") return ok(await coordinator.submit({
|
|
403
|
+
id: input.id,
|
|
404
|
+
value: input.value
|
|
405
|
+
}));
|
|
406
|
+
if (endpoint === "login/cancel") return ok(await coordinator.cancel(input.id));
|
|
407
|
+
if (endpoint === "logout") return ok(await coordinator.logout({ signal }));
|
|
408
|
+
return badRequest(`unknown Claude auth endpoint: ${endpoint}`);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (signal.aborted) throw error;
|
|
411
|
+
const message = error instanceof Error && /^(unknown|unsupported|a Claude|Claude login)/.test(error.message) ? error.message : "Claude request failed";
|
|
412
|
+
return badRequest(message);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/pi-ai-runtime.js
|
|
418
|
+
/**
|
|
419
|
+
* Preserve pi-ai's native Anthropic OAuth provider (Claude Pro/Max) while
|
|
420
|
+
* allowing DSH's generic PiAiAdapter to pass the access token resolved by the
|
|
421
|
+
* host credential store.
|
|
422
|
+
*
|
|
423
|
+
* PiAiAdapter owns a request-local Models collection without a credential
|
|
424
|
+
* store. A pure OAuth provider ignores its `apiKey` request override and fails
|
|
425
|
+
* before dispatch with "Provider is not configured". This non-interactive
|
|
426
|
+
* bridge teaches that collection how to consume only the already-refreshed
|
|
427
|
+
* token for this request; login, refresh, persistence, headers, transport, and
|
|
428
|
+
* model behavior remain owned by the original provider.
|
|
429
|
+
*/
|
|
430
|
+
function anthropicSubscriptionProvider() {
|
|
431
|
+
const provider = createAnthropicProvider();
|
|
432
|
+
const requestToken = Object.freeze({
|
|
433
|
+
name: "DSH-managed Anthropic OAuth request token",
|
|
434
|
+
async resolve({ credential }) {
|
|
435
|
+
const token = credential?.type === "api_key" ? credential.key : void 0;
|
|
436
|
+
if (typeof token !== "string" || token.length === 0) return void 0;
|
|
437
|
+
return {
|
|
438
|
+
auth: { apiKey: token },
|
|
439
|
+
source: "DSH-managed OAuth request"
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
return Object.freeze({
|
|
444
|
+
...provider,
|
|
445
|
+
auth: Object.freeze({
|
|
446
|
+
...provider.auth,
|
|
447
|
+
apiKey: requestToken
|
|
448
|
+
})
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
const PI_AI_RUNTIME_VERSION = "0.82.1";
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/settings-contract.js
|
|
454
|
+
const SETTINGS_NAMESPACE = "claude-subscription";
|
|
455
|
+
const QUICK_QUOTA_FIELD = "quickQuotaVisible";
|
|
456
|
+
Object.freeze({ [QUICK_QUOTA_FIELD]: false });
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/usage.js
|
|
459
|
+
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
460
|
+
const DEFAULT_TTL_MS = 6e4;
|
|
461
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
462
|
+
const OAUTH_BETA = "oauth-2025-04-20";
|
|
463
|
+
const WINDOW_IDS = Object.freeze([
|
|
464
|
+
"five_hour",
|
|
465
|
+
"seven_day",
|
|
466
|
+
"seven_day_opus",
|
|
467
|
+
"seven_day_sonnet"
|
|
468
|
+
]);
|
|
469
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
470
|
+
function epochSecondsOf(value) {
|
|
471
|
+
if (typeof value !== "string" || value.length === 0) return void 0;
|
|
472
|
+
const parsed = Date.parse(value);
|
|
473
|
+
return Number.isFinite(parsed) ? Math.floor(parsed / 1e3) : void 0;
|
|
474
|
+
}
|
|
475
|
+
function windowOf(id, value) {
|
|
476
|
+
if (value === void 0 || value === null) return void 0;
|
|
477
|
+
if (!record(value)) throw new Error("Claude returned a malformed usage window");
|
|
478
|
+
const utilization = value.utilization;
|
|
479
|
+
if (!Number.isFinite(utilization) || utilization < 0 || utilization > 100) throw new Error("Claude returned an invalid utilization percentage");
|
|
480
|
+
const resetsAt = epochSecondsOf(value.resets_at);
|
|
481
|
+
return {
|
|
482
|
+
id,
|
|
483
|
+
remainingPercent: 100 - utilization,
|
|
484
|
+
...resetsAt === void 0 ? {} : { resetsAt }
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function decimalOf(value, label) {
|
|
488
|
+
if (value === void 0 || value === null) return void 0;
|
|
489
|
+
if (!Number.isFinite(value) || value < 0) throw new Error(`Claude returned an invalid ${label}`);
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
function extraUsageOf(value) {
|
|
493
|
+
if (value === void 0 || value === null) return void 0;
|
|
494
|
+
if (!record(value)) throw new Error("Claude returned malformed extra usage details");
|
|
495
|
+
if (typeof value.is_enabled !== "boolean") return void 0;
|
|
496
|
+
const monthlyLimit = decimalOf(value.monthly_limit, "monthly credit limit");
|
|
497
|
+
const usedCredits = decimalOf(value.used_credits, "used credit amount");
|
|
498
|
+
const utilization = decimalOf(value.utilization, "extra usage utilization");
|
|
499
|
+
return {
|
|
500
|
+
enabled: value.is_enabled,
|
|
501
|
+
...monthlyLimit === void 0 ? {} : { monthlyLimit },
|
|
502
|
+
...usedCredits === void 0 ? {} : { usedCredits },
|
|
503
|
+
...utilization === void 0 ? {} : { utilization }
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** Reduce the provider payload to a browser-safe quota projection. */
|
|
507
|
+
function parseClaudeUsage(value) {
|
|
508
|
+
if (!record(value)) throw new Error("Claude returned a malformed usage response");
|
|
509
|
+
const windows = WINDOW_IDS.map((id) => windowOf(id, value[id])).filter((entry) => entry !== void 0);
|
|
510
|
+
const extraUsage = extraUsageOf(value.extra_usage);
|
|
511
|
+
return {
|
|
512
|
+
windows,
|
|
513
|
+
...extraUsage === void 0 ? {} : { extraUsage }
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const requestSignal = (signal, timeoutMs) => {
|
|
517
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
518
|
+
return signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
519
|
+
};
|
|
520
|
+
function messageForStatus(status) {
|
|
521
|
+
if (status === 401 || status === 403) return "Claude sign-in needs to be renewed";
|
|
522
|
+
if (status === 429) return "Claude usage requests are rate limited. Try again later";
|
|
523
|
+
return `Claude usage request failed (HTTP ${status})`;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Read quota through the same refreshable OAuth lifecycle used by model turns.
|
|
527
|
+
* The browser receives only a parsed quota projection; the bearer token is a
|
|
528
|
+
* request-local host value. Concurrent polls share one request.
|
|
529
|
+
*/
|
|
530
|
+
function createClaudeUsageReader(options) {
|
|
531
|
+
const getAuth = options.getAuth;
|
|
532
|
+
const fetchUsage = options.fetch ?? fetch;
|
|
533
|
+
const now = options.now ?? Date.now;
|
|
534
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
535
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
536
|
+
const userAgent = options.userAgent ?? "dsh-claude-subscription/0.2.0";
|
|
537
|
+
let cached;
|
|
538
|
+
let inFlight;
|
|
539
|
+
const load = async (signal) => {
|
|
540
|
+
const access = (await getAuth({ signal }))?.auth?.apiKey;
|
|
541
|
+
if (typeof access !== "string" || access.length === 0) throw new Error("Claude subscription is not signed in");
|
|
542
|
+
const response = await fetchUsage(CLAUDE_USAGE_URL, {
|
|
543
|
+
method: "GET",
|
|
544
|
+
redirect: "error",
|
|
545
|
+
headers: {
|
|
546
|
+
authorization: `Bearer ${access}`,
|
|
547
|
+
"anthropic-beta": OAUTH_BETA,
|
|
548
|
+
accept: "application/json",
|
|
549
|
+
"cache-control": "no-store",
|
|
550
|
+
"user-agent": userAgent
|
|
551
|
+
},
|
|
552
|
+
signal: requestSignal(signal, timeoutMs)
|
|
553
|
+
});
|
|
554
|
+
if (!response.ok) throw new Error(messageForStatus(response.status));
|
|
555
|
+
let value;
|
|
556
|
+
try {
|
|
557
|
+
value = await response.json();
|
|
558
|
+
} catch {
|
|
559
|
+
throw new Error("Claude returned an unreadable usage response");
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
...parseClaudeUsage(value),
|
|
563
|
+
fetchedAt: now()
|
|
564
|
+
};
|
|
565
|
+
};
|
|
566
|
+
return Object.freeze({
|
|
567
|
+
read({ force = false, signal } = {}) {
|
|
568
|
+
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return Promise.resolve(structuredClone(cached));
|
|
569
|
+
if (inFlight !== void 0) return inFlight.then(structuredClone);
|
|
570
|
+
const current = load(signal).then((value) => {
|
|
571
|
+
cached = structuredClone(value);
|
|
572
|
+
return structuredClone(value);
|
|
573
|
+
}).finally(() => {
|
|
574
|
+
if (inFlight === current) inFlight = void 0;
|
|
575
|
+
});
|
|
576
|
+
inFlight = current;
|
|
577
|
+
return current;
|
|
578
|
+
},
|
|
579
|
+
clear() {
|
|
580
|
+
cached = void 0;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/composer-quota.js
|
|
586
|
+
const SHARED_WINDOW_IDS = Object.freeze(["five_hour", "seven_day"]);
|
|
587
|
+
const MODEL_WINDOW_IDS = Object.freeze({
|
|
588
|
+
opus: "seven_day_opus",
|
|
589
|
+
sonnet: "seven_day_sonnet"
|
|
590
|
+
});
|
|
591
|
+
const isDisplayableWindow = (window) => Number.isFinite(window?.remainingPercent) && window.remainingPercent >= 0 && window.remainingPercent <= 100;
|
|
592
|
+
const normalized = (value) => String(value ?? "").toLocaleLowerCase("en-US");
|
|
593
|
+
function applicableIds(model) {
|
|
594
|
+
const name = normalized(model);
|
|
595
|
+
const family = Object.entries(MODEL_WINDOW_IDS).find(([key]) => name.includes(key));
|
|
596
|
+
return family === void 0 ? SHARED_WINDOW_IDS : [...SHARED_WINDOW_IDS, family[1]];
|
|
597
|
+
}
|
|
598
|
+
/** Pick the tightest quota window that constrains the given model. */
|
|
599
|
+
function selectModelQuota(usage, model) {
|
|
600
|
+
const ids = applicableIds(model);
|
|
601
|
+
const windows = Array.isArray(usage?.windows) ? usage.windows.filter((window) => ids.includes(window?.id) && isDisplayableWindow(window)) : [];
|
|
602
|
+
if (windows.length === 0) return void 0;
|
|
603
|
+
const selected = windows.reduce((lowest, candidate) => candidate.remainingPercent < lowest.remainingPercent ? candidate : lowest);
|
|
604
|
+
return {
|
|
605
|
+
id: selected.id,
|
|
606
|
+
remainingPercent: selected.remainingPercent,
|
|
607
|
+
...Number.isSafeInteger(selected.resetsAt) ? { resetsAt: selected.resetsAt } : {}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/index.js
|
|
612
|
+
const name = "claude-subscription";
|
|
613
|
+
const inject = [
|
|
614
|
+
"llm",
|
|
615
|
+
"credentials",
|
|
616
|
+
"connection",
|
|
617
|
+
"settings"
|
|
618
|
+
];
|
|
619
|
+
const PROVIDER = "anthropic";
|
|
620
|
+
const CREDENTIAL_REF = credentialRef("ANTHROPIC_SUBSCRIPTION_OAUTH");
|
|
621
|
+
const CHANNEL = "/claude-subscription";
|
|
622
|
+
const publicError = (code, message) => ({
|
|
623
|
+
ok: false,
|
|
624
|
+
error: {
|
|
625
|
+
code,
|
|
626
|
+
message,
|
|
627
|
+
details: { issues: [] }
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
const KNOWN_USAGE_ERRORS = /* @__PURE__ */ new Set([
|
|
631
|
+
"Claude subscription is not signed in",
|
|
632
|
+
"Claude sign-in needs to be renewed",
|
|
633
|
+
"Claude usage requests are rate limited. Try again later"
|
|
634
|
+
]);
|
|
635
|
+
/**
|
|
636
|
+
* Extend the account RPC with the preference toggle and the quota projection.
|
|
637
|
+
* Unknown endpoints stay owned by the login coordinator.
|
|
638
|
+
*/
|
|
639
|
+
function createSubscriptionRpcHandler({ authHandler, usageReader, preferences }) {
|
|
640
|
+
return async (endpoint, payload, signal) => {
|
|
641
|
+
if (endpoint === "preferences/status" || endpoint === "preferences/update") try {
|
|
642
|
+
signal.throwIfAborted();
|
|
643
|
+
if (endpoint === "preferences/update") {
|
|
644
|
+
if (!Object.hasOwn(payload ?? {}, "quickQuotaVisible")) return publicError("internal", "Invalid preference update");
|
|
645
|
+
if (typeof payload["quickQuotaVisible"] !== "boolean") return publicError("internal", "Invalid quick quota preference");
|
|
646
|
+
await preferences.update({ [QUICK_QUOTA_FIELD]: payload[QUICK_QUOTA_FIELD] });
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
ok: true,
|
|
650
|
+
value: preferences.status()
|
|
651
|
+
};
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (signal.aborted) throw error;
|
|
654
|
+
return publicError("internal", "Could not update preferences");
|
|
655
|
+
}
|
|
656
|
+
if (endpoint === "usage") try {
|
|
657
|
+
signal.throwIfAborted();
|
|
658
|
+
return {
|
|
659
|
+
ok: true,
|
|
660
|
+
value: await usageReader.read({
|
|
661
|
+
force: payload?.force === true,
|
|
662
|
+
signal
|
|
663
|
+
})
|
|
664
|
+
};
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (signal.aborted) throw error;
|
|
667
|
+
const message = error instanceof Error && KNOWN_USAGE_ERRORS.has(error.message) ? error.message : "Could not read Claude usage";
|
|
668
|
+
return publicError("internal", message);
|
|
669
|
+
}
|
|
670
|
+
const result = await authHandler(endpoint, payload, signal);
|
|
671
|
+
if (endpoint === "logout" && result.ok === true) usageReader.clear();
|
|
672
|
+
return result;
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function apply(ctx) {
|
|
676
|
+
const settings = ctx.settings.register(settingsNamespace(SETTINGS_NAMESPACE), z.object({ [QUICK_QUOTA_FIELD]: z.boolean().default(false) }));
|
|
677
|
+
const preferences = {
|
|
678
|
+
status: () => ({
|
|
679
|
+
[QUICK_QUOTA_FIELD]: settings.get()[QUICK_QUOTA_FIELD],
|
|
680
|
+
writable: ctx.settings.writable
|
|
681
|
+
}),
|
|
682
|
+
update: (patch) => settings.update(patch)
|
|
683
|
+
};
|
|
684
|
+
const store = new DshOAuthCredentialStore(ctx.credentials, CREDENTIAL_REF);
|
|
685
|
+
const provider = anthropicSubscriptionProvider();
|
|
686
|
+
const authModels = createModels({ credentials: store });
|
|
687
|
+
authModels.setProvider(provider);
|
|
688
|
+
const profile = Object.freeze({
|
|
689
|
+
provider: PROVIDER,
|
|
690
|
+
displayName: "Claude subscription",
|
|
691
|
+
piProvider: provider,
|
|
692
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
693
|
+
streamIdleTimeoutMs: 600 * 1e3,
|
|
694
|
+
cacheRetention: "short",
|
|
695
|
+
transport: "sse"
|
|
696
|
+
});
|
|
697
|
+
const profiles = /* @__PURE__ */ new Map([[PROVIDER, profile]]);
|
|
698
|
+
const resolveAuth = () => authModels.getAuth(PROVIDER);
|
|
699
|
+
const adapter = new PiAiAdapter({
|
|
700
|
+
profiles: () => profiles,
|
|
701
|
+
resolveApiKey: async () => {
|
|
702
|
+
let resolved;
|
|
703
|
+
try {
|
|
704
|
+
resolved = await resolveAuth();
|
|
705
|
+
} catch {
|
|
706
|
+
throw new LlmError("Claude subscription authorization failed", "AUTH_FAILED");
|
|
707
|
+
}
|
|
708
|
+
if (typeof resolved?.auth.apiKey !== "string" || resolved.auth.apiKey.length === 0) throw new LlmError("Claude subscription is not signed in", "MISSING_CREDENTIAL");
|
|
709
|
+
return resolved.auth.apiKey;
|
|
710
|
+
},
|
|
711
|
+
resolveAttachments: () => void 0
|
|
712
|
+
});
|
|
713
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
714
|
+
const coordinator = new ClaudeLoginCoordinator(createClaudeAuthService(authModels, store));
|
|
715
|
+
const usageReader = createClaudeUsageReader({ getAuth: resolveAuth });
|
|
716
|
+
const handler = createSubscriptionRpcHandler({
|
|
717
|
+
authHandler: createClaudeRpcHandler(coordinator, { openExternal: openClaudeAuthUrl }),
|
|
718
|
+
usageReader,
|
|
719
|
+
preferences
|
|
720
|
+
});
|
|
721
|
+
ctx.effect(() => ctx.connection.rpc.handle(CHANNEL, handler, { authority: "loopback" }), "claude-subscription: loopback account RPC");
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
export { CLAUDE_USAGE_URL, ClaudeLoginCoordinator, DshOAuthCredentialStore, PI_AI_RUNTIME_VERSION, anthropicSubscriptionProvider, apply, assertClaudeAuthUrl, createClaudeAuthService, createClaudeRpcHandler, createClaudeUsageReader, createSubscriptionRpcHandler, inject, name, openClaudeAuthUrl, parseClaudeUsage, selectModelQuota };
|