@cortexkit/opencode-anthropic-auth 1.0.0 → 1.1.1

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 CHANGED
@@ -19,14 +19,15 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one
19
19
  | Primary Claude Pro/Max OAuth | OpenCode `/connect anthropic` | Pi `/login anthropic` |
20
20
  | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override |
21
21
  | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` |
22
- | Commands | `/claude-cache`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-quota`, `/claude-dump` |
23
- | Fallback accounts, quota routing, relay, dumps | Supported | Supported through the same shared core and Pi sidecar |
22
+ | Commands | `/claude-cache`, `/claude-fast`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-fast`, `/claude-quota`, `/claude-dump` |
23
+ | Fallback accounts, quota routing, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar |
24
24
 
25
25
  ## What CortexKit adds over the original plugin
26
26
 
27
27
  - **Fallback Claude accounts**: keep each agent's normal Anthropic login as the primary account, then route to ordered fallback OAuth accounts on auth/quota/rate-limit failures.
28
28
  - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum.
29
29
  - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes.
30
+ - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models.
30
31
  - **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors.
31
32
  - **User-owned Cloudflare relay**: optionally provision your own Worker relay to reduce repeated client upload bytes for large OpenCode or Pi requests.
32
33
  - **Claude-compatible request hardening**: final-body billing signing, safer token refresh persistence, replay-safe fallback retries, and subagent cache isolation.
@@ -40,7 +41,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one
40
41
  - Support fallback Claude accounts stored in a local per-agent sidecar file.
41
42
  - Keep fallback OAuth tokens fresh in the background.
42
43
  - Apply quota thresholds before routing to main or fallback accounts.
43
- - Add `/claude-cache`, `/claude-quota`, and `/claude-dump` commands.
44
+ - Add `/claude-cache`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands.
44
45
  - Optionally relay large requests through a Cloudflare Worker owned by the user.
45
46
 
46
47
  ## Install
@@ -174,6 +175,9 @@ Example:
174
175
  "dump": {
175
176
  "enabled": false
176
177
  },
178
+ "claudeFast": {
179
+ "enabled": false
180
+ },
177
181
  "relay": {
178
182
  "enabled": false,
179
183
  "url": "https://opencode-anthropic-relay.example.workers.dev",
@@ -185,7 +189,7 @@ Example:
185
189
  }
186
190
  ```
187
191
 
188
- The `claudeCache` block controls the `/claude-cache` command's persisted mode. The `main` field identifies OpenCode's primary auth entry; Pi keeps primary OAuth credentials in Pi's own credential store, but uses the same sidecar shape for CortexKit settings and fallback accounts.
192
+ The `claudeCache` block controls the `/claude-cache` command's persisted mode, and `claudeFast` controls `/claude-fast`. The `main` field identifies OpenCode's primary auth entry; Pi keeps primary OAuth credentials in Pi's own credential store, but uses the same sidecar shape for CortexKit settings and fallback accounts.
189
193
 
190
194
  ## Fallback accounts
191
195
 
@@ -267,10 +271,24 @@ Modes:
267
271
 
268
272
  - `explicit` keeps OpenCode's explicit cache breakpoints and adds `ttl: "1h"` to them.
269
273
  - `automatic` removes block-level cache controls and sends a top-level `cache_control` object.
270
- - `hybrid` (recommended) uses top-level automatic caching while keeping stable explicit anchors near the beginning of the request.
274
+ - `hybrid` (recommended) removes top-level automatic caching and uses explicit anchors on the last stable system block, the first two messages, and `messages[n-2]` when that anchor is distinct.
271
275
 
272
276
  In OpenCode, subagent requests do not receive 1-hour TTL caching. The plugin detects child sessions through OpenCode's `x-parent-session-id` header, strips that internal header before forwarding to Anthropic, and leaves default ephemeral caching in place for those requests.
273
277
 
278
+ ## Claude fast mode
279
+
280
+ Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models:
281
+
282
+ ```text
283
+ /claude-fast
284
+ /claude-fast on
285
+ /claude-fast off
286
+ ```
287
+
288
+ When enabled, supported requests add `speed: "fast"` to the Anthropic JSON body and include the `fast-mode-2026-02-01` beta header. Unsupported models are left at standard speed. Anthropic currently documents fast mode for `claude-opus-4-6` and `claude-opus-4-7`.
289
+
290
+ Fast and standard speeds do not share prompt-cache prefixes, so switching this setting can cause cache misses.
291
+
274
292
  ### Estimate cache savings from OpenCode history
