dsh-subscriptions 0.1.2 → 0.1.4
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 +92 -10
- package/lib/auth/rpc.d.ts +3 -1
- package/lib/auth/rpc.js +10 -1
- package/lib/client/ExternalUsageCards.d.ts +11 -1
- package/lib/client/ExternalUsageCards.js +18 -3
- package/lib/client/ProviderAccountManager.js +34 -41
- package/lib/client/ProviderModelEditor.js +5 -9
- package/lib/client/SpeedSelect.js +8 -7
- package/lib/client/SubscriptionUsageBadge.d.ts +19 -2
- package/lib/client/SubscriptionUsageBadge.js +65 -9
- package/lib/client/SubscriptionsSection.d.ts +22 -0
- package/lib/client/SubscriptionsSection.js +128 -38
- package/lib/client/UsageBadgeDisplaySetting.js +6 -10
- package/lib/client/UsageMeter.js +11 -6
- package/lib/client/host-contract.d.ts +1 -1
- package/lib/client/host-contract.js +22 -0
- package/lib/client/locales.d.ts +60 -0
- package/lib/client/locales.js +62 -2
- package/lib/client/provider-settings-styles.d.ts +3 -0
- package/lib/client/provider-settings-styles.js +206 -0
- package/lib/client/reset-action.d.ts +14 -0
- package/lib/client/reset-action.js +15 -0
- package/lib/client/reset-credits-view.d.ts +53 -0
- package/lib/client/reset-credits-view.js +103 -0
- package/lib/client/usage-badge-preferences.d.ts +7 -2
- package/lib/client/usage-badge-preferences.js +3 -2
- package/lib/client/usage-pace.d.ts +14 -1
- package/lib/client/usage-pace.js +14 -1
- package/lib/client.js +1036 -334
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +9 -0
- package/lib/index.js +1120 -602
- package/lib/providers/account-preferences.js +3 -2
- package/lib/providers/catalog-store.d.ts +3 -2
- package/lib/providers/catalog-store.js +57 -26
- package/lib/providers/claude.d.ts +22 -3
- package/lib/providers/claude.js +113 -15
- package/lib/providers/codex.d.ts +15 -2
- package/lib/providers/codex.js +72 -4
- package/lib/providers/common.d.ts +25 -0
- package/lib/providers/common.js +22 -18
- package/lib/providers/copilot.d.ts +2 -1
- package/lib/providers/copilot.js +10 -3
- package/lib/providers/grok.d.ts +2 -2
- package/lib/providers/grok.js +2 -2
- package/lib/providers/minimax-usage.d.ts +22 -1
- package/lib/providers/minimax-usage.js +37 -7
- package/lib/providers/pool-health.d.ts +2 -0
- package/lib/providers/pool-health.js +9 -0
- package/lib/providers/pool-usage.d.ts +13 -1
- package/lib/providers/pool-usage.js +39 -7
- package/lib/providers/pool.d.ts +7 -5
- package/lib/providers/pool.js +89 -12
- package/lib/providers/rate-limit.d.ts +0 -14
- package/lib/providers/rate-limit.js +0 -26
- package/lib/providers/replay.d.ts +4 -0
- package/lib/providers/replay.js +50 -0
- package/lib/providers/reset-redemption.d.ts +51 -0
- package/lib/providers/reset-redemption.js +102 -0
- package/lib/translate/anthropic.d.ts +21 -2
- package/lib/translate/anthropic.js +35 -11
- package/lib/translate/antigravity.js +13 -6
- package/lib/translate/chat-completions.js +1 -1
- package/lib/translate/resolved.d.ts +21 -0
- package/lib/translate/resolved.js +63 -3
- package/lib/translate/responses.js +8 -6
- package/lib/translate/sse.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const REPLAY_KIND = 'subscriptions-account-replay';
|
|
3
|
+
/** Bind opaque replay to the concrete account/model behind a route alias. */
|
|
4
|
+
export async function* streamAccountWithReplay(adapter, options, account, model = options.model) {
|
|
5
|
+
const key = createHash('sha256')
|
|
6
|
+
.update(JSON.stringify([options.provider, account, model])).digest('hex');
|
|
7
|
+
const messages = options.messages.map(message => {
|
|
8
|
+
if (message.role !== 'assistant'
|
|
9
|
+
|| message.source?.replayState === undefined)
|
|
10
|
+
return message;
|
|
11
|
+
const source = message.source;
|
|
12
|
+
if (source.replayState === null || typeof source.replayState !== 'object'
|
|
13
|
+
|| !('response' in source.replayState))
|
|
14
|
+
return message;
|
|
15
|
+
const replay = source.replayState;
|
|
16
|
+
const response = replay.response;
|
|
17
|
+
if (response !== null && typeof response === 'object'
|
|
18
|
+
&& 'kind' in response && response.kind === REPLAY_KIND) {
|
|
19
|
+
if ('version' in response && response.version === 1
|
|
20
|
+
&& 'key' in response && response.key === key && 'state' in response) {
|
|
21
|
+
return { ...message, source: {
|
|
22
|
+
...source, provider: options.provider, model,
|
|
23
|
+
replayState: { ...replay, response: response.state },
|
|
24
|
+
} };
|
|
25
|
+
}
|
|
26
|
+
const { replayState: _discarded, ...withoutReplay } = source;
|
|
27
|
+
return { ...message, source: withoutReplay };
|
|
28
|
+
}
|
|
29
|
+
// Legacy replay has no originating-account identity. Preserve direct
|
|
30
|
+
// histories when provider/model match, as upstream does; switching
|
|
31
|
+
// accounts mid-session can therefore reuse the previous account's signed
|
|
32
|
+
// request prefix. An alias cannot identify the legacy concrete member.
|
|
33
|
+
if (source.provider === options.provider && source.model === model) {
|
|
34
|
+
return message;
|
|
35
|
+
}
|
|
36
|
+
const { replayState: _discarded, ...withoutReplay } = source;
|
|
37
|
+
return { ...message, source: withoutReplay };
|
|
38
|
+
});
|
|
39
|
+
for await (const chunk of adapter.streamAccount({
|
|
40
|
+
...options, model, messages,
|
|
41
|
+
}, account)) {
|
|
42
|
+
if (chunk.type === 'finish' && chunk.replayState !== undefined) {
|
|
43
|
+
yield { ...chunk, replayState: { ...chunk.replayState, response: {
|
|
44
|
+
kind: REPLAY_KIND, version: 1, key, state: chunk.replayState.response,
|
|
45
|
+
} } };
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
yield chunk;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ProviderUsage } from './common.js';
|
|
2
|
+
export interface ResetConfirmation {
|
|
3
|
+
ticket: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
creditExpiresAt?: number;
|
|
6
|
+
weeklyUsedPercent: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Manual-only redemption with single-use tickets. An ambiguous submission parks
|
|
10
|
+
* the account to prevent an immediate blind retry. This block is process-local,
|
|
11
|
+
* not a durable rate limit: restarting the host clears it. Re-check credits and
|
|
12
|
+
* limits in Codex before retrying; a lost success response may have spent a
|
|
13
|
+
* credit already, and another submission can spend another credit.
|
|
14
|
+
* Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
|
|
15
|
+
*/
|
|
16
|
+
export declare class ResetRedemption {
|
|
17
|
+
private readonly read;
|
|
18
|
+
private readonly consume;
|
|
19
|
+
private readonly invalidate;
|
|
20
|
+
/**
|
|
21
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
22
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
23
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
24
|
+
* workspace id — and all of them resolve to one stored account.
|
|
25
|
+
*
|
|
26
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
27
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
28
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
29
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
30
|
+
*/
|
|
31
|
+
private readonly canonical;
|
|
32
|
+
private tickets;
|
|
33
|
+
private busy;
|
|
34
|
+
private uncertain;
|
|
35
|
+
constructor(read: (account: string, signal: AbortSignal) => Promise<ProviderUsage>, consume: (account: string, creditId: string, requestId: string, signal: AbortSignal) => Promise<void>, invalidate: (account: string) => void,
|
|
36
|
+
/**
|
|
37
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
38
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
39
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
40
|
+
* workspace id — and all of them resolve to one stored account.
|
|
41
|
+
*
|
|
42
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
43
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
44
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
45
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
46
|
+
*/
|
|
47
|
+
canonical: (account: string, signal: AbortSignal) => Promise<string>);
|
|
48
|
+
private eligible;
|
|
49
|
+
prepare(account: string, signal: AbortSignal): Promise<ResetConfirmation>;
|
|
50
|
+
redeem(account: string, ticket: string, signal: AbortSignal): Promise<void>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Manual-only redemption with single-use tickets. An ambiguous submission parks
|
|
4
|
+
* the account to prevent an immediate blind retry. This block is process-local,
|
|
5
|
+
* not a durable rate limit: restarting the host clears it. Re-check credits and
|
|
6
|
+
* limits in Codex before retrying; a lost success response may have spent a
|
|
7
|
+
* credit already, and another submission can spend another credit.
|
|
8
|
+
* Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
|
|
9
|
+
*/
|
|
10
|
+
export class ResetRedemption {
|
|
11
|
+
read;
|
|
12
|
+
consume;
|
|
13
|
+
invalidate;
|
|
14
|
+
canonical;
|
|
15
|
+
tickets = new Map();
|
|
16
|
+
busy = new Set();
|
|
17
|
+
uncertain = new Set();
|
|
18
|
+
constructor(read, consume, invalidate,
|
|
19
|
+
/**
|
|
20
|
+
* Stable identity for one real account: the same key the usage cache uses,
|
|
21
|
+
* so the guards and the cache invalidate together. The RPC carries whatever
|
|
22
|
+
* reference the browser holds — a canonical id, a legacy key, an email, a
|
|
23
|
+
* workspace id — and all of them resolve to one stored account.
|
|
24
|
+
*
|
|
25
|
+
* It must not fall back to the raw reference on failure. A fallback would
|
|
26
|
+
* re-key the state by whatever happened to arrive, so a transient lookup
|
|
27
|
+
* error could let a later alias past an uncertain park. Resolution here
|
|
28
|
+
* reads the local credential store only, so failing is safe and honest.
|
|
29
|
+
*/
|
|
30
|
+
canonical) {
|
|
31
|
+
this.read = read;
|
|
32
|
+
this.consume = consume;
|
|
33
|
+
this.invalidate = invalidate;
|
|
34
|
+
this.canonical = canonical;
|
|
35
|
+
}
|
|
36
|
+
eligible(usage) {
|
|
37
|
+
const weekly = usage.windows?.find(w => w.kind === 'weekly' && w.scope === undefined);
|
|
38
|
+
if (!weekly || !Number.isFinite(weekly.usedPercent) || weekly.usedPercent < 80
|
|
39
|
+
|| weekly.resetsAt === undefined || weekly.resetsAt <= Date.now()) {
|
|
40
|
+
throw new Error('Reset blocked: fresh weekly usage must be at least 80%.');
|
|
41
|
+
}
|
|
42
|
+
if (usage.resetCreditsError)
|
|
43
|
+
throw new Error('Reset credits could not be verified.');
|
|
44
|
+
return weekly.usedPercent;
|
|
45
|
+
}
|
|
46
|
+
async prepare(account, signal) {
|
|
47
|
+
const who = await this.canonical(account, signal);
|
|
48
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
49
|
+
throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
|
|
50
|
+
const usage = await this.read(account, signal);
|
|
51
|
+
// Re-check after the read. A confirmation prepared while another client was
|
|
52
|
+
// redeeming would otherwise be minted against a park that has since been
|
|
53
|
+
// cleared, letting the next click spend a second credit.
|
|
54
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
55
|
+
throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
|
|
56
|
+
const weeklyUsedPercent = this.eligible(usage);
|
|
57
|
+
const credit = usage.resetCredits?.filter(c => c.id && (c.expiresAt === undefined || c.expiresAt > Date.now()))
|
|
58
|
+
.sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity))[0];
|
|
59
|
+
if (!credit?.id)
|
|
60
|
+
throw new Error('No available reset credit.');
|
|
61
|
+
for (const [key, value] of this.tickets)
|
|
62
|
+
if (value.account === who || value.expiresAt <= Date.now())
|
|
63
|
+
this.tickets.delete(key);
|
|
64
|
+
const ticket = randomUUID();
|
|
65
|
+
const expiresAt = Date.now() + 60_000;
|
|
66
|
+
this.tickets.set(ticket, { account: who, creditId: credit.id, expiresAt });
|
|
67
|
+
return { ticket, expiresAt, weeklyUsedPercent, ...(credit.expiresAt === undefined ? {} : { creditExpiresAt: credit.expiresAt }) };
|
|
68
|
+
}
|
|
69
|
+
async redeem(account, ticket, signal) {
|
|
70
|
+
const who = await this.canonical(account, signal);
|
|
71
|
+
const pending = this.tickets.get(ticket);
|
|
72
|
+
this.tickets.delete(ticket);
|
|
73
|
+
if (!pending || pending.account !== who || pending.expiresAt <= Date.now())
|
|
74
|
+
throw new Error('Confirmation expired. Start again.');
|
|
75
|
+
if (this.busy.has(who) || this.uncertain.has(who))
|
|
76
|
+
throw new Error('Reset blocked: operation pending or uncertain.');
|
|
77
|
+
this.busy.add(who);
|
|
78
|
+
try {
|
|
79
|
+
const usage = await this.read(account, signal);
|
|
80
|
+
// The ticket bounds how long the confirmation counts. A read that
|
|
81
|
+
// outlives it must not go on to submit, so this comes first.
|
|
82
|
+
if (pending.expiresAt <= Date.now())
|
|
83
|
+
throw new Error('Confirmation expired. Start again.');
|
|
84
|
+
this.eligible(usage);
|
|
85
|
+
if (!usage.resetCredits?.some(c => c.id === pending.creditId && (c.expiresAt === undefined || c.expiresAt > Date.now())))
|
|
86
|
+
throw new Error('The selected reset is no longer available.');
|
|
87
|
+
// Mark before submission: no blind retry after transport or response ambiguity.
|
|
88
|
+
this.uncertain.add(who);
|
|
89
|
+
try {
|
|
90
|
+
await this.consume(account, pending.creditId, randomUUID(), signal);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error('Reset outcome could not be confirmed. Do not retry or restart to bypass this block; verify credits and limits in Codex first.');
|
|
94
|
+
}
|
|
95
|
+
this.uncertain.delete(who);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.invalidate(who);
|
|
99
|
+
this.busy.delete(who);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -32,6 +32,23 @@ export declare const CACHE_BLOCK_STRIDE = 15;
|
|
|
32
32
|
* blocks before a read is lost.
|
|
33
33
|
*/
|
|
34
34
|
export declare const MESSAGE_CACHE_BREAKPOINTS = 3;
|
|
35
|
+
/**
|
|
36
|
+
* How long Anthropic keeps a cache entry after its last use: five minutes
|
|
37
|
+
* (the API default) or one hour. A one-hour entry is written at twice the base
|
|
38
|
+
* input price instead of 1.25×, and reads cost the same either way.
|
|
39
|
+
*/
|
|
40
|
+
export type PromptCacheTtl = '5m' | '1h';
|
|
41
|
+
/** The API default; a marker at this TTL carries no `ttl` field at all. */
|
|
42
|
+
export declare const DEFAULT_PROMPT_CACHE_TTL: PromptCacheTtl;
|
|
43
|
+
/**
|
|
44
|
+
* Build one `cache_control` marker.
|
|
45
|
+
*
|
|
46
|
+
* Five minutes is spelled by omitting `ttl`, so a request that does not opt in
|
|
47
|
+
* stays byte-identical to what earlier releases sent.
|
|
48
|
+
* @param ttl - cache lifetime; defaults to five minutes.
|
|
49
|
+
* @returns a fresh marker object (callers attach it to one block each).
|
|
50
|
+
*/
|
|
51
|
+
export declare function cacheControl(ttl?: PromptCacheTtl): Record<string, unknown>;
|
|
35
52
|
/** One Anthropic request message. */
|
|
36
53
|
export interface AnthropicMessage {
|
|
37
54
|
role: 'user' | 'assistant';
|
|
@@ -62,17 +79,19 @@ export declare function toAnthropicMessages(messages: readonly TranslatableMessa
|
|
|
62
79
|
* Marks are counted across the flattened block sequence, not per message,
|
|
63
80
|
* because the lookback window Anthropic walks counts blocks the same way.
|
|
64
81
|
* @param messages - assembled Anthropic messages, marked in place.
|
|
82
|
+
* @param ttl - cache lifetime for every mark; defaults to five minutes.
|
|
65
83
|
*/
|
|
66
|
-
export declare function markMessageCache(messages: readonly AnthropicMessage[]): void;
|
|
84
|
+
export declare function markMessageCache(messages: readonly AnthropicMessage[], ttl?: PromptCacheTtl): void;
|
|
67
85
|
/**
|
|
68
86
|
* Build the Anthropic `system` array: the mandatory Claude Code identity
|
|
69
87
|
* block, then the explicit system prompt, then any system-role messages.
|
|
70
88
|
* @param system - explicit system prompt, when set.
|
|
71
89
|
* @param messages - conversation messages; the system-role text preceding the
|
|
72
90
|
* conversation is appended, and a later one is left to {@link toAnthropicMessages}.
|
|
91
|
+
* @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
|
|
73
92
|
* @returns the system content blocks.
|
|
74
93
|
*/
|
|
75
|
-
export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[]): Record<string, unknown>[];
|
|
94
|
+
export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[], ttl?: PromptCacheTtl): Record<string, unknown>[];
|
|
76
95
|
/**
|
|
77
96
|
* Map harness tool schemas to Anthropic tools, in name order.
|
|
78
97
|
*
|
|
@@ -32,6 +32,19 @@ export const CACHE_BLOCK_STRIDE = 15;
|
|
|
32
32
|
* blocks before a read is lost.
|
|
33
33
|
*/
|
|
34
34
|
export const MESSAGE_CACHE_BREAKPOINTS = 3;
|
|
35
|
+
/** The API default; a marker at this TTL carries no `ttl` field at all. */
|
|
36
|
+
export const DEFAULT_PROMPT_CACHE_TTL = '5m';
|
|
37
|
+
/**
|
|
38
|
+
* Build one `cache_control` marker.
|
|
39
|
+
*
|
|
40
|
+
* Five minutes is spelled by omitting `ttl`, so a request that does not opt in
|
|
41
|
+
* stays byte-identical to what earlier releases sent.
|
|
42
|
+
* @param ttl - cache lifetime; defaults to five minutes.
|
|
43
|
+
* @returns a fresh marker object (callers attach it to one block each).
|
|
44
|
+
*/
|
|
45
|
+
export function cacheControl(ttl = DEFAULT_PROMPT_CACHE_TTL) {
|
|
46
|
+
return ttl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
|
|
47
|
+
}
|
|
35
48
|
/** Preserve native image blocks, retaining the existing text-only wire shape. */
|
|
36
49
|
function toolResultContent(block) {
|
|
37
50
|
if (!block.content.some(part => part.type === 'image' && 'dataBase64' in part)) {
|
|
@@ -217,14 +230,15 @@ export function toAnthropicMessages(messages) {
|
|
|
217
230
|
* Marks are counted across the flattened block sequence, not per message,
|
|
218
231
|
* because the lookback window Anthropic walks counts blocks the same way.
|
|
219
232
|
* @param messages - assembled Anthropic messages, marked in place.
|
|
233
|
+
* @param ttl - cache lifetime for every mark; defaults to five minutes.
|
|
220
234
|
*/
|
|
221
|
-
export function markMessageCache(messages) {
|
|
235
|
+
export function markMessageCache(messages, ttl) {
|
|
222
236
|
const blocks = messages.flatMap(message => message.content);
|
|
223
237
|
for (let mark = 0; mark < MESSAGE_CACHE_BREAKPOINTS; mark++) {
|
|
224
238
|
const at = blocks.length - 1 - mark * CACHE_BLOCK_STRIDE;
|
|
225
239
|
if (at < 0)
|
|
226
240
|
return;
|
|
227
|
-
blocks[at].cache_control =
|
|
241
|
+
blocks[at].cache_control = cacheControl(ttl);
|
|
228
242
|
}
|
|
229
243
|
}
|
|
230
244
|
/**
|
|
@@ -233,9 +247,10 @@ export function markMessageCache(messages) {
|
|
|
233
247
|
* @param system - explicit system prompt, when set.
|
|
234
248
|
* @param messages - conversation messages; the system-role text preceding the
|
|
235
249
|
* conversation is appended, and a later one is left to {@link toAnthropicMessages}.
|
|
250
|
+
* @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
|
|
236
251
|
* @returns the system content blocks.
|
|
237
252
|
*/
|
|
238
|
-
export function toAnthropicSystem(system, messages) {
|
|
253
|
+
export function toAnthropicSystem(system, messages, ttl) {
|
|
239
254
|
const blocks = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
|
|
240
255
|
if (system !== undefined && system.length > 0)
|
|
241
256
|
blocks.push({ type: 'text', text: system });
|
|
@@ -249,7 +264,11 @@ export function toAnthropicSystem(system, messages) {
|
|
|
249
264
|
// `tools` renders ahead of `system`, so this one marker caches both. It is
|
|
250
265
|
// deliberately separate from the message marks: a tool_choice or thinking
|
|
251
266
|
// change invalidates the messages tier only, and this entry survives it.
|
|
252
|
-
|
|
267
|
+
//
|
|
268
|
+
// Anthropic processes `tools`, `system`, then `messages`, and rejects a
|
|
269
|
+
// one-hour mark that follows a five-minute one (HTTP 400). This mark and the
|
|
270
|
+
// message marks therefore share one TTL, which can never hit that ordering.
|
|
271
|
+
blocks[blocks.length - 1].cache_control = cacheControl(ttl);
|
|
253
272
|
return blocks;
|
|
254
273
|
}
|
|
255
274
|
/**
|
|
@@ -294,16 +313,21 @@ function closeBlock(block) {
|
|
|
294
313
|
* @returns the mapped error.
|
|
295
314
|
*/
|
|
296
315
|
export function anthropicFailure(error) {
|
|
316
|
+
// Safe here means this diagnostic path carries no provider-controlled free text,
|
|
317
|
+
// not that no secret can exist anywhere. Raw values remain local classification inputs.
|
|
297
318
|
const type = error?.type ?? 'unknown_error';
|
|
298
319
|
const message = error?.message ?? `Anthropic reported ${type}`;
|
|
320
|
+
let classification = 'SERVER';
|
|
299
321
|
if (type === 'invalid_request_error' && /prompt is too long/i.test(message)) {
|
|
300
|
-
|
|
322
|
+
classification = CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
323
|
+
}
|
|
324
|
+
else if (type === 'rate_limit_error') {
|
|
325
|
+
classification = 'RATE_LIMIT';
|
|
326
|
+
}
|
|
327
|
+
else if (type === 'authentication_error') {
|
|
328
|
+
classification = 'AUTH';
|
|
301
329
|
}
|
|
302
|
-
|
|
303
|
-
return new LlmError(message, 'RATE_LIMIT');
|
|
304
|
-
if (type === 'authentication_error')
|
|
305
|
-
return new LlmError(message, 'AUTH');
|
|
306
|
-
return new LlmError(message, 'SERVER');
|
|
330
|
+
return new LlmError(`Anthropic failure (${classification}): [provider response body omitted]`, classification);
|
|
307
331
|
}
|
|
308
332
|
/**
|
|
309
333
|
* Push-model Anthropic SSE translator: feed each parsed event object to
|
|
@@ -505,7 +529,7 @@ export async function* streamAnthropic(stream, onActivity) {
|
|
|
505
529
|
event = JSON.parse(sseEvent.data);
|
|
506
530
|
}
|
|
507
531
|
catch {
|
|
508
|
-
throw new LlmError(
|
|
532
|
+
throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
|
|
509
533
|
}
|
|
510
534
|
yield* translator.push(event);
|
|
511
535
|
if (translator.terminated)
|
|
@@ -15,9 +15,11 @@ import { antigravityToolParameters } from './antigravity-schema.js';
|
|
|
15
15
|
* `FunctionResponse.response` field requires. That field maps to a singular
|
|
16
16
|
* protobuf `Struct`, so a bare array, string, number or boolean is rejected
|
|
17
17
|
* by the endpoint with "Proto field is not repeating, cannot start list".
|
|
18
|
-
*
|
|
18
|
+
* Non-array JSON objects use their own fields; every other JSON value is
|
|
19
19
|
* wrapped in the same "output" envelope the non-JSON path already uses --
|
|
20
20
|
* the shape other Cloud Code Assist clients send for every tool result.
|
|
21
|
+
* Failed results carry top-level `isError: true`, overriding a payload field;
|
|
22
|
+
* successful results retain their existing serialized shape.
|
|
21
23
|
*/
|
|
22
24
|
function toolResultValue(block) {
|
|
23
25
|
const text = block.content.map(part => part.type === 'text' ? part.text : '').join('');
|
|
@@ -28,9 +30,11 @@ function toolResultValue(block) {
|
|
|
28
30
|
catch {
|
|
29
31
|
return { output: text, ...block.isError === true ? { isError: true } : {} };
|
|
30
32
|
}
|
|
31
|
-
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
34
|
+
const value = parsed;
|
|
35
|
+
return block.isError === true ? { ...value, isError: true } : value;
|
|
36
|
+
}
|
|
37
|
+
return { output: parsed, ...block.isError === true ? { isError: true } : {} };
|
|
34
38
|
}
|
|
35
39
|
/** Safely read per-block replay metadata emitted by this adapter. */
|
|
36
40
|
function replayBlocks(message, model) {
|
|
@@ -88,7 +92,10 @@ export function toAntigravityContents(messages, model) {
|
|
|
88
92
|
const part = { functionResponse: {
|
|
89
93
|
id,
|
|
90
94
|
name: callNames.get(id) ?? '',
|
|
91
|
-
response: toolResultValue({
|
|
95
|
+
response: toolResultValue({
|
|
96
|
+
type: 'tool-result', toolCallId: ToolCallId(id), content: message.content,
|
|
97
|
+
...message.isError === undefined ? {} : { isError: message.isError },
|
|
98
|
+
}),
|
|
92
99
|
} };
|
|
93
100
|
const previous = out.at(-1);
|
|
94
101
|
if (previous?.role === 'user')
|
|
@@ -350,7 +357,7 @@ export async function* streamAntigravity(stream, onActivity) {
|
|
|
350
357
|
parsed = JSON.parse(event.data);
|
|
351
358
|
}
|
|
352
359
|
catch {
|
|
353
|
-
throw new LlmError(
|
|
360
|
+
throw new LlmError('malformed Antigravity SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
|
|
354
361
|
}
|
|
355
362
|
yield* translator.push(parsed);
|
|
356
363
|
if (translator.terminated)
|
|
@@ -369,7 +369,7 @@ export async function* streamChatCompletions(stream, onActivity) {
|
|
|
369
369
|
event = JSON.parse(sseEvent.data);
|
|
370
370
|
}
|
|
371
371
|
catch {
|
|
372
|
-
throw new LlmError(
|
|
372
|
+
throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
|
|
373
373
|
}
|
|
374
374
|
yield* translator.push(event);
|
|
375
375
|
if (translator.terminated)
|
|
@@ -50,6 +50,27 @@ export interface ImageRequestLimit {
|
|
|
50
50
|
/** Encoded-byte target before base64 expansion. */
|
|
51
51
|
maxBytes: number;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Failure code a host's image-offload executor answers by replacing the oldest
|
|
55
|
+
* retained images with text and retrying (`IMAGE_OFFLOAD_REQUIRED_CODE` in
|
|
56
|
+
* dsh-llm 0.2+). Spelled out so the build links against older hosts too.
|
|
57
|
+
*/
|
|
58
|
+
export declare const IMAGE_OFFLOAD_REQUIRED = "IMAGE_OFFLOAD_REQUIRED";
|
|
59
|
+
/**
|
|
60
|
+
* Whether the installed host records image offloads. Hosts that predate the
|
|
61
|
+
* executor never mark images `offloaded`, so asking them to would fail every
|
|
62
|
+
* later request instead of shrinking it.
|
|
63
|
+
*/
|
|
64
|
+
export declare function hostSupportsImageOffload(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* How many of the oldest images still sent inline must be offloaded before
|
|
67
|
+
* the resolved request fits `maxBase64Bytes` of image data; zero when it fits.
|
|
68
|
+
* Images are counted depth-first in request order, skipping assistant
|
|
69
|
+
* messages, the same order the host's executor selects occurrences in.
|
|
70
|
+
* @param messages - the resolved request, after {@link resolveImages}.
|
|
71
|
+
* @param maxBase64Bytes - the route's budget for all inline image data.
|
|
72
|
+
*/
|
|
73
|
+
export declare function requiredImageOffloadCount(messages: readonly TranslatableMessage[], maxBase64Bytes: number): number;
|
|
53
74
|
/**
|
|
54
75
|
* The request projection for one oversized image, or undefined when it fits.
|
|
55
76
|
* Hosts before DSH 0.1.7 read a pixel budget (`maxPixels`), later ones the
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* base64 data.
|
|
7
7
|
*/
|
|
8
8
|
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import * as llm from '@deepseek-ai/dsh-llm';
|
|
9
10
|
/**
|
|
10
11
|
* Wires with text-only tool outputs receive images in a following user turn.
|
|
11
12
|
* Defer that turn until all consecutive user messages have been processed:
|
|
@@ -42,6 +43,52 @@ export function withToolResultImages(messages) {
|
|
|
42
43
|
flush();
|
|
43
44
|
return out;
|
|
44
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Failure code a host's image-offload executor answers by replacing the oldest
|
|
48
|
+
* retained images with text and retrying (`IMAGE_OFFLOAD_REQUIRED_CODE` in
|
|
49
|
+
* dsh-llm 0.2+). Spelled out so the build links against older hosts too.
|
|
50
|
+
*/
|
|
51
|
+
export const IMAGE_OFFLOAD_REQUIRED = 'IMAGE_OFFLOAD_REQUIRED';
|
|
52
|
+
/**
|
|
53
|
+
* Whether the installed host records image offloads. Hosts that predate the
|
|
54
|
+
* executor never mark images `offloaded`, so asking them to would fail every
|
|
55
|
+
* later request instead of shrinking it.
|
|
56
|
+
*/
|
|
57
|
+
export function hostSupportsImageOffload() {
|
|
58
|
+
const exports = llm;
|
|
59
|
+
return exports['IMAGE_OFFLOAD_REQUIRED_CODE'] === IMAGE_OFFLOAD_REQUIRED
|
|
60
|
+
&& typeof exports['offloadedImageText'] === 'function';
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* How many of the oldest images still sent inline must be offloaded before
|
|
64
|
+
* the resolved request fits `maxBase64Bytes` of image data; zero when it fits.
|
|
65
|
+
* Images are counted depth-first in request order, skipping assistant
|
|
66
|
+
* messages, the same order the host's executor selects occurrences in.
|
|
67
|
+
* @param messages - the resolved request, after {@link resolveImages}.
|
|
68
|
+
* @param maxBase64Bytes - the route's budget for all inline image data.
|
|
69
|
+
*/
|
|
70
|
+
export function requiredImageOffloadCount(messages, maxBase64Bytes) {
|
|
71
|
+
const lengths = [];
|
|
72
|
+
const visit = (blocks) => {
|
|
73
|
+
for (const block of blocks) {
|
|
74
|
+
if (block.type === 'image' && 'dataBase64' in block)
|
|
75
|
+
lengths.push(block.dataBase64.length);
|
|
76
|
+
else if (block.type === 'tool-result')
|
|
77
|
+
visit(block.content);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
for (const message of messages) {
|
|
81
|
+
if (message.role !== 'assistant')
|
|
82
|
+
visit(message.content);
|
|
83
|
+
}
|
|
84
|
+
let excess = lengths.reduce((sum, bytes) => sum + bytes, 0) - maxBase64Bytes;
|
|
85
|
+
let count = 0;
|
|
86
|
+
while (excess > 0 && count < lengths.length) {
|
|
87
|
+
excess -= lengths[count];
|
|
88
|
+
count += 1;
|
|
89
|
+
}
|
|
90
|
+
return count;
|
|
91
|
+
}
|
|
45
92
|
/**
|
|
46
93
|
* The request projection for one oversized image, or undefined when it fits.
|
|
47
94
|
* Hosts before DSH 0.1.7 read a pixel budget (`maxPixels`), later ones the
|
|
@@ -70,8 +117,11 @@ export function imageRequestTarget(ref, limit) {
|
|
|
70
117
|
* @returns the same messages with image blocks resolved for the translators.
|
|
71
118
|
*/
|
|
72
119
|
export async function resolveImages(messages, attachments, signal, limit) {
|
|
73
|
-
const
|
|
74
|
-
|
|
120
|
+
const hasRetainedImage = (block) => block.type === 'image'
|
|
121
|
+
? !('offloaded' in block && block.offloaded === true)
|
|
122
|
+
: block.type === 'tool-result' && block.content.some(hasRetainedImage);
|
|
123
|
+
if (attachments === undefined
|
|
124
|
+
&& messages.some(message => message.content.some(hasRetainedImage))) {
|
|
75
125
|
throw new LlmError('dsh-subscriptions: the request carries an image but no attachments service is mounted; '
|
|
76
126
|
+ 'image input requires the harness attachment store', 'UNSUPPORTED');
|
|
77
127
|
}
|
|
@@ -94,8 +144,18 @@ export async function resolveImages(messages, attachments, signal, limit) {
|
|
|
94
144
|
return { data: stored.data, mediaType: stored.ref.mediaType, ref: stored.ref };
|
|
95
145
|
};
|
|
96
146
|
const resolveBlock = async (block) => {
|
|
97
|
-
if (block.type
|
|
147
|
+
if (block.type === 'tool-result') {
|
|
148
|
+
return [{ ...block, content: (await Promise.all(block.content.map(resolveBlock))).flat() }];
|
|
149
|
+
}
|
|
150
|
+
if (block.type !== 'image' || 'dataBase64' in block)
|
|
98
151
|
return [block];
|
|
152
|
+
if ('offloaded' in block && block.offloaded === true) {
|
|
153
|
+
// The host owns omission decisions. Never restore offloaded bytes, even
|
|
154
|
+
// when the original attachment is unreadable or no store is mounted.
|
|
155
|
+
const format = llm.offloadedImageText;
|
|
156
|
+
return [{ type: 'text', text: format?.(block.attachment)
|
|
157
|
+
?? `[image omitted to fit request image limits; ${block.attachment.attachmentId}]` }];
|
|
158
|
+
}
|
|
99
159
|
const { data, mediaType: sentType, ref } = await readForRequest(block.attachment);
|
|
100
160
|
const { attachmentId, mediaType, bytes, width, height, name } = ref;
|
|
101
161
|
return [{
|
|
@@ -168,15 +168,17 @@ export function mapResponsesUsage(usage) {
|
|
|
168
168
|
* @returns the mapped error (context overflow, quota, otherwise SERVER).
|
|
169
169
|
*/
|
|
170
170
|
export function responsesFailure(code, message) {
|
|
171
|
-
|
|
171
|
+
// Safe here means this diagnostic path carries no provider-controlled free text,
|
|
172
|
+
// not that no secret can exist anywhere. Raw values remain local classification inputs.
|
|
172
173
|
const detail = `${code ?? ''} ${message ?? ''}`;
|
|
174
|
+
let classification = 'SERVER';
|
|
173
175
|
if (code === 'context_window_exceeded' || isContextWindowExceededError(detail)) {
|
|
174
|
-
|
|
176
|
+
classification = CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
175
177
|
}
|
|
176
|
-
if ((code !== undefined && /insufficient|quota/i.test(code)) || isQuotaExceededError(detail)) {
|
|
177
|
-
|
|
178
|
+
else if ((code !== undefined && /insufficient|quota/i.test(code)) || isQuotaExceededError(detail)) {
|
|
179
|
+
classification = QUOTA_EXCEEDED_CODE;
|
|
178
180
|
}
|
|
179
|
-
return new LlmError(
|
|
181
|
+
return new LlmError(`Responses failure (${classification}): [provider response body omitted]`, classification);
|
|
180
182
|
}
|
|
181
183
|
/** Assemble the final ContentBlock for one open block. */
|
|
182
184
|
function closeBlock(block) {
|
|
@@ -396,7 +398,7 @@ export async function* streamResponses(stream, onActivity, transform) {
|
|
|
396
398
|
event = JSON.parse(sseEvent.data);
|
|
397
399
|
}
|
|
398
400
|
catch {
|
|
399
|
-
throw new LlmError(
|
|
401
|
+
throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
|
|
400
402
|
}
|
|
401
403
|
if (transform !== undefined)
|
|
402
404
|
event = transform(event);
|
package/lib/translate/sse.js
CHANGED
|
@@ -51,6 +51,14 @@ export async function* parseSse(stream, onActivity) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
finally {
|
|
54
|
+
// A terminal SSE frame can precede HTTP EOF; returning or decoding failure
|
|
55
|
+
// must close the remaining body rather than leave the connection reading.
|
|
56
|
+
try {
|
|
57
|
+
await reader.cancel();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Cleanup failure must not mask the original stream outcome.
|
|
61
|
+
}
|
|
54
62
|
reader.releaseLock();
|
|
55
63
|
}
|
|
56
64
|
}
|