dsh-subscriptions 0.1.5 → 0.2.2
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 +212 -154
- package/lib/auth/claude-code-creds.js +31 -9
- package/lib/auth/device-flow.js +16 -9
- package/lib/auth/oauth-flow.js +13 -10
- package/lib/auth/rpc.d.ts +73 -2
- package/lib/auth/rpc.js +124 -23
- package/lib/auth/store.js +80 -31
- package/lib/client/CursorCard.d.ts +1 -1
- package/lib/client/CursorCard.js +50 -16
- package/lib/client/ExternalUsageCards.d.ts +1 -1
- package/lib/client/ExternalUsageCards.js +37 -16
- package/lib/client/ImageGallery.d.ts +2 -2
- package/lib/client/ImageGallery.js +92 -33
- package/lib/client/ImageGenerateToolview.js +50 -14
- package/lib/client/OllamaCard.d.ts +13 -0
- package/lib/client/OllamaCard.js +94 -0
- package/lib/client/OllamaSettingsManager.d.ts +24 -0
- package/lib/client/OllamaSettingsManager.js +329 -0
- package/lib/client/ProviderAccountManager.d.ts +1 -1
- package/lib/client/ProviderAccountManager.js +164 -49
- package/lib/client/ProviderModelEditor.js +109 -26
- package/lib/client/SpeedSelect.d.ts +62 -14
- package/lib/client/SpeedSelect.js +257 -46
- package/lib/client/SubscriptionUsageBadge.d.ts +5 -11
- package/lib/client/SubscriptionUsageBadge.js +301 -112
- package/lib/client/SubscriptionsSection.d.ts +2 -0
- package/lib/client/SubscriptionsSection.js +259 -85
- package/lib/client/UsageBadgeDisplaySetting.js +35 -12
- package/lib/client/UsageMeter.d.ts +1 -1
- package/lib/client/UsageMeter.js +69 -27
- package/lib/client/VideoGenerateToolview.js +69 -17
- package/lib/client/account-preferences.d.ts +8 -0
- package/lib/client/account-preferences.js +20 -5
- package/lib/client/host-contract.d.ts +11 -2
- package/lib/client/host-contract.js +20 -3
- package/lib/client/index.d.ts +5 -5
- package/lib/client/index.js +34 -12
- package/lib/client/locales.d.ts +78 -0
- package/lib/client/locales.js +121 -34
- package/lib/client/ollama-draft.d.ts +229 -0
- package/lib/client/ollama-draft.js +255 -0
- package/lib/client/reset-action.js +6 -4
- package/lib/client/reset-credits-view.d.ts +1 -1
- package/lib/client/reset-credits-view.js +66 -17
- package/lib/client/subscription-card-styles.js +94 -24
- package/lib/client/usage-color-preferences.js +4 -2
- package/lib/client/usage-pace.d.ts +7 -0
- package/lib/client/usage-pace.js +43 -10
- package/lib/client.js +1647 -266
- package/lib/client.js.map +1 -1
- package/lib/http.js +1 -1
- package/lib/index.d.ts +28 -3
- package/lib/index.js +9329 -8217
- package/lib/model-defaults.js +4 -2
- package/lib/provider-settings.d.ts +14 -1
- package/lib/provider-settings.js +64 -20
- package/lib/providers/account-preferences.js +51 -22
- package/lib/providers/accounts.js +15 -9
- package/lib/providers/antigravity-oauth-client.js +2 -2
- package/lib/providers/antigravity.js +141 -68
- package/lib/providers/catalog-store.js +120 -41
- package/lib/providers/claude-cli-version.js +3 -3
- package/lib/providers/claude.d.ts +23 -3
- package/lib/providers/claude.js +167 -92
- package/lib/providers/codex-client-version.js +3 -3
- package/lib/providers/codex-search.js +40 -13
- package/lib/providers/codex.js +196 -112
- package/lib/providers/common.d.ts +14 -0
- package/lib/providers/common.js +118 -38
- package/lib/providers/copilot.js +143 -82
- package/lib/providers/cursor-adapter.js +42 -18
- package/lib/providers/cursor-auth.d.ts +1 -1
- package/lib/providers/cursor-auth.js +69 -24
- package/lib/providers/cursor-model-settings.js +14 -5
- package/lib/providers/cursor-usage.js +27 -13
- package/lib/providers/external-usage-controller.d.ts +9 -2
- package/lib/providers/external-usage-controller.js +30 -7
- package/lib/providers/external-usage.d.ts +8 -0
- package/lib/providers/external-usage.js +140 -20
- package/lib/providers/grok.js +152 -89
- package/lib/providers/image-pool.js +29 -11
- package/lib/providers/minimax-usage.js +31 -11
- package/lib/providers/npm-cli-version.js +10 -3
- package/lib/providers/ollama-settings.d.ts +90 -0
- package/lib/providers/ollama-settings.js +213 -0
- package/lib/providers/ollama-web.d.ts +46 -0
- package/lib/providers/ollama-web.js +231 -0
- package/lib/providers/ollama.d.ts +107 -0
- package/lib/providers/ollama.js +608 -0
- package/lib/providers/pool-family.js +11 -3
- package/lib/providers/pool-health.js +28 -8
- package/lib/providers/pool-usage.js +16 -9
- package/lib/providers/pool.d.ts +8 -0
- package/lib/providers/pool.js +81 -42
- package/lib/providers/rate-limit.js +8 -2
- package/lib/providers/replay.js +40 -17
- package/lib/providers/reset-redemption.js +18 -6
- package/lib/tools/image-generate.js +123 -65
- package/lib/tools/registration.js +5 -2
- package/lib/tools/video-generate.js +51 -31
- package/lib/tools/x-search.js +50 -22
- package/lib/translate/anthropic.js +85 -37
- package/lib/translate/antigravity-schema.js +71 -28
- package/lib/translate/antigravity-thinking.js +22 -9
- package/lib/translate/antigravity.js +141 -50
- package/lib/translate/chat-completions.js +67 -23
- package/lib/translate/resolved.js +55 -21
- package/lib/translate/responses.js +69 -29
- package/lib/translate/sse.js +4 -1
- package/package.json +26 -4
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* cooldown (transport blips say nothing about the account), or rethrow
|
|
10
10
|
* (the request itself is at fault and another account would fail alike).
|
|
11
11
|
*/
|
|
12
|
-
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
|
|
13
13
|
/** Registry key for one pool member. */
|
|
14
14
|
export function memberKey(provider, account, model) {
|
|
15
15
|
return `${provider}/${account}/${model}`;
|
|
@@ -33,7 +33,10 @@ export const TRANSIENT_COOLDOWN_MS = 60_000;
|
|
|
33
33
|
* wall means its siblings on the SAME account would too, so the cooldown
|
|
34
34
|
* parks the account (other accounts of the provider are unaffected).
|
|
35
35
|
*/
|
|
36
|
-
const MODEL_SCOPED_QUOTA_PROVIDERS = new Set([
|
|
36
|
+
const MODEL_SCOPED_QUOTA_PROVIDERS = new Set([
|
|
37
|
+
'claude',
|
|
38
|
+
'antigravity',
|
|
39
|
+
]);
|
|
37
40
|
/** The `retry-after` an adapter propagated through `httpLlmError`, when any. */
|
|
38
41
|
function retryAfterMs(error) {
|
|
39
42
|
return error.failure.providerRetryAfterMs;
|
|
@@ -61,16 +64,28 @@ export function classifyPoolFailure(error, provider) {
|
|
|
61
64
|
action: 'switch',
|
|
62
65
|
cooldownMs: retryAfterMs(error) ?? DEFAULT_QUOTA_COOLDOWN_MS,
|
|
63
66
|
reason: error.code,
|
|
64
|
-
scope: MODEL_SCOPED_QUOTA_PROVIDERS.has(provider)
|
|
67
|
+
scope: MODEL_SCOPED_QUOTA_PROVIDERS.has(provider)
|
|
68
|
+
? 'member'
|
|
69
|
+
: 'account',
|
|
65
70
|
};
|
|
66
71
|
case 'AUTH':
|
|
67
72
|
case 'INVALID_CREDENTIAL':
|
|
68
73
|
case 'MISSING_CREDENTIAL':
|
|
69
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
action: 'switch',
|
|
76
|
+
cooldownMs: AUTH_COOLDOWN_MS,
|
|
77
|
+
reason: error.code,
|
|
78
|
+
scope: 'account',
|
|
79
|
+
};
|
|
70
80
|
case 'SERVER':
|
|
71
81
|
case 'TIMEOUT':
|
|
72
82
|
case 'EMPTY_RESPONSE':
|
|
73
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
action: 'switch',
|
|
85
|
+
cooldownMs: TRANSIENT_COOLDOWN_MS,
|
|
86
|
+
reason: error.code,
|
|
87
|
+
scope: 'member',
|
|
88
|
+
};
|
|
74
89
|
case 'TRANSPORT':
|
|
75
90
|
return { action: 'switch' };
|
|
76
91
|
case 'HTTP_402':
|
|
@@ -78,7 +93,12 @@ export function classifyPoolFailure(error, provider) {
|
|
|
78
93
|
// Plan/model availability is account-shaped: another account of the
|
|
79
94
|
// same subscription may still serve. A 400 that is not a context-window
|
|
80
95
|
// error stays HTTP_400 and throws (the request itself is at fault).
|
|
81
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
action: 'switch',
|
|
98
|
+
cooldownMs: TRANSIENT_COOLDOWN_MS,
|
|
99
|
+
reason: error.code,
|
|
100
|
+
scope: 'member',
|
|
101
|
+
};
|
|
82
102
|
case CONTEXT_WINDOW_EXCEEDED_CODE:
|
|
83
103
|
case 'ABORTED':
|
|
84
104
|
default:
|
|
@@ -94,8 +114,8 @@ export class PoolHealthRegistry {
|
|
|
94
114
|
records = new Map();
|
|
95
115
|
/** Whether a member may serve: neither it nor its whole account is cooling. */
|
|
96
116
|
isMemberAvailable(provider, account, model, now = Date.now()) {
|
|
97
|
-
return this.isAvailable(accountKey(provider, account), now)
|
|
98
|
-
|
|
117
|
+
return (this.isAvailable(accountKey(provider, account), now) &&
|
|
118
|
+
this.isAvailable(memberKey(provider, account, model), now));
|
|
99
119
|
}
|
|
100
120
|
/** Whether one registry key is clear right now. */
|
|
101
121
|
isAvailable(key, now = Date.now()) {
|
|
@@ -102,7 +102,8 @@ export class PoolUsageTracker {
|
|
|
102
102
|
return { supported: false };
|
|
103
103
|
const key = `${provider}/${account}`;
|
|
104
104
|
const entry = this.entries.get(key);
|
|
105
|
-
if (entry !== undefined &&
|
|
105
|
+
if (entry !== undefined &&
|
|
106
|
+
Date.now() - entry.at < (entry.cooldownMs ?? this.ttlMs)) {
|
|
106
107
|
if (entry.snapshot !== undefined) {
|
|
107
108
|
if (!force)
|
|
108
109
|
return entry.snapshot;
|
|
@@ -178,29 +179,33 @@ export class PoolUsageTracker {
|
|
|
178
179
|
// fetch fails late and would otherwise cache a cooldown over a fresher
|
|
179
180
|
// success that already landed.
|
|
180
181
|
const sequence = ++this.nextSequence;
|
|
181
|
-
pending = fetcher()
|
|
182
|
+
pending = fetcher()
|
|
183
|
+
.then((value) => {
|
|
182
184
|
const at = Date.now();
|
|
183
185
|
const snapshot = { ...value, observedAt: at, stale: false };
|
|
184
186
|
// An invalidate() during the fetch means this result describes a world
|
|
185
187
|
// that has moved on; caching it would resurrect the old numbers.
|
|
186
|
-
if ((this.generation.get(key) ?? 0) === generation &&
|
|
188
|
+
if ((this.generation.get(key) ?? 0) === generation &&
|
|
189
|
+
(this.lastWritten.get(key) ?? 0) < sequence) {
|
|
187
190
|
this.lastWritten.set(key, sequence);
|
|
188
191
|
this.entries.set(key, { snapshot, at });
|
|
189
192
|
}
|
|
190
193
|
return snapshot;
|
|
191
194
|
}, (error) => {
|
|
192
|
-
if (!isMissingOrInvalidCredential(error) &&
|
|
193
|
-
|
|
195
|
+
if (!isMissingOrInvalidCredential(error) &&
|
|
196
|
+
(this.generation.get(key) ?? 0) === generation &&
|
|
197
|
+
(this.lastWritten.get(key) ?? 0) < sequence) {
|
|
194
198
|
this.lastWritten.set(key, sequence);
|
|
195
199
|
this.entries.set(key, {
|
|
196
200
|
error,
|
|
197
201
|
at: Date.now(),
|
|
198
202
|
cooldownMs: cooldownFor(error, this.ttlMs),
|
|
199
|
-
...lastSnapshot === undefined ? {} : { lastSnapshot },
|
|
203
|
+
...(lastSnapshot === undefined ? {} : { lastSnapshot }),
|
|
200
204
|
});
|
|
201
205
|
}
|
|
202
206
|
throw error;
|
|
203
|
-
})
|
|
207
|
+
})
|
|
208
|
+
.finally(() => {
|
|
204
209
|
// Only clear our own slot: an invalidate may have replaced it.
|
|
205
210
|
if (this.inflight.get(key) === pending)
|
|
206
211
|
this.inflight.delete(key);
|
|
@@ -211,7 +216,7 @@ export class PoolUsageTracker {
|
|
|
211
216
|
}
|
|
212
217
|
/** Score one member against a snapshot's windows. */
|
|
213
218
|
score(member, entry) {
|
|
214
|
-
const windows = (entry.snapshot.windows ?? []).filter(window => windowApplies(window, member.model));
|
|
219
|
+
const windows = (entry.snapshot.windows ?? []).filter((window) => windowApplies(window, member.model));
|
|
215
220
|
let available = true;
|
|
216
221
|
let urgency = 0;
|
|
217
222
|
for (const window of windows) {
|
|
@@ -235,7 +240,9 @@ function degradedQuota(error) {
|
|
|
235
240
|
}
|
|
236
241
|
/** How long to hold a failure in the negative cache: the endpoint's own `retry-after`, or the default TTL. */
|
|
237
242
|
function cooldownFor(error, defaultTtlMs) {
|
|
238
|
-
return error instanceof OAuthEndpointError && error.retryAfterMs !== undefined
|
|
243
|
+
return error instanceof OAuthEndpointError && error.retryAfterMs !== undefined
|
|
244
|
+
? error.retryAfterMs
|
|
245
|
+
: defaultTtlMs;
|
|
239
246
|
}
|
|
240
247
|
/**
|
|
241
248
|
* Whether a window constrains this model: unscoped windows always do; a
|
package/lib/providers/pool.d.ts
CHANGED
|
@@ -105,6 +105,14 @@ export declare class PoolAdapter extends LlmAdapter {
|
|
|
105
105
|
private select;
|
|
106
106
|
/** Pin the serving member to the session (with bounded memory). */
|
|
107
107
|
private remember;
|
|
108
|
+
/**
|
|
109
|
+
* Build the terminal exhaustion error and log it. Per-member failovers are
|
|
110
|
+
* already warned above; without this line dsh.log shows the failover chain
|
|
111
|
+
* but not the exhaustion that ends it. The message carries only the
|
|
112
|
+
* LlmError's own sanitized text and code — raw provider payloads never
|
|
113
|
+
* reach here (see the note in {@link exhausted}).
|
|
114
|
+
*/
|
|
115
|
+
private exhaustedWithWarn;
|
|
108
116
|
/** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
|
|
109
117
|
private exhausted;
|
|
110
118
|
}
|
package/lib/providers/pool.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
|
|
11
11
|
import { poolKey } from './pool-family.js';
|
|
12
|
-
import { accountKey, classifyPoolFailure, memberKey, PoolHealthRegistry } from './pool-health.js';
|
|
12
|
+
import { accountKey, classifyPoolFailure, memberKey, PoolHealthRegistry, } from './pool-health.js';
|
|
13
13
|
import { streamAccountWithReplay } from './replay.js';
|
|
14
14
|
/** Bound on sticky-session memory; oldest entries evict past it. */
|
|
15
15
|
const STICKY_SESSION_LIMIT = 1000;
|
|
@@ -57,7 +57,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
57
57
|
usable(pools) {
|
|
58
58
|
const result = new Map(pools);
|
|
59
59
|
for (const [id, definition] of [...result]) {
|
|
60
|
-
const kept = definition.members.filter(member => this.options.adapters[member.provider] !== undefined);
|
|
60
|
+
const kept = definition.members.filter((member) => this.options.adapters[member.provider] !== undefined);
|
|
61
61
|
if (kept.length === 0)
|
|
62
62
|
result.delete(id);
|
|
63
63
|
else if (kept.length < definition.members.length)
|
|
@@ -118,7 +118,9 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
118
118
|
provider,
|
|
119
119
|
id,
|
|
120
120
|
name: definition.name ?? id,
|
|
121
|
-
...definition.description === undefined
|
|
121
|
+
...(definition.description === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { description: definition.description }),
|
|
122
124
|
});
|
|
123
125
|
}
|
|
124
126
|
return models;
|
|
@@ -139,7 +141,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
139
141
|
const seen = new Set();
|
|
140
142
|
const resolved = [];
|
|
141
143
|
for (const member of members) {
|
|
142
|
-
const requested = member.account ?? await this.options.defaultAccount(member.provider);
|
|
144
|
+
const requested = member.account ?? (await this.options.defaultAccount(member.provider));
|
|
143
145
|
if (requested === undefined)
|
|
144
146
|
continue;
|
|
145
147
|
const account = await (this.options.resolveAccount?.(member.provider, requested) ?? Promise.resolve(requested));
|
|
@@ -177,28 +179,38 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
177
179
|
}
|
|
178
180
|
catch (error) {
|
|
179
181
|
lastFailure = error;
|
|
180
|
-
this.warnOnce(`pool "${model}": member ${memberLabel(member)} failed to resolve`
|
|
181
|
-
|
|
182
|
+
this.warnOnce(`pool "${model}": member ${memberLabel(member)} failed to resolve` +
|
|
183
|
+
` (${error instanceof Error ? error.message : String(error)}); excluding it`);
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
if (resolved.length === 0) {
|
|
185
187
|
throw new LlmError(`pool "${model}" has no usable member`, 'NO_ADAPTER', {
|
|
186
|
-
...lastFailure === undefined ? {} : { cause: lastFailure },
|
|
188
|
+
...(lastFailure === undefined ? {} : { cause: lastFailure }),
|
|
187
189
|
});
|
|
188
190
|
}
|
|
189
|
-
const contextWindows = resolved
|
|
190
|
-
|
|
191
|
+
const contextWindows = resolved
|
|
192
|
+
.map((info) => info.context?.contextWindow)
|
|
193
|
+
.filter(isNumber);
|
|
194
|
+
const maxTokens = resolved
|
|
195
|
+
.map((info) => info.defaultMaxTokens)
|
|
196
|
+
.filter(isNumber);
|
|
191
197
|
const reasoning = intersectReasoning(resolved);
|
|
192
198
|
const modalities = intersectModalities(resolved);
|
|
193
199
|
return {
|
|
194
200
|
provider,
|
|
195
201
|
id: model,
|
|
196
202
|
name: definition.name ?? model,
|
|
197
|
-
...definition.description === undefined
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
...
|
|
201
|
-
|
|
203
|
+
...(definition.description === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { description: definition.description }),
|
|
206
|
+
...(contextWindows.length > 0
|
|
207
|
+
? { context: { contextWindow: Math.min(...contextWindows) } }
|
|
208
|
+
: {}),
|
|
209
|
+
...(maxTokens.length > 0
|
|
210
|
+
? { defaultMaxTokens: Math.min(...maxTokens) }
|
|
211
|
+
: {}),
|
|
212
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
213
|
+
...(modalities === undefined ? {} : { inputModalities: modalities }),
|
|
202
214
|
};
|
|
203
215
|
}
|
|
204
216
|
/**
|
|
@@ -214,7 +226,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
214
226
|
const members = await this.concrete(definition.members);
|
|
215
227
|
const candidates = await this.select(options.model, members, options.sessionId);
|
|
216
228
|
if (candidates.length === 0)
|
|
217
|
-
throw this.
|
|
229
|
+
throw this.exhaustedWithWarn(options.model, members);
|
|
218
230
|
const failures = [];
|
|
219
231
|
for (const member of candidates) {
|
|
220
232
|
const adapter = this.options.adapters[member.provider];
|
|
@@ -239,12 +251,13 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
239
251
|
// A quota failure invalidates the cached usage snapshot so the NEXT
|
|
240
252
|
// selection re-polls instead of trusting minutes-old percentages.
|
|
241
253
|
// Transient/auth failures say nothing about quota — keep the cache.
|
|
242
|
-
if (classification.reason === QUOTA_EXCEEDED_CODE ||
|
|
254
|
+
if (classification.reason === QUOTA_EXCEEDED_CODE ||
|
|
255
|
+
classification.reason === 'RATE_LIMIT') {
|
|
243
256
|
this.options.usage.invalidate(member.provider, member.account);
|
|
244
257
|
}
|
|
245
258
|
}
|
|
246
|
-
this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output`
|
|
247
|
-
|
|
259
|
+
this.options.onWarn(`pool "${options.model}": ${memberLabel(member)} failed before any output` +
|
|
260
|
+
` (${error instanceof Error ? error.message : String(error)}); trying the next member`);
|
|
248
261
|
failures.push(error);
|
|
249
262
|
continue;
|
|
250
263
|
}
|
|
@@ -271,7 +284,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
271
284
|
}
|
|
272
285
|
return;
|
|
273
286
|
}
|
|
274
|
-
throw this.
|
|
287
|
+
throw this.exhaustedWithWarn(options.model, members, failures);
|
|
275
288
|
}
|
|
276
289
|
/**
|
|
277
290
|
* Order the candidates for one request. Health filters both strategies;
|
|
@@ -282,27 +295,29 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
282
295
|
* `switchMargin`.
|
|
283
296
|
*/
|
|
284
297
|
async select(poolId, members, sessionId) {
|
|
285
|
-
const usable = members.filter(member => this.options.adapters[member.provider] !== undefined
|
|
286
|
-
|
|
298
|
+
const usable = members.filter((member) => this.options.adapters[member.provider] !== undefined &&
|
|
299
|
+
this.options.health.isMemberAvailable(member.provider, member.account, member.model));
|
|
287
300
|
if (usable.length === 0)
|
|
288
301
|
return [];
|
|
289
302
|
const stickyMember = sessionId === undefined
|
|
290
303
|
? undefined
|
|
291
|
-
: usable.find(member => memberKey(member.provider, member.account, member.model) ===
|
|
304
|
+
: usable.find((member) => memberKey(member.provider, member.account, member.model) ===
|
|
305
|
+
this.sticky.get(stickyKey(poolId, sessionId)));
|
|
292
306
|
if (this.options.strategy === 'priority') {
|
|
293
307
|
return stickyMember === undefined
|
|
294
308
|
? usable
|
|
295
|
-
: [stickyMember, ...usable.filter(member => member !== stickyMember)];
|
|
309
|
+
: [stickyMember, ...usable.filter((member) => member !== stickyMember)];
|
|
296
310
|
}
|
|
297
311
|
const quotas = new Map(await Promise.all(usable.map(async (member) => [member, await this.options.usage.quotaFor(member)])));
|
|
298
|
-
const scored = usable.filter(member => quotas.get(member)?.available === true);
|
|
299
|
-
const quotaFull = usable.filter(member => quotas.get(member)?.available === false);
|
|
312
|
+
const scored = usable.filter((member) => quotas.get(member)?.available === true);
|
|
313
|
+
const quotaFull = usable.filter((member) => quotas.get(member)?.available === false);
|
|
300
314
|
scored.sort((a, b) => (quotas.get(b)?.urgency ?? 0) - (quotas.get(a)?.urgency ?? 0));
|
|
301
315
|
if (stickyMember !== undefined && scored.includes(stickyMember)) {
|
|
302
316
|
const best = scored[0];
|
|
303
317
|
const stickyUrgency = quotas.get(stickyMember)?.urgency ?? 0;
|
|
304
318
|
const bestUrgency = quotas.get(best)?.urgency ?? 0;
|
|
305
|
-
if (best === stickyMember ||
|
|
319
|
+
if (best === stickyMember ||
|
|
320
|
+
bestUrgency <= stickyUrgency * this.options.switchMargin) {
|
|
306
321
|
// Sticky holds (no challenger beats it by the margin): lead with it.
|
|
307
322
|
scored.splice(scored.indexOf(stickyMember), 1);
|
|
308
323
|
scored.unshift(stickyMember);
|
|
@@ -323,6 +338,18 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
323
338
|
}
|
|
324
339
|
this.sticky.set(key, memberKey(member.provider, member.account, member.model));
|
|
325
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Build the terminal exhaustion error and log it. Per-member failovers are
|
|
343
|
+
* already warned above; without this line dsh.log shows the failover chain
|
|
344
|
+
* but not the exhaustion that ends it. The message carries only the
|
|
345
|
+
* LlmError's own sanitized text and code — raw provider payloads never
|
|
346
|
+
* reach here (see the note in {@link exhausted}).
|
|
347
|
+
*/
|
|
348
|
+
exhaustedWithWarn(model, pool, failures = []) {
|
|
349
|
+
const error = this.exhausted(model, pool, failures);
|
|
350
|
+
this.options.onWarn(`pool "${model}": exhausted (${error.code}: ${error.message})`);
|
|
351
|
+
return error;
|
|
352
|
+
}
|
|
326
353
|
/** Report attempt failures or this pool's active cooldowns, never unrelated health records. */
|
|
327
354
|
exhausted(model, pool, failures = []) {
|
|
328
355
|
if (pool.length === 0) {
|
|
@@ -336,7 +363,7 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
336
363
|
const recovery = this.options.health.earliestRecovery(keys);
|
|
337
364
|
const retryAfterMs = recovery === undefined ? undefined : Math.max(recovery - Date.now(), 1);
|
|
338
365
|
const coolingReasons = this.options.health.unavailableReasons(keys);
|
|
339
|
-
const codes = [...failures.map(error => error.code), ...coolingReasons];
|
|
366
|
+
const codes = [...failures.map((error) => error.code), ...coolingReasons];
|
|
340
367
|
if (codes.length === 0)
|
|
341
368
|
return new LlmError(`pool "${model}" has no usable member`, 'NO_ADAPTER');
|
|
342
369
|
const quotaOnly = codes.every(isQuotaFailure);
|
|
@@ -352,15 +379,22 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
352
379
|
// Keep the error object itself. LlmError freezes its facts snapshot, so
|
|
353
380
|
// replace that snapshot to correct only providerRetryAfterMs for pool
|
|
354
381
|
// routing; this is not permission to rewrite other frozen failure facts.
|
|
355
|
-
if (retryAfterMs !== undefined &&
|
|
382
|
+
if (retryAfterMs !== undefined &&
|
|
383
|
+
retryAfterMs !== failure.failure.providerRetryAfterMs) {
|
|
356
384
|
Object.defineProperty(failure, 'failure', {
|
|
357
|
-
value: Object.freeze({
|
|
385
|
+
value: Object.freeze({
|
|
386
|
+
...failure.failure,
|
|
387
|
+
providerRetryAfterMs: retryAfterMs,
|
|
388
|
+
}),
|
|
358
389
|
});
|
|
359
390
|
}
|
|
360
391
|
return failure;
|
|
361
392
|
}
|
|
362
|
-
const attemptedCodes = failures.map(error => error.code);
|
|
363
|
-
const details = [
|
|
393
|
+
const attemptedCodes = failures.map((error) => error.code);
|
|
394
|
+
const details = [
|
|
395
|
+
...attemptedCodes,
|
|
396
|
+
...coolingReasons.filter((code) => !attemptedCodes.includes(code)),
|
|
397
|
+
];
|
|
364
398
|
const summary = failureSummary(details);
|
|
365
399
|
const cause = failures.length === 0
|
|
366
400
|
? undefined
|
|
@@ -377,11 +411,13 @@ export class PoolAdapter extends LlmAdapter {
|
|
|
377
411
|
// merely because the last member hit its quota. No new aggregate error code.
|
|
378
412
|
const code = quotaOnly
|
|
379
413
|
? 'RATE_LIMIT'
|
|
380
|
-
: attemptedCodes.filter(code => !isQuotaFailure(code)).at(-1)
|
|
381
|
-
|
|
414
|
+
: (attemptedCodes.filter((code) => !isQuotaFailure(code)).at(-1) ??
|
|
415
|
+
coolingReasons.filter((code) => !isQuotaFailure(code)).at(-1));
|
|
382
416
|
return new LlmError(message, code, {
|
|
383
|
-
...retryAfterMs === undefined
|
|
384
|
-
|
|
417
|
+
...(retryAfterMs === undefined
|
|
418
|
+
? {}
|
|
419
|
+
: { providerRetryAfterMs: retryAfterMs }),
|
|
420
|
+
...(cause === undefined ? {} : { cause }),
|
|
385
421
|
});
|
|
386
422
|
}
|
|
387
423
|
}
|
|
@@ -404,9 +440,11 @@ function failureSummary(codes) {
|
|
|
404
440
|
RATE_LIMIT: 'provider rate limit reached',
|
|
405
441
|
};
|
|
406
442
|
const limit = 5;
|
|
407
|
-
const shown = codes.slice(0, limit).map(code => {
|
|
443
|
+
const shown = codes.slice(0, limit).map((code) => {
|
|
408
444
|
const description = descriptions[code];
|
|
409
|
-
return description === undefined
|
|
445
|
+
return description === undefined
|
|
446
|
+
? 'member unavailable'
|
|
447
|
+
: `${code}: ${description}`;
|
|
410
448
|
});
|
|
411
449
|
if (codes.length > limit)
|
|
412
450
|
shown.push(`${codes.length - limit} more`);
|
|
@@ -423,21 +461,22 @@ function intersectReasoning(resolved) {
|
|
|
423
461
|
const [first, ...rest] = resolved;
|
|
424
462
|
if (first?.reasoning === undefined)
|
|
425
463
|
return undefined;
|
|
426
|
-
const efforts = first.reasoning.efforts.filter(effort => rest.every(info => info.reasoning?.efforts.some(other => other.id === effort.id) ===
|
|
464
|
+
const efforts = first.reasoning.efforts.filter((effort) => rest.every((info) => info.reasoning?.efforts.some((other) => other.id === effort.id) ===
|
|
465
|
+
true));
|
|
427
466
|
if (efforts.length === 0)
|
|
428
467
|
return undefined;
|
|
429
|
-
const defaultEffort = first.reasoning.defaultEffort !== undefined
|
|
430
|
-
|
|
468
|
+
const defaultEffort = first.reasoning.defaultEffort !== undefined &&
|
|
469
|
+
efforts.some((effort) => effort.id === first.reasoning?.defaultEffort)
|
|
431
470
|
? first.reasoning.defaultEffort
|
|
432
471
|
: undefined;
|
|
433
|
-
return { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } };
|
|
472
|
+
return { efforts, ...(defaultEffort === undefined ? {} : { defaultEffort }) };
|
|
434
473
|
}
|
|
435
474
|
/** Modalities all members accept; undefined when any member leaves it unknown. */
|
|
436
475
|
function intersectModalities(resolved) {
|
|
437
476
|
const [first, ...rest] = resolved;
|
|
438
477
|
if (first?.inputModalities === undefined)
|
|
439
478
|
return undefined;
|
|
440
|
-
const modalities = first.inputModalities.filter(modality => rest.every(info => info.inputModalities?.includes(modality) === true));
|
|
479
|
+
const modalities = first.inputModalities.filter((modality) => rest.every((info) => info.inputModalities?.includes(modality) === true));
|
|
441
480
|
// An empty intersection would declare negative capability ("accepts
|
|
442
481
|
// nothing"); report unknown instead — the serving member enforces its own
|
|
443
482
|
// limits at request time.
|
|
@@ -67,7 +67,12 @@ export function durationMs(text) {
|
|
|
67
67
|
// Sticky: every component must abut the previous one, so trailing or
|
|
68
68
|
// interleaved junk ("6m0s later") fails the length check below.
|
|
69
69
|
const pattern = /(\d+(?:\.\d+)?)(ms|h|m|s)/y;
|
|
70
|
-
const units = {
|
|
70
|
+
const units = {
|
|
71
|
+
h: 3_600_000,
|
|
72
|
+
m: 60_000,
|
|
73
|
+
s: 1_000,
|
|
74
|
+
ms: 1,
|
|
75
|
+
};
|
|
71
76
|
let total = 0;
|
|
72
77
|
let matched = false;
|
|
73
78
|
// A failed sticky exec resets `lastIndex` to zero, so the reached offset is
|
|
@@ -191,7 +196,8 @@ export function resetFromFields(value, keys, now, depth = MAX_BODY_DEPTH) {
|
|
|
191
196
|
return undefined;
|
|
192
197
|
let earliest;
|
|
193
198
|
const consider = (candidate) => {
|
|
194
|
-
if (candidate !== undefined &&
|
|
199
|
+
if (candidate !== undefined &&
|
|
200
|
+
(earliest === undefined || candidate < earliest))
|
|
195
201
|
earliest = candidate;
|
|
196
202
|
};
|
|
197
203
|
if (Array.isArray(value)) {
|
package/lib/providers/replay.js
CHANGED
|
@@ -3,25 +3,37 @@ const REPLAY_KIND = 'subscriptions-account-replay';
|
|
|
3
3
|
/** Bind opaque replay to the concrete account/model behind a route alias. */
|
|
4
4
|
export async function* streamAccountWithReplay(adapter, options, account, model = options.model) {
|
|
5
5
|
const key = createHash('sha256')
|
|
6
|
-
.update(JSON.stringify([options.provider, account, model]))
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
.update(JSON.stringify([options.provider, account, model]))
|
|
7
|
+
.digest('hex');
|
|
8
|
+
const messages = options.messages.map((message) => {
|
|
9
|
+
if (message.role !== 'assistant' ||
|
|
10
|
+
message.source?.replayState === undefined)
|
|
10
11
|
return message;
|
|
11
12
|
const source = message.source;
|
|
12
|
-
if (source.replayState === null ||
|
|
13
|
-
|
|
13
|
+
if (source.replayState === null ||
|
|
14
|
+
typeof source.replayState !== 'object' ||
|
|
15
|
+
!('response' in source.replayState))
|
|
14
16
|
return message;
|
|
15
17
|
const replay = source.replayState;
|
|
16
18
|
const response = replay.response;
|
|
17
|
-
if (response !== null &&
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
if (response !== null &&
|
|
20
|
+
typeof response === 'object' &&
|
|
21
|
+
'kind' in response &&
|
|
22
|
+
response.kind === REPLAY_KIND) {
|
|
23
|
+
if ('version' in response &&
|
|
24
|
+
response.version === 1 &&
|
|
25
|
+
'key' in response &&
|
|
26
|
+
response.key === key &&
|
|
27
|
+
'state' in response) {
|
|
28
|
+
return {
|
|
29
|
+
...message,
|
|
30
|
+
source: {
|
|
31
|
+
...source,
|
|
32
|
+
provider: options.provider,
|
|
33
|
+
model,
|
|
23
34
|
replayState: { ...replay, response: response.state },
|
|
24
|
-
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
25
37
|
}
|
|
26
38
|
const { replayState: _discarded, ...withoutReplay } = source;
|
|
27
39
|
return { ...message, source: withoutReplay };
|
|
@@ -37,12 +49,23 @@ export async function* streamAccountWithReplay(adapter, options, account, model
|
|
|
37
49
|
return { ...message, source: withoutReplay };
|
|
38
50
|
});
|
|
39
51
|
for await (const chunk of adapter.streamAccount({
|
|
40
|
-
...options,
|
|
52
|
+
...options,
|
|
53
|
+
model,
|
|
54
|
+
messages,
|
|
41
55
|
}, account)) {
|
|
42
56
|
if (chunk.type === 'finish' && chunk.replayState !== undefined) {
|
|
43
|
-
yield {
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
yield {
|
|
58
|
+
...chunk,
|
|
59
|
+
replayState: {
|
|
60
|
+
...chunk.replayState,
|
|
61
|
+
response: {
|
|
62
|
+
kind: REPLAY_KIND,
|
|
63
|
+
version: 1,
|
|
64
|
+
key,
|
|
65
|
+
state: chunk.replayState.response,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
46
69
|
}
|
|
47
70
|
else
|
|
48
71
|
yield chunk;
|
|
@@ -34,9 +34,12 @@ export class ResetRedemption {
|
|
|
34
34
|
this.canonical = canonical;
|
|
35
35
|
}
|
|
36
36
|
eligible(usage) {
|
|
37
|
-
const weekly = usage.windows?.find(w => w.kind === 'weekly' && w.scope === undefined);
|
|
38
|
-
if (!weekly ||
|
|
39
|
-
|
|
37
|
+
const weekly = usage.windows?.find((w) => w.kind === 'weekly' && w.scope === undefined);
|
|
38
|
+
if (!weekly ||
|
|
39
|
+
!Number.isFinite(weekly.usedPercent) ||
|
|
40
|
+
weekly.usedPercent < 80 ||
|
|
41
|
+
weekly.resetsAt === undefined ||
|
|
42
|
+
weekly.resetsAt <= Date.now()) {
|
|
40
43
|
throw new Error('Reset blocked: fresh weekly usage must be at least 80%.');
|
|
41
44
|
}
|
|
42
45
|
if (usage.resetCreditsError)
|
|
@@ -54,7 +57,8 @@ export class ResetRedemption {
|
|
|
54
57
|
if (this.busy.has(who) || this.uncertain.has(who))
|
|
55
58
|
throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
|
|
56
59
|
const weeklyUsedPercent = this.eligible(usage);
|
|
57
|
-
const credit = usage.resetCredits
|
|
60
|
+
const credit = usage.resetCredits
|
|
61
|
+
?.filter((c) => c.id && (c.expiresAt === undefined || c.expiresAt > Date.now()))
|
|
58
62
|
.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity))[0];
|
|
59
63
|
if (!credit?.id)
|
|
60
64
|
throw new Error('No available reset credit.');
|
|
@@ -64,7 +68,14 @@ export class ResetRedemption {
|
|
|
64
68
|
const ticket = randomUUID();
|
|
65
69
|
const expiresAt = Date.now() + 60_000;
|
|
66
70
|
this.tickets.set(ticket, { account: who, creditId: credit.id, expiresAt });
|
|
67
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
ticket,
|
|
73
|
+
expiresAt,
|
|
74
|
+
weeklyUsedPercent,
|
|
75
|
+
...(credit.expiresAt === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { creditExpiresAt: credit.expiresAt }),
|
|
78
|
+
};
|
|
68
79
|
}
|
|
69
80
|
async redeem(account, ticket, signal) {
|
|
70
81
|
const who = await this.canonical(account, signal);
|
|
@@ -82,7 +93,8 @@ export class ResetRedemption {
|
|
|
82
93
|
if (pending.expiresAt <= Date.now())
|
|
83
94
|
throw new Error('Confirmation expired. Start again.');
|
|
84
95
|
this.eligible(usage);
|
|
85
|
-
if (!usage.resetCredits?.some(c => c.id === pending.creditId &&
|
|
96
|
+
if (!usage.resetCredits?.some((c) => c.id === pending.creditId &&
|
|
97
|
+
(c.expiresAt === undefined || c.expiresAt > Date.now())))
|
|
86
98
|
throw new Error('The selected reset is no longer available.');
|
|
87
99
|
// Mark before submission: no blind retry after transport or response ambiguity.
|
|
88
100
|
this.uncertain.add(who);
|