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