@moxxy/plugin-provider-anthropic 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +99 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +438 -0
- package/dist/provider.js.map +1 -0
- package/dist/translate.d.ts +76 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +125 -0
- package/dist/translate.js.map +1 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +27 -0
- package/dist/validate.js.map +1 -0
- package/package.json +67 -0
- package/src/index.ts +24 -0
- package/src/oauth-mode.test.ts +151 -0
- package/src/oauth-refresh.test.ts +310 -0
- package/src/provider.test.ts +198 -0
- package/src/provider.ts +547 -0
- package/src/real-api.test.ts +154 -0
- package/src/translate.test.ts +125 -0
- package/src/translate.ts +194 -0
- package/src/validate.test.ts +43 -0
- package/src/validate.ts +39 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import type {
|
|
3
|
+
LLMProvider,
|
|
4
|
+
ModelDescriptor,
|
|
5
|
+
ProviderEvent,
|
|
6
|
+
ProviderRequest,
|
|
7
|
+
StopReason,
|
|
8
|
+
TokenUsage,
|
|
9
|
+
} from '@moxxy/sdk';
|
|
10
|
+
|
|
11
|
+
type MessageStreamParams = Anthropic.Messages.MessageStreamParams;
|
|
12
|
+
type MessageCountTokensParams = Anthropic.Messages.MessageCountTokensParams;
|
|
13
|
+
import { estimateTextTokens, toFriendlyError } from '@moxxy/sdk';
|
|
14
|
+
import { toAnthropicMessages, toAnthropicTools } from './translate.js';
|
|
15
|
+
|
|
16
|
+
export interface AnthropicProviderConfig {
|
|
17
|
+
readonly apiKey?: string;
|
|
18
|
+
readonly baseURL?: string;
|
|
19
|
+
readonly defaultModel?: string;
|
|
20
|
+
readonly client?: Anthropic;
|
|
21
|
+
/**
|
|
22
|
+
* Override the reported provider name (used in error context). Defaults
|
|
23
|
+
* to `'anthropic'`. The `claude-code` subscription provider reuses this
|
|
24
|
+
* class with `name: 'claude-code'` so errors/metering attribute correctly.
|
|
25
|
+
*/
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Override the advertised model catalog. Defaults to the Anthropic catalog
|
|
29
|
+
* ({@link anthropicModels}). An Anthropic-compatible vendor that reuses this
|
|
30
|
+
* class (e.g. `@moxxy/plugin-provider-zai`'s GLM Coding Plan path, which
|
|
31
|
+
* points `baseURL` at z.ai's Anthropic Messages endpoint) passes its own
|
|
32
|
+
* descriptors so context-window lookups (compaction/elision budgets) and
|
|
33
|
+
* capability gating run against the vendor's models, not Claude's.
|
|
34
|
+
*/
|
|
35
|
+
readonly models?: ReadonlyArray<ModelDescriptor>;
|
|
36
|
+
/**
|
|
37
|
+
* OAuth (Claude subscription) mode. When set, the client authenticates
|
|
38
|
+
* with `Authorization: Bearer <oauthToken>` instead of an `x-api-key`,
|
|
39
|
+
* and the request/response is otherwise the standard Messages API. Used
|
|
40
|
+
* by `@moxxy/plugin-provider-claude-code`; the plain `anthropic` provider
|
|
41
|
+
* leaves all of these unset and behaves exactly as before.
|
|
42
|
+
*/
|
|
43
|
+
readonly oauthToken?: string;
|
|
44
|
+
/** `anthropic-beta` values sent with every OAuth-mode request (joined by `,`). */
|
|
45
|
+
readonly oauthBeta?: ReadonlyArray<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Text injected as the FIRST system block in OAuth mode. Claude rejects
|
|
48
|
+
* subscription tokens unless the system prompt leads with the Claude Code
|
|
49
|
+
* identity line, so the provider prepends this ahead of the real system
|
|
50
|
+
* prompt (which follows as the next block).
|
|
51
|
+
*/
|
|
52
|
+
readonly systemPreamble?: string;
|
|
53
|
+
/** Epoch-ms expiry of `oauthToken`, if known — drives proactive refresh. */
|
|
54
|
+
readonly oauthExpiresAt?: number;
|
|
55
|
+
/**
|
|
56
|
+
* Refresh callback. Returns a fresh access token (and its expiry). Invoked
|
|
57
|
+
* proactively just before a request when the current token is near expiry,
|
|
58
|
+
* and once more reactively if a request still comes back 401. Omit for
|
|
59
|
+
* non-refreshable tokens (e.g. a pasted `claude setup-token`).
|
|
60
|
+
*/
|
|
61
|
+
readonly oauthRefresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Hardcoded model catalog (re-exported to @moxxy/plugin-provider-claude-code, which
|
|
65
|
+
// reuses this provider class for the subscription path). Deriving it from the Models
|
|
66
|
+
// API is a larger change (auth + caching) — deliberately deferred (TECH_DEBT P3 #8).
|
|
67
|
+
// Values verified against the current Anthropic model catalog (2026-06): fable-5,
|
|
68
|
+
// opus-4-8, opus-4-7 and opus-4-6 carry a 1M context window with a 128k streaming
|
|
69
|
+
// ceiling; sonnet-4-6 is 1M/64k; haiku-4-5 is 200k/64k. fable-5 is Anthropic's most
|
|
70
|
+
// capable model (always-on reasoning); the loop never sets `temperature`, which
|
|
71
|
+
// fable-5/opus-4-8/4.7 reject — so they stream cleanly here, same as opus-4-7 already did.
|
|
72
|
+
// `supportsReasoning` marks models that accept adaptive thinking (`thinking:
|
|
73
|
+
// {type:'adaptive', display:'summarized'}`) — fable-5/opus-4-8/4-7/4-6 and sonnet-4-6
|
|
74
|
+
// do; haiku-4-5 does not (effort/adaptive-thinking error there), so it stays off.
|
|
75
|
+
export const anthropicModels: ReadonlyArray<ModelDescriptor> = [
|
|
76
|
+
{ id: 'claude-fable-5', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
|
|
77
|
+
{ id: 'claude-opus-4-8', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
|
|
78
|
+
{ id: 'claude-opus-4-7', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
|
|
79
|
+
{ id: 'claude-opus-4-6', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
|
|
80
|
+
{ id: 'claude-sonnet-4-6', contextWindow: 1_000_000, maxOutputTokens: 64_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
|
|
81
|
+
{ id: 'claude-haiku-4-5-20251001', contextWindow: 200_000, maxOutputTokens: 64_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export class AnthropicProvider implements LLMProvider {
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
87
|
+
// Mutable so OAuth-mode refresh can swap in a client carrying the new
|
|
88
|
+
// bearer token; the plain apiKey client never changes after construction.
|
|
89
|
+
private client: Anthropic;
|
|
90
|
+
private readonly defaultModel: string;
|
|
91
|
+
private readonly baseURL?: string;
|
|
92
|
+
// Present only in OAuth (Claude subscription) mode.
|
|
93
|
+
private readonly oauth?: {
|
|
94
|
+
readonly beta: ReadonlyArray<string>;
|
|
95
|
+
readonly systemPreamble?: string;
|
|
96
|
+
readonly refresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
|
|
97
|
+
};
|
|
98
|
+
private oauthToken?: string;
|
|
99
|
+
private oauthExpiresAt?: number;
|
|
100
|
+
|
|
101
|
+
constructor(config: AnthropicProviderConfig = {}) {
|
|
102
|
+
this.name = config.name ?? 'anthropic';
|
|
103
|
+
this.models = config.models ?? anthropicModels;
|
|
104
|
+
this.defaultModel = config.defaultModel ?? 'claude-sonnet-4-6';
|
|
105
|
+
if (config.baseURL) this.baseURL = config.baseURL;
|
|
106
|
+
|
|
107
|
+
if (config.oauthToken) {
|
|
108
|
+
this.oauthToken = config.oauthToken;
|
|
109
|
+
this.oauthExpiresAt = config.oauthExpiresAt;
|
|
110
|
+
this.oauth = {
|
|
111
|
+
beta: config.oauthBeta ?? [],
|
|
112
|
+
...(config.systemPreamble ? { systemPreamble: config.systemPreamble } : {}),
|
|
113
|
+
...(config.oauthRefresh ? { refresh: config.oauthRefresh } : {}),
|
|
114
|
+
};
|
|
115
|
+
this.client = config.client ?? this.makeOauthClient(config.oauthToken);
|
|
116
|
+
} else {
|
|
117
|
+
this.client =
|
|
118
|
+
config.client ??
|
|
119
|
+
new Anthropic({
|
|
120
|
+
apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY,
|
|
121
|
+
...(config.baseURL ? { baseURL: config.baseURL } : {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build an Anthropic client in OAuth (bearer) mode. `apiKey: null` stops
|
|
128
|
+
* the SDK from falling back to `ANTHROPIC_API_KEY` and suppresses the
|
|
129
|
+
* `x-api-key` header (its `apiKeyAuth()` returns `{}` when apiKey is null),
|
|
130
|
+
* so only `Authorization: Bearer <token>` goes out, plus the beta header.
|
|
131
|
+
*/
|
|
132
|
+
private makeOauthClient(token: string): Anthropic {
|
|
133
|
+
const beta = this.oauth?.beta ?? [];
|
|
134
|
+
return new Anthropic({
|
|
135
|
+
apiKey: null,
|
|
136
|
+
authToken: token,
|
|
137
|
+
...(beta.length > 0 ? { defaultHeaders: { 'anthropic-beta': beta.join(',') } } : {}),
|
|
138
|
+
...(this.baseURL ? { baseURL: this.baseURL } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Proactively refresh the bearer token when it's within the skew window. */
|
|
143
|
+
private async ensureFreshOauth(): Promise<void> {
|
|
144
|
+
if (!this.oauth?.refresh) return;
|
|
145
|
+
if (this.oauthExpiresAt === undefined) return;
|
|
146
|
+
if (Date.now() + 60_000 < this.oauthExpiresAt) return;
|
|
147
|
+
await this.refreshOauthNow();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Force a token refresh and rebuild the client with the new bearer. */
|
|
151
|
+
private async refreshOauthNow(): Promise<void> {
|
|
152
|
+
if (!this.oauth?.refresh) throw new Error('no refresh callback');
|
|
153
|
+
const next = await this.oauth.refresh();
|
|
154
|
+
this.oauthToken = next.token;
|
|
155
|
+
this.oauthExpiresAt = next.expiresAt;
|
|
156
|
+
this.client = this.makeOauthClient(next.token);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build the `system` request field. In OAuth mode the Claude Code identity
|
|
161
|
+
* preamble MUST lead, so we always emit block form with the preamble first;
|
|
162
|
+
* the real system prompt follows (carrying the cache breakpoint if set).
|
|
163
|
+
* In apiKey mode the behaviour is unchanged: a bare string, upgraded to a
|
|
164
|
+
* single cache-marked block only when a system cache hint is present.
|
|
165
|
+
*
|
|
166
|
+
* `extraSystem` is `ProviderRequest.system` — the hook-injection side
|
|
167
|
+
* channel (e.g. the memory consolidation nudge), delivered IN ADDITION to
|
|
168
|
+
* the message-derived system prompt. It rides as a separate block AFTER
|
|
169
|
+
* the cache-marked one so volatile per-request text never busts the
|
|
170
|
+
* stable system-prefix cache.
|
|
171
|
+
*/
|
|
172
|
+
private buildSystemParam(
|
|
173
|
+
system: string | undefined,
|
|
174
|
+
cacheSystem: boolean,
|
|
175
|
+
extraSystem?: string,
|
|
176
|
+
): string | undefined | Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> {
|
|
177
|
+
const preamble = this.oauth?.systemPreamble;
|
|
178
|
+
if (preamble || extraSystem) {
|
|
179
|
+
const blocks: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> = [];
|
|
180
|
+
if (preamble) blocks.push({ type: 'text', text: preamble });
|
|
181
|
+
if (system) {
|
|
182
|
+
blocks.push(
|
|
183
|
+
cacheSystem
|
|
184
|
+
? { type: 'text', text: system, cache_control: { type: 'ephemeral' } }
|
|
185
|
+
: { type: 'text', text: system },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (extraSystem) blocks.push({ type: 'text', text: extraSystem });
|
|
189
|
+
return blocks.length > 0 ? blocks : undefined;
|
|
190
|
+
}
|
|
191
|
+
return cacheSystem && system
|
|
192
|
+
? [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]
|
|
193
|
+
: system;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
|
|
197
|
+
// Translate provider-neutral cache hints into Anthropic cache_control
|
|
198
|
+
// markers. `tools`/`system` mark those session-stable regions; a
|
|
199
|
+
// `{ messageIndex }` hint marks the end of that message (the rolling
|
|
200
|
+
// prefix breakpoint). Anthropic honors at most 4 breakpoints.
|
|
201
|
+
const hints = req.cacheHints ?? [];
|
|
202
|
+
const cacheTools = hints.some((h) => h.target === 'tools');
|
|
203
|
+
const cacheSystem = hints.some((h) => h.target === 'system');
|
|
204
|
+
const cacheMessageIndices = new Set<number>();
|
|
205
|
+
for (const h of hints) {
|
|
206
|
+
if (typeof h.target === 'object') cacheMessageIndices.add(h.target.messageIndex);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { system, messages } = toAnthropicMessages(req.messages, { cacheMessageIndices });
|
|
210
|
+
const tools =
|
|
211
|
+
req.tools && req.tools.length > 0
|
|
212
|
+
? toAnthropicTools(req.tools, { cacheLast: cacheTools })
|
|
213
|
+
: undefined;
|
|
214
|
+
// OAuth mode prepends the Claude Code identity preamble as the first
|
|
215
|
+
// system block; apiKey mode keeps the prior string/cache-block behaviour.
|
|
216
|
+
// req.system (hook-injected extra system text) is appended last.
|
|
217
|
+
const systemParam = this.buildSystemParam(system, cacheSystem, req.system);
|
|
218
|
+
const model = req.model || this.defaultModel;
|
|
219
|
+
|
|
220
|
+
yield { type: 'message_start', model };
|
|
221
|
+
|
|
222
|
+
// In OAuth mode refresh the bearer proactively when it's near expiry, so
|
|
223
|
+
// we don't fire a request on a token we already knew was about to die.
|
|
224
|
+
if (this.oauth) {
|
|
225
|
+
try {
|
|
226
|
+
await this.ensureFreshOauth();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// NARROW cast: `messages`/`tools`/`systemParam` are our hand-rolled
|
|
234
|
+
// Anthropic shapes (e.g. `media_type: string`) which the SDK narrows to
|
|
235
|
+
// literal unions it can't see we never violate. The body is otherwise
|
|
236
|
+
// typed as the SDK's real `MessageStreamParams` — `model`/`max_tokens`/
|
|
237
|
+
// `temperature` are checked at compile time.
|
|
238
|
+
const requestBody: MessageStreamParams = {
|
|
239
|
+
model,
|
|
240
|
+
max_tokens: req.maxTokens ?? 4096,
|
|
241
|
+
system: systemParam as MessageStreamParams['system'],
|
|
242
|
+
messages: messages as MessageStreamParams['messages'],
|
|
243
|
+
tools: tools as MessageStreamParams['tools'],
|
|
244
|
+
...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Reasoning: on supported models (gated upstream by `supportsReasoning`),
|
|
248
|
+
// enable adaptive thinking with summarized display so the model streams a
|
|
249
|
+
// readable reasoning summary (`display: 'omitted'` — the default — would
|
|
250
|
+
// stream empty thinking blocks). Adaptive thinking auto-enables interleaved
|
|
251
|
+
// thinking on the 4.6+ catalog, so no beta header is needed. `effort` maps
|
|
252
|
+
// to `output_config.effort`. Fields are attached via a cast because the
|
|
253
|
+
// pinned SDK's `MessageStreamParams` predates these params (same hand-rolled
|
|
254
|
+
// approach used for system/messages/tools above).
|
|
255
|
+
const reasoningOn = req.reasoning !== undefined && req.reasoning !== false;
|
|
256
|
+
if (reasoningOn) {
|
|
257
|
+
const effort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
|
|
258
|
+
const body = requestBody as unknown as Record<string, unknown>;
|
|
259
|
+
body.thinking = { type: 'adaptive', display: 'summarized' };
|
|
260
|
+
if (effort) body.output_config = { effort };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A 401 always arrives before any SSE body, so in OAuth mode we can force
|
|
264
|
+
// a single refresh and replay the request with no risk of duplicate output.
|
|
265
|
+
try {
|
|
266
|
+
yield* this.streamOnce(requestBody, req.signal);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (this.oauth?.refresh && isUnauthorized(err)) {
|
|
269
|
+
try {
|
|
270
|
+
await this.refreshOauthNow();
|
|
271
|
+
yield* this.streamOnce(requestBody, req.signal);
|
|
272
|
+
return;
|
|
273
|
+
} catch (retryErr) {
|
|
274
|
+
yield { type: 'error', ...toFriendlyError(retryErr, { provider: this.name }) };
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One streaming attempt. THROWS on transport/HTTP errors so `stream()` can
|
|
284
|
+
* decide whether to refresh-and-replay (OAuth 401) or surface the error;
|
|
285
|
+
* yields content events plus a terminal `message_end` on the happy path.
|
|
286
|
+
* Abort is terminal here (yields the abort error and returns).
|
|
287
|
+
*/
|
|
288
|
+
private async *streamOnce(
|
|
289
|
+
requestBody: MessageStreamParams,
|
|
290
|
+
signal: AbortSignal | undefined,
|
|
291
|
+
): AsyncIterable<ProviderEvent> {
|
|
292
|
+
const stream = this.client.messages.stream(
|
|
293
|
+
requestBody,
|
|
294
|
+
// Pass the AbortSignal into the SDK request options so cancelling
|
|
295
|
+
// tears down the underlying HTTP request. Without this, Esc only
|
|
296
|
+
// stopped our loop while the model kept generating upstream.
|
|
297
|
+
signal ? { signal } : undefined,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const pendingToolUses = new Map<string, { name: string; partial: string }>();
|
|
301
|
+
// Anthropic's stream events carry a block `index` on every delta/stop;
|
|
302
|
+
// we map that index to the tool_use id at content_block_start time so
|
|
303
|
+
// parallel tool_use blocks route their deltas correctly. Without this,
|
|
304
|
+
// we used to return the first key in `pendingToolUses` for every event,
|
|
305
|
+
// causing two parallel blocks to overwrite each other's partial JSON.
|
|
306
|
+
const blockIndexToId = new Map<number, string>();
|
|
307
|
+
// Track which block indices are `thinking` blocks so their signature_delta
|
|
308
|
+
// accumulates and flushes as a reasoning_signature at content_block_stop.
|
|
309
|
+
const thinkingBlockIndices = new Set<number>();
|
|
310
|
+
let pendingThinkingSig = '';
|
|
311
|
+
let stopReason: StopReason = 'end_turn';
|
|
312
|
+
// Type as the SDK's `TokenUsage` (which carries the optional cache fields)
|
|
313
|
+
// so cacheReadTokens/cacheCreationTokens are first-class on the accumulator
|
|
314
|
+
// and the message_delta merge below is type-checked to preserve them —
|
|
315
|
+
// rather than the cache fields sneaking in only via a spread (which bypasses
|
|
316
|
+
// the excess-property check) against a narrower declared type.
|
|
317
|
+
let usage: TokenUsage | undefined;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
for await (const event of stream as AsyncIterable<AnthropicStreamEvent>) {
|
|
321
|
+
if (signal?.aborted) {
|
|
322
|
+
yield { type: 'error', message: 'aborted', retryable: false };
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
switch (event.type) {
|
|
326
|
+
case 'message_start': {
|
|
327
|
+
// Anthropic reports cache hits/writes only on the message_start
|
|
328
|
+
// usage block — `cache_read_input_tokens` (billed 0.1x) and
|
|
329
|
+
// `cache_creation_input_tokens` (billed 1.25x). Capture them here
|
|
330
|
+
// so the metering layer can prove cache savings; without this the
|
|
331
|
+
// fields are silently dropped and cache wins are invisible.
|
|
332
|
+
const u = event.message?.usage;
|
|
333
|
+
usage = {
|
|
334
|
+
inputTokens: u?.input_tokens ?? 0,
|
|
335
|
+
outputTokens: u?.output_tokens ?? 0,
|
|
336
|
+
...(u?.cache_read_input_tokens !== undefined
|
|
337
|
+
? { cacheReadTokens: u.cache_read_input_tokens }
|
|
338
|
+
: {}),
|
|
339
|
+
...(u?.cache_creation_input_tokens !== undefined
|
|
340
|
+
? { cacheCreationTokens: u.cache_creation_input_tokens }
|
|
341
|
+
: {}),
|
|
342
|
+
};
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case 'content_block_start': {
|
|
346
|
+
const block = event.content_block;
|
|
347
|
+
if (block && block.type === 'tool_use') {
|
|
348
|
+
pendingToolUses.set(block.id, { name: block.name, partial: '' });
|
|
349
|
+
// Real Anthropic events carry `index` here; fall back to the
|
|
350
|
+
// arrival ordinal when callers (e.g. test fakes) omit it.
|
|
351
|
+
const idx = typeof event.index === 'number' ? event.index : blockIndexToId.size;
|
|
352
|
+
blockIndexToId.set(idx, block.id);
|
|
353
|
+
yield { type: 'tool_use_start', id: block.id, name: block.name };
|
|
354
|
+
} else if (block && block.type === 'thinking') {
|
|
355
|
+
if (typeof event.index === 'number') thinkingBlockIndices.add(event.index);
|
|
356
|
+
pendingThinkingSig = '';
|
|
357
|
+
} else if (block && block.type === 'redacted_thinking') {
|
|
358
|
+
// No readable text — replay the opaque blob verbatim on round-trip.
|
|
359
|
+
yield { type: 'reasoning_signature', redacted: true, ...(block.data ? { encrypted: block.data } : {}) };
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case 'content_block_delta': {
|
|
364
|
+
const delta = event.delta;
|
|
365
|
+
if (!delta) break;
|
|
366
|
+
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
367
|
+
yield { type: 'text_delta', delta: delta.text };
|
|
368
|
+
} else if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
|
|
369
|
+
yield { type: 'reasoning_delta', delta: delta.thinking };
|
|
370
|
+
} else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') {
|
|
371
|
+
pendingThinkingSig += delta.signature;
|
|
372
|
+
} else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
373
|
+
const id = idOfBlock(event, blockIndexToId);
|
|
374
|
+
if (id) {
|
|
375
|
+
const t = pendingToolUses.get(id);
|
|
376
|
+
if (t) {
|
|
377
|
+
t.partial += delta.partial_json;
|
|
378
|
+
yield { type: 'tool_use_delta', id, partialInput: delta.partial_json };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case 'content_block_stop': {
|
|
385
|
+
if (typeof event.index === 'number' && thinkingBlockIndices.has(event.index)) {
|
|
386
|
+
thinkingBlockIndices.delete(event.index);
|
|
387
|
+
if (pendingThinkingSig) yield { type: 'reasoning_signature', signature: pendingThinkingSig };
|
|
388
|
+
pendingThinkingSig = '';
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
const id = idOfBlock(event, blockIndexToId);
|
|
392
|
+
if (id) {
|
|
393
|
+
const t = pendingToolUses.get(id);
|
|
394
|
+
if (t) {
|
|
395
|
+
let parsed: unknown = {};
|
|
396
|
+
try {
|
|
397
|
+
parsed = t.partial ? JSON.parse(t.partial) : {};
|
|
398
|
+
} catch {
|
|
399
|
+
parsed = { _rawPartial: t.partial };
|
|
400
|
+
}
|
|
401
|
+
yield { type: 'tool_use_end', id, input: parsed };
|
|
402
|
+
pendingToolUses.delete(id);
|
|
403
|
+
if (typeof event.index === 'number') blockIndexToId.delete(event.index);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'message_delta': {
|
|
409
|
+
if (event.delta?.stop_reason) {
|
|
410
|
+
stopReason = mapStopReason(event.delta.stop_reason);
|
|
411
|
+
}
|
|
412
|
+
if (event.usage) {
|
|
413
|
+
// Preserve cache fields captured at message_start — the delta
|
|
414
|
+
// usage only carries the final output_tokens count.
|
|
415
|
+
usage = {
|
|
416
|
+
...usage,
|
|
417
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
418
|
+
outputTokens: event.usage.output_tokens ?? usage?.outputTokens ?? 0,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
case 'message_stop':
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// A cancel surfaces as a thrown AbortError mid-await — report it as the
|
|
429
|
+
// clean terminal 'aborted' event. Every other error propagates so
|
|
430
|
+
// `stream()` can classify it (OAuth 401 → refresh+replay; else error).
|
|
431
|
+
if (signal?.aborted) {
|
|
432
|
+
yield { type: 'error', message: 'aborted', retryable: false };
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
yield { type: 'message_end', stopReason, usage };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async countTokens(req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>): Promise<number> {
|
|
442
|
+
const { system, messages } = toAnthropicMessages(req.messages);
|
|
443
|
+
const tools = req.tools && req.tools.length > 0 ? toAnthropicTools(req.tools) : undefined;
|
|
444
|
+
// Mirror stream(): in OAuth mode the request carries the identity preamble
|
|
445
|
+
// as an extra system block, and req.system (hook-injected extra system
|
|
446
|
+
// text) is appended as another — count both for a faithful estimate.
|
|
447
|
+
const parts = [this.oauth?.systemPreamble, system, req.system].filter(
|
|
448
|
+
(s): s is string => typeof s === 'string' && s.length > 0,
|
|
449
|
+
);
|
|
450
|
+
const systemForCount = parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
451
|
+
try {
|
|
452
|
+
// Mirror stream(): in OAuth mode refresh a near-expiry bearer before the
|
|
453
|
+
// request so a token we already knew was about to die doesn't 401 us
|
|
454
|
+
// straight into the (less accurate) text-estimate fallback below.
|
|
455
|
+
if (this.oauth) await this.ensureFreshOauth();
|
|
456
|
+
const result = await this.client.messages.countTokens({
|
|
457
|
+
model: req.model || this.defaultModel,
|
|
458
|
+
...(systemForCount !== undefined ? { system: systemForCount } : {}),
|
|
459
|
+
// NARROW cast: our hand-rolled message/tool shapes carry `media_type:
|
|
460
|
+
// string`, which the SDK narrows to a literal union it can't see we
|
|
461
|
+
// never violate. The method itself is fully typed (no `as unknown` on
|
|
462
|
+
// the resource anymore); only these two args need the structural cast.
|
|
463
|
+
messages: messages as MessageCountTokensParams['messages'],
|
|
464
|
+
...(tools !== undefined ? { tools: tools as MessageCountTokensParams['tools'] } : {}),
|
|
465
|
+
});
|
|
466
|
+
return result.input_tokens;
|
|
467
|
+
} catch {
|
|
468
|
+
const blob =
|
|
469
|
+
(systemForCount ?? '') +
|
|
470
|
+
messages.map((m) => JSON.stringify(m.content)).join('') +
|
|
471
|
+
JSON.stringify(tools ?? []);
|
|
472
|
+
return estimateTextTokens(blob);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface AnthropicStreamEvent {
|
|
478
|
+
type:
|
|
479
|
+
| 'message_start'
|
|
480
|
+
| 'content_block_start'
|
|
481
|
+
| 'content_block_delta'
|
|
482
|
+
| 'content_block_stop'
|
|
483
|
+
| 'message_delta'
|
|
484
|
+
| 'message_stop';
|
|
485
|
+
message?: {
|
|
486
|
+
usage?: {
|
|
487
|
+
input_tokens?: number;
|
|
488
|
+
output_tokens?: number;
|
|
489
|
+
cache_read_input_tokens?: number;
|
|
490
|
+
cache_creation_input_tokens?: number;
|
|
491
|
+
};
|
|
492
|
+
};
|
|
493
|
+
content_block?: {
|
|
494
|
+
type: 'text' | 'tool_use' | 'thinking' | 'redacted_thinking';
|
|
495
|
+
id: string;
|
|
496
|
+
name: string;
|
|
497
|
+
/** redacted_thinking carries an opaque encrypted blob (no readable text). */
|
|
498
|
+
data?: string;
|
|
499
|
+
};
|
|
500
|
+
index?: number;
|
|
501
|
+
delta?: {
|
|
502
|
+
type?: 'text_delta' | 'input_json_delta' | 'thinking_delta' | 'signature_delta';
|
|
503
|
+
text?: string;
|
|
504
|
+
partial_json?: string;
|
|
505
|
+
/** thinking_delta payload (the summarized reasoning text). */
|
|
506
|
+
thinking?: string;
|
|
507
|
+
/** signature_delta payload (the thinking-block signature for round-trip). */
|
|
508
|
+
signature?: string;
|
|
509
|
+
stop_reason?: string;
|
|
510
|
+
};
|
|
511
|
+
usage?: { output_tokens?: number };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function idOfBlock(
|
|
515
|
+
event: AnthropicStreamEvent,
|
|
516
|
+
blockIndexToId: Map<number, string>,
|
|
517
|
+
): string | null {
|
|
518
|
+
if (typeof event.index === 'number') {
|
|
519
|
+
return blockIndexToId.get(event.index) ?? null;
|
|
520
|
+
}
|
|
521
|
+
// Fallback when `index` is missing (older SDKs / hand-rolled fakes): only
|
|
522
|
+
// unambiguous when exactly one tool_use is pending; otherwise refuse to
|
|
523
|
+
// guess and let the delta drop rather than misroute it.
|
|
524
|
+
if (blockIndexToId.size === 1) {
|
|
525
|
+
for (const id of blockIndexToId.values()) return id;
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function mapStopReason(s: string): StopReason {
|
|
531
|
+
if (s === 'tool_use') return 'tool_use';
|
|
532
|
+
if (s === 'max_tokens') return 'max_tokens';
|
|
533
|
+
if (s === 'stop_sequence') return 'stop_sequence';
|
|
534
|
+
if (s === 'end_turn') return 'end_turn';
|
|
535
|
+
return 'error';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* True when the SDK error is an HTTP 401. The Anthropic SDK throws
|
|
540
|
+
* `APIError` instances carrying a numeric `status`; an expired/revoked OAuth
|
|
541
|
+
* bearer is the only 401 we want to refresh-and-retry on.
|
|
542
|
+
*/
|
|
543
|
+
function isUnauthorized(err: unknown): boolean {
|
|
544
|
+
return typeof (err as { status?: unknown } | null | undefined)?.status === 'number'
|
|
545
|
+
? (err as { status: number }).status === 401
|
|
546
|
+
: false;
|
|
547
|
+
}
|