Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/cachekeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,12 +557,15 @@ const BODY_FIELD_ORDER = [

export function orderClaudeCodeBody<T extends Record<string, unknown>>(
body: T,
options: { isOAuth?: boolean } = {},
): T {
const ordered: Record<string, unknown> = {}
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
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/src/tests/opencode2-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
RELOCATED_MESSAGE,
RelocatedError,
RequestGzipLatch,
resetBillingHeaderCCH,
signRequestBody,
TRAILING_ASSISTANT_HISTORY_MESSAGE,
USER_AGENT,
Expand Down Expand Up @@ -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]'
Expand Down
104 changes: 104 additions & 0 deletions packages/opencode/src/tests/request-history.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,110 @@ function readAccountStorage(): Promise<AccountStorage | null> {
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<string, unknown> }> =
[]
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

Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string {
export function prefixToolNames(
parsed: Record<string, unknown>,
options: { isOAuth?: boolean } = {},
): string {
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = parsed.tools.map(
(tool: { name?: string; [k: string]: unknown }) => ({
Expand Down Expand Up @@ -330,7 +333,7 @@ export function prefixToolNames(parsed: Record<string, unknown>): string {
)
}

return JSON.stringify(orderClaudeCodeBody(parsed))
return JSON.stringify(orderClaudeCodeBody(parsed, options))
}

/**
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/pi/src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(),
cacheTtlMs: 0,
}
Expand Down
9 changes: 8 additions & 1 deletion packages/pi/src/tests/request-history-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
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-',
)
Expand Down
9 changes: 9 additions & 0 deletions packages/pi/src/tests/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>),
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<string, any>; model: unknown }> = []
const sent = await capture((payload, model) => {
Expand Down
Loading