diff --git a/CHANGELOG.md b/CHANGELOG.md index cb67a3b1..8f72b12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. The OpenCode package is a fork of the original `@ex-machina/opencode-anthropic-auth` plugin, so older entries below the initial CortexKit release are inherited from upstream package history. +## Unreleased + +- Claude subscription requests no longer carry a `service_tier` field a host adds, keeping them in the shape Claude Code sends; API-key requests keep it. ## 2.7.3 - Vault setup now names the plugin when it asks for access ("Claude sign-in for OpenCode" or "for Pi"), so the vault's approval prompt and the apps' Permissions page show a readable name. Older vaults that don't know the field are retried without it. diff --git a/packages/core/src/cachekeep.ts b/packages/core/src/cachekeep.ts index e875449e..72da26d2 100644 --- a/packages/core/src/cachekeep.ts +++ b/packages/core/src/cachekeep.ts @@ -384,7 +384,7 @@ export async function buildCacheKeepPrewarmBody( } const signedBodyText = await signRequestBody( - JSON.stringify(orderClaudeCodeBody(warm)), + JSON.stringify(orderClaudeCodeBody(warm, { isOAuth: true })), ) return { ok: true, bodyText: signedBodyText } } diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index 8e36a02e..fffde646 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -557,12 +557,15 @@ const BODY_FIELD_ORDER = [ export function orderClaudeCodeBody>( body: T, + options: { isOAuth?: boolean } = {}, ): T { const ordered: Record = {} for (const key of BODY_FIELD_ORDER) { if (Object.hasOwn(body, key)) ordered[key] = body[key] } for (const [key, value] of Object.entries(body)) { + // Claude Code does not select a service tier on subscription requests. + if (options.isOAuth && key === 'service_tier') continue if (!Object.hasOwn(ordered, key)) ordered[key] = value } return ordered as T diff --git a/packages/opencode/src/tests/opencode2-adapter.test.ts b/packages/opencode/src/tests/opencode2-adapter.test.ts index 1bc461c8..d3df14c6 100644 --- a/packages/opencode/src/tests/opencode2-adapter.test.ts +++ b/packages/opencode/src/tests/opencode2-adapter.test.ts @@ -13,6 +13,7 @@ import { RELOCATED_MESSAGE, RelocatedError, RequestGzipLatch, + resetBillingHeaderCCH, signRequestBody, TRAILING_ASSISTANT_HISTORY_MESSAGE, USER_AGENT, @@ -476,6 +477,39 @@ const bodyOf = async (request: Request) => JSON.parse(await request.clone().text()) as InspectedBody describe('OpenCode 2 native Anthropic adapter: request shape', () => { + test.each(['oauth', 'api'] as const)( + 'service_tier follows the OpenCode 2 %s route', + async (route) => { + const { source } = makeSource({ + ...(route === 'api' + ? { + authorize: () => + apiKey('synthetic-key', { custody: 'api', receipt: 'api' }), + } + : {}), + }) + const native = createNativeAnthropicAdapter({ source }) + try { + const host = new Host(native.adapter) + const payload = { + ...sonnetPayload(), + service_tier: route === 'oauth' ? 'auto' : 'standard_only', + } + const { final } = await host.run(scopeOf(), hostRequest(payload)) + const sent = await final.text() + if (route === 'oauth') { + expect(JSON.parse(sent)).not.toHaveProperty('service_tier') + expect(await signRequestBody(resetBillingHeaderCCH(sent))).toBe(sent) + } else { + expect(JSON.parse(sent).service_tier).toBe('standard_only') + expect(sent).toBe(JSON.stringify(payload)) + } + } finally { + native.dispose() + } + }, + ) + test('OC2 runner notes use the adapter session on OAuth and report API skips without changing bytes', async () => { const reports: unknown[] = [] const hostModelId = 'claude-opus-5-5[1m]' diff --git a/packages/opencode/src/tests/request-history.integration.test.ts b/packages/opencode/src/tests/request-history.integration.test.ts index 966ff95b..9066c2cb 100644 --- a/packages/opencode/src/tests/request-history.integration.test.ts +++ b/packages/opencode/src/tests/request-history.integration.test.ts @@ -69,6 +69,110 @@ function readAccountStorage(): Promise { return readAccountStorageWithCtx(testContext) } +describe('OpenCode 1 service_tier shaping', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test.each(['oauth', 'api fallback'] as const)( + 'service_tier follows the OpenCode 1 %s route', + async (route) => { + await useTempAccountFile( + createFallbackStorage({ + fallbackOn: [429], + refresh: { + enabled: false, + intervalMinutes: 10, + refreshBeforeExpiryMinutes: 30, + }, + quota: { + enabled: false, + checkIntervalMinutes: 5, + minimumRemaining: {}, + failClosedOnUnknownQuota: false, + }, + accounts: [ + { + id: 'service-tier-api', + type: 'api', + apiKey: 'synthetic-service-tier-key', + baseURL: 'https://paid.invalid', + authHeader: 'x-api-key', + }, + ], + }), + ) + const sent: Array<{ headers: Headers; body: Record }> = + [] + globalThis.fetch = mock( + withNativeAdmission( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/claude_cli/bootstrap')) + return Promise.resolve( + Response.json({ + oauth_account: { account_uuid: 'service-tier-account' }, + }), + ) + if (url.includes('/api/oauth/usage')) + return Promise.resolve( + Response.json({ + five_hour: { utilization: 100 }, + seven_day: { utilization: 50 }, + }), + ) + if (url.includes('/v1/messages')) { + const headers = new Headers(init?.headers) + sent.push({ headers, body: JSON.parse(String(init?.body)) }) + return Promise.resolve( + new Response('{}', { + status: + route === 'api fallback' && headers.has('authorization') + ? 429 + : 200, + }), + ) + } + throw new Error(`Unexpected service-tier fixture request: ${url}`) + }, + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + try { + const auth = await plugin.auth.loader( + () => Promise.resolve({ type: 'oauth', ...syntheticMainHostAuth() }), + { models: {} }, + ) + const response = await auth.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-4-8', + max_tokens: 64, + service_tier: route === 'oauth' ? 'auto' : 'standard_only', + messages: [{ role: 'user', content: 'Hello' }], + }), + }) + expect(response.status).toBe(200) + expect(sent).toHaveLength(route === 'oauth' ? 1 : 2) + expect(sent[0]?.headers.get('authorization')).toBe( + 'Bearer sk-ant-oat01-main-access', + ) + expect(sent[0]?.body).not.toHaveProperty('service_tier') + if (route === 'api fallback') { + expect(sent[1]?.headers.get('x-api-key')).toBe( + 'synthetic-service-tier-key', + ) + expect(sent[1]?.body.service_tier).toBe('standard_only') + } + } finally { + await plugin.dispose?.() + } + }, + ) +}) + describe('Fable 5.1 request-scoped effort history', () => { const originalFetch = globalThis.fetch diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index fedf9cbc..a5b40bda 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -288,7 +288,10 @@ export function setOAuthHeaders( * Add TOOL_PREFIX to tool names in the request body. * Prefixes both tool definitions and tool_use blocks in messages. */ -export function prefixToolNames(parsed: Record): string { +export function prefixToolNames( + parsed: Record, + options: { isOAuth?: boolean } = {}, +): string { if (parsed.tools && Array.isArray(parsed.tools)) { parsed.tools = parsed.tools.map( (tool: { name?: string; [k: string]: unknown }) => ({ @@ -330,7 +333,7 @@ export function prefixToolNames(parsed: Record): string { ) } - return JSON.stringify(orderClaudeCodeBody(parsed)) + return JSON.stringify(orderClaudeCodeBody(parsed, options)) } /** @@ -1760,7 +1763,7 @@ export async function rewriteRequestBody( const prefixStart = rewriteNowMs() if (options.modelRemapEnabled === true) remapRequestBodyModel(parsed) const toolNameMap = responseToolNames(parsed.tools) - const prefixed = prefixToolNames(parsed) + const prefixed = prefixToolNames(parsed, { isOAuth: options.isOAuth }) options.perf?.('prefix_tools_stringify', { ms: rewriteRoundMs(rewriteNowMs() - prefixStart), outputBytesBeforeSign: prefixed.length, diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index a4125fa4..8490e826 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -844,7 +844,9 @@ export async function buildAnthropicRequest( throw new Error( `Anthropic accepts at most 4 cache breakpoints; the onPayload hook left ${breakpoints}`, ) - const unsigned = JSON.stringify(orderClaudeCodeBody(body)) + const unsigned = JSON.stringify( + orderClaudeCodeBody(body, { isOAuth: !!identity }), + ) const bodyText = controls.deferSigning ? unsigned : await signRequestBody(unsigned) diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 8aa7771e..bd3dbfc9 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -727,7 +727,7 @@ function buildPiPrimeBody(identity?: ClaudeCodeIdentity) { if (identity) applyClaudeCodeMetadata(body, identity) return { body, - bodyText: JSON.stringify(orderClaudeCodeBody(body)), + bodyText: JSON.stringify(orderClaudeCodeBody(body, { isOAuth: true })), toolNames: new Map(), cacheTtlMs: 0, } diff --git a/packages/pi/src/tests/request-history-stream.test.ts b/packages/pi/src/tests/request-history-stream.test.ts index 377be93e..777b36a9 100644 --- a/packages/pi/src/tests/request-history-stream.test.ts +++ b/packages/pi/src/tests/request-history-stream.test.ts @@ -188,12 +188,19 @@ test('Pi API-key requests never use message threads', async () => { }, apiAccount, storagePath, - streamOptions: { sessionId: 'pi-api-thread' }, + streamOptions: { + sessionId: 'pi-api-thread', + onPayload: (payload) => ({ + ...(payload as Record), + service_tier: 'standard_only', + }), + }, }, { fetch: fetchImpl }, ) ).text() expect(JSON.parse(String(sent?.body)).thread).toBeUndefined() + expect(JSON.parse(String(sent?.body)).service_tier).toBe('standard_only') expect(new Headers(sent?.headers).get('anthropic-beta')).not.toContain( 'message-threads-', ) diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index 041fbf03..603f3dfd 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -2497,6 +2497,15 @@ describe('Pi onPayload hook', () => { return sent } + test('Pi OAuth strips service_tier added by the payload hook before signing', async () => { + const sent = await capture((payload) => ({ + ...(payload as Record), + service_tier: 'auto', + })) + expect(JSON.parse(sent)).not.toHaveProperty('service_tier') + expect(await signRequestBody(resetBillingHeaderCCH(sent))).toBe(sent) + }) + test('sends the payload the hook returns, signed after the hook', async () => { const seen: Array<{ payload: Record; model: unknown }> = [] const sent = await capture((payload, model) => {