275
293
 
276
294
  The repo includes an OpenCode SQLite analyzer that compares estimated Claude cost under three scenarios: no prompt cache, Anthropic's default 5-minute cache, and this plugin's 1-hour cache mode.
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
- import { authorize, buildClaudeQuotaSummary, buildFallbackQuotaSummaries, CACHE_1H_COMMAND_NAME, CLAUDE_DUMP_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLIENT_ID, exchange, executeCache1hCommand, executeDumpCommand, FallbackAccountManager, fetchOAuthQuotaSnapshot, getCache1hMode, getCache1hPersistentMode, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getRelayConfig, isCache1hEnabled, isCache1hPersistentlyEnabled, isDumpPersistentlyEnabled, loadAccounts, log, parseCache1hCommandAction, parseDumpCommandAction, quotaSnapshotPassesPolicy, sendViaRelay, setCache1hPersistentEnabled, setCache1hPersistentMode, setCache1hState, setDumpEnabled, setDumpPersistentEnabled, shouldFallbackStatus, TOKEN_URL, } from '@cortexkit/anthropic-auth-core';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { authorize, buildClaudeQuotaSummary, buildFallbackQuotaSummaries, buildRefreshOperationError, CACHE_1H_COMMAND_NAME, CLAUDE_DUMP_COMMAND_NAME, CLAUDE_FAST_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, ClaudeOAuthRefreshError, exchange, executeCache1hCommand, executeDumpCommand, executeFastModeCommand, FallbackAccountManager, fetchOAuthQuotaSnapshot, formatRefreshBackoffMessage, getCache1hMode, getCache1hPersistentMode, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getRelayConfig, hashRefreshToken, isCache1hEnabled, isCache1hPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModeEnabled, isFastModePersistentlyEnabled, isFastModeSupportedModel, loadAccounts, log, parseCache1hCommandAction, parseDumpCommandAction, parseFastModeCommandAction, quotaSnapshotPassesPolicy, refreshBackoffActive, refreshClaudeOAuthToken, resolveClaudeCodeIdentity, saveAccounts, sendViaRelay, setCache1hPersistentEnabled, setCache1hPersistentMode, setCache1hState, setDumpEnabled, setDumpPersistentEnabled, setFastModeEnabled, setFastModePersistentEnabled, shouldFallbackStatus, } from '@cortexkit/anthropic-auth-core';
2
3
  import { resolvePromptContext } from "./prompt-context.js";
3
- import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
+ import { addFastModeBetaHeader, createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
5
  const HANDLED_SENTINEL = '__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__';
6
+ const MAIN_AUTH_REFRESH_TICK_MS = 60_000;
7
+ const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 30;
5
8
  let nextPerfRequestId = 1;
6
9
  let eventLoopLagMonitorStarted = false;
7
10
  function nowMs() {
@@ -102,7 +105,17 @@ export const AnthropicAuthPlugin = async (ctx) => {
102
105
  mode: getCache1hPersistentMode(initialCache1hStorage),
103
106
  });
104
107
  setDumpEnabled(isDumpPersistentlyEnabled(initialCache1hStorage));
108
+ setFastModeEnabled(isFastModePersistentlyEnabled(initialCache1hStorage));
105
109
  let latestGetAuth = null;
110
+ let mainBackgroundRefreshTimer = null;
111
+ function mainRefreshBeforeExpiryMs(storage) {
112
+ const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
113
+ DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES;
114
+ return Math.max(0, minutes) * 60_000;
115
+ }
116
+ function mainRefreshEnabled(storage) {
117
+ return storage?.refresh?.enabled !== false;
118
+ }
106
119
  async function buildQuotaCommandSummary() {
107
120
  const accounts = [];
108
121
  if (latestGetAuth) {
@@ -180,6 +193,19 @@ export const AnthropicAuthPlugin = async (ctx) => {
180
193
  setDumpEnabled(enabled);
181
194
  return executeDumpCommand({ argumentsText, enabled });
182
195
  }
196
+ async function executePersistentFastModeCommand(argumentsText) {
197
+ const action = parseFastModeCommandAction(argumentsText);
198
+ if (action.type === 'enable' || action.type === 'disable') {
199
+ const enabled = action.type === 'enable';
200
+ await setFastModePersistentEnabled(enabled);
201
+ setFastModeEnabled(enabled);
202
+ return executeFastModeCommand({ argumentsText, enabled });
203
+ }
204
+ const storage = await loadAccounts();
205
+ const enabled = isFastModePersistentlyEnabled(storage);
206
+ setFastModeEnabled(enabled);
207
+ return executeFastModeCommand({ argumentsText, enabled });
208
+ }
183
209
  return {
184
210
  config: async (config) => {
185
211
  config.command = {
@@ -196,6 +222,10 @@ export const AnthropicAuthPlugin = async (ctx) => {
196
222
  template: CLAUDE_DUMP_COMMAND_NAME,
197
223
  description: 'Show or toggle Anthropic request dump capture for debugging.',
198
224
  },
225
+ [CLAUDE_FAST_COMMAND_NAME]: {
226
+ template: CLAUDE_FAST_COMMAND_NAME,
227
+ description: 'Show or toggle Anthropic fast mode for supported Opus models.',
228
+ },
199
229
  };
200
230
  },
201
231
  'command.execute.before': async (input) => {
@@ -211,6 +241,10 @@ export const AnthropicAuthPlugin = async (ctx) => {
211
241
  await sendIgnoredMessage(ctx, input.sessionID, await executePersistentDumpCommand(input.arguments));
212
242
  throwHandledSentinel();
213
243
  }
244
+ if (input.command === CLAUDE_FAST_COMMAND_NAME) {
245
+ await sendIgnoredMessage(ctx, input.sessionID, await executePersistentFastModeCommand(input.arguments));
246
+ throwHandledSentinel();
247
+ }
214
248
  },
215
249
  auth: {
216
250
  provider: 'anthropic',
@@ -240,7 +274,20 @@ export const AnthropicAuthPlugin = async (ctx) => {
240
274
  refreshPromise = (async () => {
241
275
  const maxRetries = 2;
242
276
  const baseDelayMs = 500;
277
+ let leaseId = null;
278
+ let leaseTokenHash = null;
279
+ async function updateMainRefreshState(update) {
280
+ const storage = (await loadAccounts()) ?? {
281
+ version: 1,
282
+ main: { type: 'opencode', provider: 'anthropic' },
283
+ accounts: [],
284
+ };
285
+ storage.refresh = storage.refresh ?? {};
286
+ update(storage);
287
+ await saveAccounts(storage);
288
+ }
243
289
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
290
+ let freshAuth = null;
244
291
  try {
245
292
  if (attempt > 0) {
246
293
  const delay = baseDelayMs * 2 ** (attempt - 1);
@@ -249,29 +296,43 @@ export const AnthropicAuthPlugin = async (ctx) => {
249
296
  // Re-read auth to get the latest refresh token.
250
297
  // The outer `auth` snapshot may be stale if tokens
251
298
  // were rotated since the fetch() call was made.
252
- const freshAuth = await getAuth();
253
- const response = await fetch(TOKEN_URL, {
254
- method: 'POST',
255
- headers: {
256
- 'Content-Type': 'application/json',
257
- Accept: 'application/json, text/plain, */*',
258
- 'User-Agent': 'axios/1.13.6',
259
- },
260
- body: JSON.stringify({
261
- grant_type: 'refresh_token',
262
- refresh_token: freshAuth.refresh,
263
- client_id: CLIENT_ID,
264
- }),
299
+ freshAuth = await getAuth();
300
+ if (!freshAuth.refresh) {
301
+ throw new Error('Token refresh failed: missing refresh token');
302
+ }
303
+ const storage = await loadAccounts();
304
+ const refreshTokenHash = hashRefreshToken(freshAuth.refresh);
305
+ const mainError = storage?.refresh?.mainLastRefreshError;
306
+ if (mainError &&
307
+ refreshBackoffActive(mainError, freshAuth.refresh, Date.now())) {
308
+ throw new Error(formatRefreshBackoffMessage(mainError, Date.now()));
309
+ }
310
+ if (storage?.refresh?.mainRefreshLeaseUntil &&
311
+ storage.refresh.mainRefreshLeaseUntil > Date.now() &&
312
+ storage.refresh.mainRefreshLeaseTokenHash ===
313
+ refreshTokenHash) {
314
+ throw new Error('Claude OAuth refresh is already in progress');
315
+ }
316
+ leaseId = randomUUID();
317
+ leaseTokenHash = refreshTokenHash;
318
+ await updateMainRefreshState((nextStorage) => {
319
+ nextStorage.refresh = nextStorage.refresh ?? {};
320
+ nextStorage.refresh.mainRefreshLeaseId =
321
+ leaseId ?? undefined;
322
+ nextStorage.refresh.mainRefreshLeaseUntil =
323
+ Date.now() + 2 * 60_000;
324
+ nextStorage.refresh.mainRefreshLeaseTokenHash =
325
+ refreshTokenHash;
265
326
  });
266
- if (!response.ok) {
267
- if (response.status >= 500 && attempt < maxRetries) {
268
- await response.body?.cancel();
269
- continue;
270
- }
271
- const body = await response.text().catch(() => '');
272
- throw new Error(`Token refresh failed: ${response.status} — ${body}`);
327
+ const latestLease = await loadAccounts();
328
+ if (latestLease?.refresh?.mainRefreshLeaseId !== leaseId ||
329
+ latestLease.refresh.mainRefreshLeaseTokenHash !==
330
+ refreshTokenHash) {
331
+ throw new Error('Claude OAuth refresh is already in progress');
273
332
  }
274
- const json = (await response.json());
333
+ const refreshed = await refreshClaudeOAuthToken({
334
+ refreshToken: freshAuth.refresh,
335
+ });
275
336
  // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
276
337
  await client.auth.set({
277
338
  path: {
@@ -279,12 +340,22 @@ export const AnthropicAuthPlugin = async (ctx) => {
279
340
  },
280
341
  body: {
281
342
  type: 'oauth',
282
- refresh: json.refresh_token,
283
- access: json.access_token,
284
- expires: Date.now() + json.expires_in * 1000,
343
+ refresh: refreshed.refresh,
344
+ access: refreshed.access,
345
+ expires: refreshed.expires,
285
346
  },
286
347
  });
287
- return json.access_token;
348
+ await updateMainRefreshState((storage) => {
349
+ if (!storage?.refresh)
350
+ return;
351
+ storage.refresh.mainLastRefreshError = undefined;
352
+ if (storage.refresh.mainRefreshLeaseId === leaseId) {
353
+ storage.refresh.mainRefreshLeaseId = undefined;
354
+ storage.refresh.mainRefreshLeaseUntil = undefined;
355
+ storage.refresh.mainRefreshLeaseTokenHash = undefined;
356
+ }
357
+ });
358
+ return refreshed.access;
288
359
  }
289
360
  catch (error) {
290
361
  const isNetworkError = error instanceof Error &&
@@ -294,11 +365,43 @@ export const AnthropicAuthPlugin = async (ctx) => {
294
365
  error.code === 'ECONNREFUSED' ||
295
366
  error.code === 'ETIMEDOUT' ||
296
367
  error.code === 'UND_ERR_CONNECT_TIMEOUT')));
297
- if (attempt < maxRetries && isNetworkError) {
368
+ if (attempt < maxRetries &&
369
+ (isNetworkError ||
370
+ (error instanceof ClaudeOAuthRefreshError &&
371
+ error.status >= 500))) {
298
372
  continue;
299
373
  }
374
+ const failedRefreshToken = freshAuth?.refresh;
375
+ if (failedRefreshToken &&
376
+ error instanceof ClaudeOAuthRefreshError) {
377
+ await updateMainRefreshState((storage) => {
378
+ storage.refresh = storage.refresh ?? {};
379
+ storage.refresh.mainLastRefreshError =
380
+ buildRefreshOperationError({
381
+ error,
382
+ now: Date.now(),
383
+ refreshToken: failedRefreshToken,
384
+ previous: storage.refresh.mainLastRefreshError,
385
+ });
386
+ });
387
+ }
300
388
  throw error;
301
389
  }
390
+ finally {
391
+ if (leaseId) {
392
+ await updateMainRefreshState((storage) => {
393
+ if (!storage?.refresh)
394
+ return;
395
+ if (storage.refresh.mainRefreshLeaseId === leaseId &&
396
+ storage.refresh.mainRefreshLeaseTokenHash ===
397
+ leaseTokenHash) {
398
+ storage.refresh.mainRefreshLeaseId = undefined;
399
+ storage.refresh.mainRefreshLeaseUntil = undefined;
400
+ storage.refresh.mainRefreshLeaseTokenHash = undefined;
401
+ }
402
+ }).catch(() => { });
403
+ }
404
+ }
302
405
  }
303
406
  // Unreachable — each iteration either returns or throws.
304
407
  // Kept as a TypeScript exhaustiveness guard.
@@ -309,6 +412,55 @@ export const AnthropicAuthPlugin = async (ctx) => {
309
412
  }
310
413
  return refreshPromise;
311
414
  }
415
+ function startMainBackgroundRefresh() {
416
+ if (mainBackgroundRefreshTimer) {
417
+ clearInterval(mainBackgroundRefreshTimer);
418
+ mainBackgroundRefreshTimer = null;
419
+ }
420
+ const run = async () => {
421
+ try {
422
+ const storage = await loadAccounts();
423
+ if (!mainRefreshEnabled(storage))
424
+ return;
425
+ const latestAuth = await getAuth();
426
+ if (latestAuth.type !== 'oauth')
427
+ return;
428
+ if (!latestAuth.expires)
429
+ return;
430
+ if (latestAuth.expires - Date.now() >
431
+ mainRefreshBeforeExpiryMs(storage)) {
432
+ return;
433
+ }
434
+ if (latestAuth.refresh &&
435
+ refreshBackoffActive(storage?.refresh?.mainLastRefreshError, latestAuth.refresh, Date.now())) {
436
+ return;
437
+ }
438
+ if (latestAuth.refresh &&
439
+ storage?.refresh?.mainRefreshLeaseUntil &&
440
+ storage.refresh.mainRefreshLeaseUntil > Date.now() &&
441
+ storage.refresh.mainRefreshLeaseTokenHash ===
442
+ hashRefreshToken(latestAuth.refresh)) {
443
+ return;
444
+ }
445
+ await refreshMainAccessToken();
446
+ log('[refresh] opencode main oauth refreshed in background', {
447
+ expires: latestAuth.expires,
448
+ });
449
+ }
450
+ catch (error) {
451
+ log('[refresh] opencode main oauth refresh failed', {
452
+ message: error instanceof Error ? error.message : String(error),
453
+ });
454
+ }
455
+ };
456
+ mainBackgroundRefreshTimer = setInterval(() => {
457
+ void run();
458
+ }, MAIN_AUTH_REFRESH_TICK_MS);
459
+ if ('unref' in mainBackgroundRefreshTimer) {
460
+ mainBackgroundRefreshTimer.unref();
461
+ }
462
+ }
463
+ startMainBackgroundRefresh();
312
464
  function isReplayableRequest(input, body) {
313
465
  if (input instanceof Request && input.body)
314
466
  return false;
@@ -395,17 +547,54 @@ export const AnthropicAuthPlugin = async (ctx) => {
395
547
  async function sendWithAccessToken(input, init, accessToken, trace, route = 'unknown') {
396
548
  const start = nowMs();
397
549
  const requestHeaders = mergeHeaders(input, init);
550
+ const relayAffinity = requestHeaders.get('x-session-affinity') ||
551
+ requestHeaders.get('x-opencode-session');
398
552
  const subagentRequest = isSubagentRequest(requestHeaders);
399
553
  requestHeaders.delete('x-parent-session-id');
400
- setOAuthHeaders(requestHeaders, accessToken);
554
+ requestHeaders.delete('x-session-affinity');
555
+ requestHeaders.delete('x-opencode-session');
401
556
  let body = init?.body;
557
+ let modelForIdentity;
558
+ if (body && typeof body === 'string') {
559
+ try {
560
+ const parsedBody = JSON.parse(body);
561
+ if (typeof parsedBody.model === 'string') {
562
+ modelForIdentity = parsedBody.model;
563
+ }
564
+ }
565
+ catch { }
566
+ }
567
+ const identity = await resolveClaudeCodeIdentity(accessToken, modelForIdentity);
402
568
  const originalBytes = typeof body === 'string' ? body.length : undefined;
403
569
  if (body && typeof body === 'string') {
404
570
  const rewriteStart = nowMs();
571
+ const fastModeRequested = (() => {
572
+ if (!isFastModeEnabled())
573
+ return false;
574
+ try {
575
+ return isFastModeSupportedModel(JSON.parse(body).model);
576
+ }
577
+ catch {
578
+ return false;
579
+ }
580
+ })();
405
581
  body = await rewriteRequestBody(body, {
406
582
  cache1hEnabled: !subagentRequest && isCache1hEnabled(),
407
583
  cache1hMode: getCache1hMode(),
584
+ fastModeEnabled: fastModeRequested,
585
+ identity,
408
586
  });
587
+ try {
588
+ setOAuthHeaders(requestHeaders, accessToken, {
589
+ body: JSON.parse(body),
590
+ identity,
591
+ });
592
+ }
593
+ catch {
594
+ setOAuthHeaders(requestHeaders, accessToken, { identity });
595
+ }
596
+ if (fastModeRequested)
597
+ addFastModeBetaHeader(requestHeaders);
409
598
  trace?.mark('rewrite_body', {
410
599
  route,
411
600
  ms: roundMs(nowMs() - rewriteStart),
@@ -413,6 +602,7 @@ export const AnthropicAuthPlugin = async (ctx) => {
413
602
  rewrittenBytes: body.length,
414
603
  cacheEnabled: !subagentRequest && isCache1hEnabled(),
415
604
  cacheMode: getCache1hMode(),
605
+ fastModeEnabled: fastModeRequested,
416
606
  subagent: subagentRequest,
417
607
  });
418
608
  }
@@ -431,6 +621,7 @@ export const AnthropicAuthPlugin = async (ctx) => {
431
621
  headers: requestHeaders,
432
622
  body,
433
623
  fallback: directFetch,
624
+ affinity: relayAffinity,
434
625
  optimisticResponse: relayConfig?.transport === 'websocket',
435
626
  });
436
627
  trace?.mark('send_headers_received', {
@@ -1,4 +1,4 @@
1
- import { type Cache1hMode } from '@cortexkit/anthropic-auth-core';
1
+ import { type Cache1hMode, type ClaudeCodeIdentity } from '@cortexkit/anthropic-auth-core';
2
2
  export type FetchInput = string | URL | Request;
3
3
  /**
4
4
  * Merge headers from a Request object and/or a RequestInit headers value
@@ -9,11 +9,15 @@ export declare function mergeHeaders(input: FetchInput, init?: RequestInit): Hea
9
9
  * Merge incoming beta headers with the required OAuth betas, deduplicating.
10
10
  */
11
11
  export declare function mergeBetaHeaders(headers: Headers): string;
12
+ export declare function addFastModeBetaHeader(headers: Headers): Headers;
12
13
  /**
13
14
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
14
15
  * Removes x-api-key since we're using OAuth.
15
16
  */
16
- export declare function setOAuthHeaders(headers: Headers, accessToken: string): Headers;
17
+ export declare function setOAuthHeaders(headers: Headers, accessToken: string, options?: {
18
+ body?: Record<string, unknown> | null;
19
+ identity?: ClaudeCodeIdentity;
20
+ }): Headers;
17
21
  /**
18
22
  * Add TOOL_PREFIX to tool names in the request body.
19
23
  * Prefixes both tool definitions and tool_use blocks in messages.
@@ -69,6 +73,8 @@ export declare function prependClaudeCodeIdentity(system: unknown): SystemBlock[
69
73
  export declare function rewriteRequestBody(body: string, options?: {
70
74
  cache1hEnabled?: boolean;
71
75
  cache1hMode?: Cache1hMode;
76
+ fastModeEnabled?: boolean;
77
+ identity?: ClaudeCodeIdentity;
72
78
  }): Promise<string>;
73
79
  /**
74
80
  * Create a streaming response that strips the tool prefix from tool names.
package/dist/transform.js CHANGED
@@ -1,4 +1,4 @@
1
- import { buildBillingHeaderValue, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, signRequestBody, TEXT_REPLACEMENTS, TOOL_PREFIX, USER_AGENT, } from '@cortexkit/anthropic-auth-core';
1
+ import { applyClaudeCodeHeaders, applyClaudeCodeMetadata, buildBillingHeaderValue, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, FAST_MODE_BETA, isFastModeSupportedModel, mergeAnthropicBetas, OPENCODE_IDENTITY_PREFIX, orderClaudeCodeBody, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, signRequestBody, TEXT_REPLACEMENTS, TOOL_PREFIX, } from '@cortexkit/anthropic-auth-core';
2
2
  /**
3
3
  * Prefix a tool name with TOOL_PREFIX and uppercase the first character.
4
4
  * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read);
@@ -64,16 +64,16 @@ export function mergeBetaHeaders(headers) {
64
64
  .filter(Boolean);
65
65
  return [...new Set([...REQUIRED_BETAS, ...incomingBetasList])].join(',');
66
66
  }
67
+ export function addFastModeBetaHeader(headers) {
68
+ headers.set('anthropic-beta', mergeAnthropicBetas(headers.get('anthropic-beta'), [FAST_MODE_BETA]));
69
+ return headers;
70
+ }
67
71
  /**
68
72
  * Set OAuth-required headers on the request: authorization, beta, user-agent.
69
73
  * Removes x-api-key since we're using OAuth.
70
74
  */
71
- export function setOAuthHeaders(headers, accessToken) {
72
- headers.set('authorization', `Bearer ${accessToken}`);
73
- headers.set('anthropic-beta', mergeBetaHeaders(headers));
74
- headers.set('user-agent', USER_AGENT);
75
- headers.delete('x-api-key');
76
- return headers;
75
+ export function setOAuthHeaders(headers, accessToken, options = {}) {
76
+ return applyClaudeCodeHeaders(headers, accessToken, options);
77
77
  }
78
78
  /**
79
79
  * Add TOOL_PREFIX to tool names in the request body.
@@ -99,7 +99,7 @@ export function prefixToolNames(parsed) {
99
99
  return msg;
100
100
  });
101
101
  }
102
- return JSON.stringify(parsed);
102
+ return JSON.stringify(orderClaudeCodeBody(parsed));
103
103
  }
104
104
  /**
105
105
  * Strip TOOL_PREFIX from tool names in streaming response text.
@@ -107,6 +107,33 @@ export function prefixToolNames(parsed) {
107
107
  export function stripToolPrefix(text) {
108
108
  return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, (_match, name) => `"name": "${unprefixName(name)}"`);
109
109
  }
110
+ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
111
+ if (flush)
112
+ return { ready: stripToolPrefix(buffer), pending: '' };
113
+ let keepFrom = buffer.length;
114
+ const marker = '"name"';
115
+ const partialMarkerStart = Math.max(0, buffer.length - marker.length + 1);
116
+ for (let index = partialMarkerStart; index < buffer.length; index++) {
117
+ if (marker.startsWith(buffer.slice(index))) {
118
+ keepFrom = Math.min(keepFrom, index);
119
+ break;
120
+ }
121
+ }
122
+ const lastName = buffer.lastIndexOf(marker);
123
+ if (lastName !== -1) {
124
+ const tail = buffer.slice(lastName);
125
+ if (/^"name"\s*(?::\s*(?:"[^"]*)?)?$/.test(tail)) {
126
+ keepFrom = Math.min(keepFrom, lastName);
127
+ }
128
+ }
129
+ if (keepFrom < buffer.length) {
130
+ return {
131
+ ready: stripToolPrefix(buffer.slice(0, keepFrom)),
132
+ pending: buffer.slice(keepFrom),
133
+ };
134
+ }
135
+ return { ready: stripToolPrefix(buffer), pending: '' };
136
+ }
110
137
  /**
111
138
  * Check if TLS verification should be skipped for custom API endpoints.
112
139
  * Only effective when ANTHROPIC_BASE_URL is also set.
@@ -349,32 +376,39 @@ function applyAutomaticCache1h(parsed) {
349
376
  removeAllCacheControls(parsed);
350
377
  parsed.cache_control = { ...CACHE_1H_CONTROL };
351
378
  }
379
+ function setMessageCacheAnchor(message) {
380
+ if (!isRecord(message))
381
+ return false;
382
+ const content = normalizeContentToArray(message.content);
383
+ if (!content?.length)
384
+ return setWireCacheControl(message, true);
385
+ message.content = content;
386
+ const lastCacheableBlock = [...content]
387
+ .reverse()
388
+ .find((block) => isRecord(block) && block.type !== 'thinking');
389
+ return setWireCacheControl(lastCacheableBlock ?? message, true);
390
+ }
352
391
  function applyHybridCache1h(parsed) {
353
392
  removeAllCacheControls(parsed);
354
- parsed.cache_control = { ...CACHE_1H_CONTROL };
355
393
  if (Array.isArray(parsed.system)) {
356
- for (const block of parsed.system.slice(2).filter(isRecord).slice(0, 2)) {
357
- setWireCacheControl(block, true);
358
- }
394
+ const identityIndex = parsed.system.findIndex((block) => isRecord(block) && block.text === CLAUDE_CODE_IDENTITY);
395
+ const cacheableSystemBlocks = parsed.system
396
+ .slice(identityIndex >= 0 ? identityIndex + 1 : 0)
397
+ .filter(isRecord);
398
+ const lastSystemBlock = cacheableSystemBlocks[cacheableSystemBlocks.length - 1];
399
+ setWireCacheControl(lastSystemBlock, true);
359
400
  }
360
401
  else {
361
402
  setWireCacheControl(parsed.system, true);
362
403
  }
363
404
  if (!Array.isArray(parsed.messages))
364
405
  return;
365
- const firstMessage = parsed.messages.find((message) => isRecord(message));
366
- if (!isRecord(firstMessage))
367
- return;
368
- const content = normalizeContentToArray(firstMessage.content);
369
- if (!content?.length) {
370
- setWireCacheControl(firstMessage, true);
371
- return;
406
+ setMessageCacheAnchor(parsed.messages[0]);
407
+ setMessageCacheAnchor(parsed.messages[1]);
408
+ const movingMessageIndex = parsed.messages.length - 2;
409
+ if (movingMessageIndex > 1) {
410
+ setMessageCacheAnchor(parsed.messages[movingMessageIndex]);
372
411
  }
373
- firstMessage.content = content;
374
- const lastCacheableBlock = [...content]
375
- .reverse()
376
- .find((block) => isRecord(block) && block.type !== 'thinking');
377
- setWireCacheControl(lastCacheableBlock ?? firstMessage, true);
378
412
  }
379
413
  function applyCache1hStrategy(parsed, options) {
380
414
  if (!options.enabled) {
@@ -415,6 +449,14 @@ export async function rewriteRequestBody(body, options = {}) {
415
449
  enabled: options.cache1hEnabled ?? false,
416
450
  mode: options.cache1hMode ?? 'explicit',
417
451
  });
452
+ if (options.fastModeEnabled && isFastModeSupportedModel(parsed.model)) {
453
+ parsed.speed = 'fast';
454
+ }
455
+ else if (parsed.speed === 'fast') {
456
+ delete parsed.speed;
457
+ }
458
+ if (options.identity)
459
+ applyClaudeCodeMetadata(parsed, options.identity);
418
460
  return await signRequestBody(prefixToolNames(parsed));
419
461
  }
420
462
  catch {
@@ -430,16 +472,22 @@ export function createStrippedStream(response) {
430
472
  const reader = response.body.getReader();
431
473
  const decoder = new TextDecoder();
432
474
  const encoder = new TextEncoder();
475
+ let pending = '';
433
476
  const stream = new ReadableStream({
434
477
  async pull(controller) {
435
478
  const { done, value } = await reader.read();
436
479
  if (done) {
480
+ const flushed = splitToolPrefixRewriteBuffer(`${pending}${decoder.decode()}`, true);
481
+ if (flushed.ready)
482
+ controller.enqueue(encoder.encode(flushed.ready));
437
483
  controller.close();
438
484
  return;
439
485
  }
440
- let text = decoder.decode(value, { stream: true });
441
- text = stripToolPrefix(text);
442
- controller.enqueue(encoder.encode(text));
486
+ const text = pending + decoder.decode(value, { stream: true });
487
+ const rewritten = splitToolPrefixRewriteBuffer(text);
488
+ pending = rewritten.pending;
489
+ if (rewritten.ready)
490
+ controller.enqueue(encoder.encode(rewritten.ready));
443
491
  },
444
492
  });
445
493
  return new Response(stream, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/opencode-anthropic-auth",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,6 +40,6 @@
40
40
  "@opencode-ai/plugin": "*"
41
41
  },
42
42
  "dependencies": {
43
- "@cortexkit/anthropic-auth-core": "1.0.0"
43
+ "@cortexkit/anthropic-auth-core": "1.1.1"
44
44
  }
45
45
  }