From b5d77c347d89871c3489b4541bcfd9cec7c409dc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:21:40 +0200 Subject: [PATCH 1/2] feat(opencode): capture cache-diagnostics and usage cache accounting per request The plugin has sent cache-diagnosis-2026-04-07 in its beta list on every request without enabling the feature: the beta requires a request-body opt-in (diagnostics.previous_message_id), and nothing sent it or read the response. Turn the dormant channel on, measure-only. Request side: eligible OAuth requests gain the diagnostics opt-in inside the fail-closed rewriteRequestBody pipeline. The id sent is only ever one captured from a prior Anthropic response (bounded per-session tracker) - opencode mints its own msg_01-shaped ids for every provider, and a foreign id fails silently as previous_message_not_found. Response side: the existing SSE wrapper exposes message_start.message through a typed callback; per valid eligible response one MC-CACHE-DIAG single-line JSON record (schema v:2) is emitted via the logger with verbatim usage, TTL-bucket accounting, diag_state (absent|server_null|pending|populated, always a string), populated cache_miss_reason.type, and attribution fields: source (open set) + synthetic (closed machinery/traffic split with published mapping), account_id (opaque persisted identifier; consumer timelines key on (account_id, prefix) because sticky routing migrates sessions across account-scoped caches), betas_hash (xxh64 of the sorted sent beta list, resolved by a once-per-hash MC-CACHE-DIAG-BETAS side-channel line), and requested_model present only on request/served divergence. Dumps: responses gain artifacts (status/id/model/usage/diagnostics, never content); CacheKeep prewarms are dumped tagged -prewarm-cachekeep- and emit records through the same chain, since any write resets the server TTL clock. Canary: short-gap previous_message_not_found with a genuinely-sent id logs a warn - the id capture broke, not a fingerprint expiry. Every per-chunk stateful consumer in the response wrapper is bounded at 8 MiB with drain-before-cap semantics; complete frames are processed before overflow passthrough engages. Verified live: null(write=24831) -> null(read=24831, hit) -> system_changed(cache_missed_input_tokens=23963) on a forced system change against a warm prefix. Comparison engages only on cacheable requests; consumers classify hit-first (documented in README, which is the record contract). Closes #157 --- CHANGELOG.md | 2 + packages/core/src/cachekeep.ts | 84 ++- packages/core/src/cch.ts | 6 + packages/core/src/claude-code.ts | 9 +- packages/core/src/dump.ts | 80 ++- packages/core/src/relay.ts | 7 +- packages/core/src/tests/dump.test.ts | 166 ++++- packages/opencode/README.md | 70 ++ packages/opencode/src/cache-diagnostics.ts | 325 +++++++++ packages/opencode/src/index.ts | 415 ++++++++++-- packages/opencode/src/server-fallback.ts | 44 +- .../src/tests/cache-diagnostics.test.ts | 402 +++++++++++ packages/opencode/src/tests/cachekeep.test.ts | 137 ++++ packages/opencode/src/tests/index.test.ts | 641 ++++++++++++++++++ .../opencode/src/tests/plugin-exports.test.ts | 8 +- packages/opencode/src/tests/relay.test.ts | 46 ++ .../src/tests/server-fallback.test.ts | 56 ++ packages/opencode/src/tests/transform.test.ts | 378 +++++++++++ packages/opencode/src/transform.ts | 210 +++++- 19 files changed, 2987 insertions(+), 99 deletions(-) create mode 100644 packages/opencode/src/cache-diagnostics.ts create mode 100644 packages/opencode/src/tests/cache-diagnostics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c54772..172d35a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ## Unreleased +- Capture Anthropic cache diagnostics in versioned `MC-CACHE-DIAG ` records, preserve provider response IDs across requests and cachekeep prewarms, and write response/request dump artifacts without response content. Document the beta states and known fingerprint, organization, workspace, and beta-set limitations. + ## 1.19.1 ### Patch Changes diff --git a/packages/core/src/cachekeep.ts b/packages/core/src/cachekeep.ts index 3d9621ad..8707a368 100644 --- a/packages/core/src/cachekeep.ts +++ b/packages/core/src/cachekeep.ts @@ -2,6 +2,7 @@ import type { AccountStorage } from './accounts.ts' import type { CacheKeepTrackedSession } from './cachekeep-registry.ts' import { signRequestBody } from './cch.ts' import { orderClaudeCodeBody } from './claude-code.ts' +import { dumpDirectRequest, dumpResponseArtifact } from './dump.ts' import { logger } from './logger.ts' export const CLAUDE_CACHE_KEEP_COMMAND_NAME = 'claude-cachekeep' @@ -328,6 +329,7 @@ export type CacheKeepTarget = { cacheExpiresAt: number dayKey: string oauthAccountId?: string + isSubagent: boolean } export type CacheKeepPrewarmResult = @@ -360,6 +362,17 @@ export class CacheKeepManager { onTrackedSessionsChanged?: ( sessions: readonly CacheKeepTrackedSession[], ) => Promise | void + prepareBody?: ( + bodyText: string, + target: CacheKeepTarget, + ) => string | Promise + onResponse?: (input: { + target: CacheKeepTarget + bodyText: string + status: number + data: unknown + receivedAt: number + }) => void | Promise }, ) {} @@ -482,6 +495,7 @@ export class CacheKeepManager { storage: AccountStorage | null cacheMode: string oauthAccountId?: string + isSubagent?: boolean }) { if (!input.sessionId) return { tracked: false, reason: 'missing session id' } @@ -517,6 +531,7 @@ export class CacheKeepManager { cacheExpiresAt: now + CACHE_KEEP_TTL_MS, dayKey: today, oauthAccountId: input.oauthAccountId, + isSubagent: input.isSubagent ?? false, }) this.pruneTargets(now, today) this.publishTrackedSessions() @@ -530,6 +545,7 @@ export class CacheKeepManager { headers: Headers bodyText: string oauthAccountId?: string + isSubagent?: boolean }): Promise { const headers: Record = {} input.headers.forEach((value, key) => { @@ -543,6 +559,7 @@ export class CacheKeepManager { cacheExpiresAt: this.options.now?.() ?? Date.now(), dayKey: '', oauthAccountId: input.oauthAccountId, + isSubagent: input.isSubagent ?? false, } return this.sendPrewarm(target) } @@ -584,11 +601,23 @@ export class CacheKeepManager { private async sendPrewarm( target: CacheKeepTarget, ): Promise { - const prewarm = await buildCacheKeepPrewarmBody(target.bodyText) + let bodyText = target.bodyText + if (this.options.prepareBody) { + try { + bodyText = await this.options.prepareBody(bodyText, target) + } catch (error) { + logger.warn('cachekeep', 'prepare body failed', { + session: target.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + const preparedTarget = { ...target, bodyText } + const prewarm = await buildCacheKeepPrewarmBody(bodyText) if (!prewarm.ok) return prewarm const fetchImpl = this.options.fetchImpl ?? fetch - const prewarmTarget = { ...target, bodyText: prewarm.bodyText } + const prewarmTarget = { ...preparedTarget, bodyText: prewarm.bodyText } const headers = this.options.prepareHeaders ? await this.options.prepareHeaders( new Headers(target.headers), @@ -605,21 +634,54 @@ export class CacheKeepManager { this.options.prewarmTimeoutMs ?? CACHE_KEEP_PREWARM_TIMEOUT_MS, ), }) + const receivedAt = this.options.now?.() ?? Date.now() + const raw = await response.text().catch(() => '') + let data: unknown = null + try { + data = raw ? JSON.parse(raw) : null + } catch {} + try { + await this.options.onResponse?.({ + target, + bodyText: prewarm.bodyText, + status: response.status, + data, + receivedAt, + }) + } catch {} + try { + const dumpHandle = await dumpDirectRequest({ + affinity: target.id, + route: 'cachekeep', + status: response.status, + bodyText: prewarm.bodyText, + url: target.url, + method: 'POST', + headers, + tag: 'cachekeep', + }) + await dumpResponseArtifact(dumpHandle, { + status: response.status, + message: data, + }) + } catch (error) { + logger.debug('cachekeep', 'dump failed', { + session: target.id, + error: error instanceof Error ? error.message : String(error), + }) + } if (!response.ok) { return { ok: false, - reason: await response - .text() - .catch(() => '') - .then((body) => body || `HTTP ${response.status}`), + reason: raw || `HTTP ${response.status}`, status: response.status, } } - const data = (await response.json().catch(() => null)) as Record< - string, - unknown - > | null - const usage = data?.usage as + const objectData = + data && typeof data === 'object' && !Array.isArray(data) + ? (data as Record) + : null + const usage = objectData?.usage as | { input_tokens?: number cache_creation_input_tokens?: number diff --git a/packages/core/src/cch.ts b/packages/core/src/cch.ts index 4cc8243f..2904d012 100644 --- a/packages/core/src/cch.ts +++ b/packages/core/src/cch.ts @@ -58,6 +58,12 @@ export async function computeCCH(bodyBytes: Uint8Array): Promise { return (hash & 0xfffffn).toString(16).padStart(5, '0') } +export async function computeXxhash64Hex(value: string): Promise { + await ensureXxhash() + const hash = xxhash64Raw?.(new TextEncoder().encode(value), 0n) ?? 0n + return hash.toString(16).padStart(16, '0').slice(0, 16) +} + export function resetBillingHeaderCCH(bodyString: string): string { return bodyString.replace(BILLING_HEADER_CCH_PATTERN, `$1${CCH_PLACEHOLDER}`) } diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index a9c34d8e..54321a6b 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -18,8 +18,13 @@ export type ClaudeCodeIdentity = { const IDENTITY_CACHE_LIMIT = 1_000 const identityCache = new Map() -function setBounded(map: Map, key: K, value: V) { - if (!map.has(key) && map.size >= IDENTITY_CACHE_LIMIT) { +export function setBounded( + map: Map, + key: K, + value: V, + limit = IDENTITY_CACHE_LIMIT, +) { + if (!map.has(key) && map.size >= limit) { const oldest = map.keys().next().value if (oldest !== undefined) map.delete(oldest) } diff --git a/packages/core/src/dump.ts b/packages/core/src/dump.ts index 00ea38f6..02699d50 100644 --- a/packages/core/src/dump.ts +++ b/packages/core/src/dump.ts @@ -29,7 +29,8 @@ const DEFAULT_DUMP_MAX_BYTES = 512 * 1024 * 1024 const DUMP_SWEEP_INTERVAL_MS = 5 * 60 * 1000 const DUMP_SWEEP_NEWNESS_FLOOR_MS = 60 * 1000 const DUMP_PARTIAL_STALE_MS = 10 * 60 * 1000 -const DUMP_ARTIFACT_SUFFIX_PATTERN = /\.(body|meta|relay|request)\.json$/ +const DUMP_ARTIFACT_SUFFIX_PATTERN = + /\.(body|meta|relay|request|response)\.json$/ // Earlier builds emitted five-digit counters; current counters grow without truncation. const DUMP_ARTIFACT_ID_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d{5,}-(.+)$/ @@ -54,6 +55,13 @@ export type DumpCommandAction = | { type: 'disable' } | { type: 'usage' } +export type DumpTag = 'cachekeep' + +export type DumpHandle = { + responsePath: string + tag?: DumpTag +} + export function isDumpEnabled() { return dumpEnabled } @@ -289,13 +297,15 @@ function shortAffinity(affinity: string) { return affinity.length <= 16 ? affinity : `${affinity.slice(0, 12)}…` } -function dumpFileSessionSegment(affinity: string) { +function dumpFileSessionSegment(affinity: string, maxLength = 80) { const normalized = affinity .trim() .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') if (!normalized) return 'session-unknown' - return normalized.length <= 80 ? normalized : normalized.slice(0, 80) + return normalized.length <= maxLength + ? normalized + : normalized.slice(0, maxLength) } function dumpRequestSegment(input: { @@ -311,6 +321,10 @@ function dumpRequestSegment(input: { return `direct${route}` } +function dumpTagSegment(tag: DumpTag | undefined) { + return tag ? `-prewarm-${tag}` : '' +} + function directDumpPreviousKey(input: { affinity?: string | null route?: string @@ -467,26 +481,30 @@ async function dumpRequest(input: { previousBodyText?: string payload?: unknown relayBytes?: number + tag?: DumpTag request?: { url?: string method?: string headers?: DumpHeaders } }) { - if (!dumpEnabled) return + if (!dumpEnabled) return null nextDumpId += 1 const affinity = input.affinity?.trim() || 'session-unknown' - const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(6, '0')}-${dumpFileSessionSegment(affinity)}-${dumpRequestSegment(input)}` + const tagSegment = dumpTagSegment(input.tag) + const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${String(nextDumpId).padStart(6, '0')}-${dumpFileSessionSegment(affinity, 80 - tagSegment.length)}${tagSegment}-${dumpRequestSegment(input)}` const dumpDir = getDumpDirectory() const prefix = join(dumpDir, id) const files: { body: string metadata: string + response: string relay?: string request?: string } = { body: `${prefix}.body.json`, metadata: `${prefix}.meta.json`, + response: `${prefix}.response.json`, } if (input.payload !== undefined) files.relay = `${prefix}.relay.json` if (input.request !== undefined) files.request = `${prefix}.request.json` @@ -503,6 +521,7 @@ async function dumpRequest(input: { route: input.route, status: input.status, error: input.error, + tag: input.tag, bodyBytes: input.bodyText.length, relayBytes: input.relayBytes, bodyHash: hashText(input.bodyText), @@ -551,6 +570,11 @@ async function dumpRequest(input: { relayLog( `dump failed: ${error instanceof Error ? error.message : String(error)}`, ) + return null + } + return { + responsePath: files.response, + ...(input.tag ? { tag: input.tag } : {}), } } @@ -563,11 +587,12 @@ export async function dumpDirectRequest(input: { url?: string method?: string headers?: DumpHeaders -}) { - if (!dumpEnabled) return + tag?: DumpTag +}): Promise { + if (!dumpEnabled) return null const previousKey = directDumpPreviousKey(input) const previousBodyText = directDumpPreviousBodies.get(previousKey) - await dumpRequest({ + const handle = await dumpRequest({ affinity: input.affinity, transport: 'direct', route: input.route, @@ -580,8 +605,10 @@ export async function dumpDirectRequest(input: { method: input.method, headers: input.headers, }, + tag: input.tag, }) rememberDirectDumpBody(previousKey, input.bodyText) + return handle } export async function dumpRelayRequest(input: { @@ -594,8 +621,9 @@ export async function dumpRelayRequest(input: { previousBodyText?: string payload: unknown relayBytes: number -}) { - await dumpRequest({ + tag?: DumpTag +}): Promise { + return dumpRequest({ affinity: input.affinity, transport: input.transport, protocol: input.protocol, @@ -605,5 +633,37 @@ export async function dumpRelayRequest(input: { previousBodyText: input.previousBodyText, payload: input.payload, relayBytes: input.relayBytes, + tag: input.tag, }) } + +export async function dumpResponseArtifact( + handle: DumpHandle | null, + input: { status: number; message: unknown }, +): Promise { + if (!handle) return + const message = + input.message != null && + typeof input.message === 'object' && + !Array.isArray(input.message) + ? (input.message as Record) + : {} + const artifact: Record = { status: input.status } + if (typeof message.id === 'string' && message.id.length > 0) + artifact.message_id = message.id + if (typeof message.model === 'string' && message.model.length > 0) + artifact.model = message.model + if (Object.hasOwn(message, 'usage')) artifact.usage = message.usage + if (Object.hasOwn(message, 'diagnostics')) + artifact.diagnostics = message.diagnostics + try { + await writeDumpFile( + handle.responsePath, + `${JSON.stringify(artifact, null, 2)}\n`, + ) + } catch (error) { + relayLog( + `dump response failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} diff --git a/packages/core/src/relay.ts b/packages/core/src/relay.ts index 5fb0169d..085cd46d 100644 --- a/packages/core/src/relay.ts +++ b/packages/core/src/relay.ts @@ -1109,6 +1109,7 @@ export async function sendViaRelay(options: { * within an attempt are ignored. */ onResponseHeaders?: (headers: Headers) => void + onDumpCreated?: (handle: { responsePath: string; tag?: 'cachekeep' }) => void setTimeoutImpl?: typeof globalThis.setTimeout clearTimeoutImpl?: typeof globalThis.clearTimeout }): Promise { @@ -1122,6 +1123,7 @@ export async function sendViaRelay(options: { affinity: explicitAffinity, optimisticResponse, onResponseHeaders, + onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, } = options @@ -1237,7 +1239,7 @@ export async function sendViaRelay(options: { `used relay transport=${result.transport} protocol=${result.protocol} mode=${result.payload.mode} status=${result.response.status} session=${shortAffinity(affinity)} bodyBytes=${bodyText.length} relayBytes=${actualPayloadBytes}`, ) const dumpStart = perfNowMs() - await dumpRelayRequest({ + const dumpHandle = await dumpRelayRequest({ affinity, transport: result.transport, protocol: result.protocol, @@ -1248,6 +1250,9 @@ export async function sendViaRelay(options: { payload: result.payload, relayBytes: actualPayloadBytes, }) + try { + if (dumpHandle) onDumpCreated?.(dumpHandle) + } catch {} relayPerfLog('dump', { session: shortAffinity(affinity), ms: formatMs(perfNowMs() - dumpStart), diff --git a/packages/core/src/tests/dump.test.ts b/packages/core/src/tests/dump.test.ts index 2be787c9..349f1213 100644 --- a/packages/core/src/tests/dump.test.ts +++ b/packages/core/src/tests/dump.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { + chmod, lstat, mkdir, mkdtemp, @@ -13,7 +14,15 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { sweepDumpDirectory, writeDumpFile } from '../dump' +import { + dumpDirectRequest, + dumpRelayRequest, + dumpResponseArtifact, + resetDumpState, + setDumpEnabled, + sweepDumpDirectory, + writeDumpFile, +} from '../dump' const dumpDirs: string[] = [] const dumpLinks: string[] = [] @@ -22,12 +31,13 @@ const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR function dumpArtifactName( id: number, - kind: 'body' | 'meta' | 'relay' | 'request' = 'body', + kind: 'body' | 'meta' | 'relay' | 'request' | 'response' = 'body', ) { return `2026-07-17T12-00-00-000Z-${String(id).padStart(6, '0')}-session-direct.${kind}.json` } afterEach(async () => { + resetDumpState() if (originalDumpMaxBytes === undefined) { delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_MAX_BYTES } else { @@ -48,6 +58,158 @@ afterEach(async () => { ) }) +test('request dumps return response handles only when enabled', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + + expect( + await dumpDirectRequest({ + affinity: 'ses-a', + bodyText: '{}', + route: 'oauth', + }), + ).toBeNull() + setDumpEnabled(true) + const direct = await dumpDirectRequest({ + affinity: 'ses-a', + bodyText: '{}', + route: 'oauth', + }) + const relay = await dumpRelayRequest({ + affinity: 'ses-b', + transport: 'http', + protocol: 1, + mode: 'full_sync', + bodyText: '{}', + payload: {}, + relayBytes: 2, + }) + expect(direct?.responsePath).toMatch(/\.response\.json$/) + expect(relay?.responsePath).toMatch(/\.response\.json$/) + expect(await lstat(direct!.responsePath).catch(() => null)).toBeNull() +}) + +test('sweeps tagged dumps with a maximum-length affinity segment', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + + await dumpDirectRequest({ + affinity: 'a'.repeat(80), + bodyText: '{"messages":[]}', + tag: 'cachekeep', + }) + + expect(await readdir(dumpDir)).not.toEqual([]) + const result = await sweepDumpDirectory({ + dumpDir, + maxBytes: 1, + minAgeMs: 0, + now: Date.now() + 1, + }) + + expect(result.removed).toBeGreaterThan(0) + expect(await readdir(dumpDir)).toEqual([]) +}) + +test('failed request dumps return no handle or orphan response artifact', async () => { + if (process.getuid?.() === 0) return + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + await chmod(dumpDir, 0o500) + try { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + const handle = await dumpDirectRequest({ + affinity: 'ses-failure', + bodyText: '{}', + }) + expect(handle).toBeNull() + expect( + (await readdir(dumpDir)).some((name) => name.endsWith('.response.json')), + ).toBe(false) + } finally { + await chmod(dumpDir, 0o700) + } +}) + +test('response artifacts sanitize message fields and preserve diagnostics presence', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + const handle = await dumpDirectRequest({ affinity: 'ses-a', bodyText: '{}' }) + expect(handle).not.toBeNull() + await dumpResponseArtifact(handle, { + status: 200, + message: { + id: 'msg_provider', + model: 'claude-opus-4-7', + usage: { input_tokens: 1 }, + diagnostics: null, + content: [{ text: 'secret' }], + }, + }) + const artifact = JSON.parse(await readFile(handle!.responsePath, 'utf8')) + expect(artifact).toEqual({ + status: 200, + message_id: 'msg_provider', + model: 'claude-opus-4-7', + usage: { input_tokens: 1 }, + diagnostics: null, + }) + expect(JSON.stringify(artifact)).not.toContain('secret') +}) + +test('tagged prewarm dumps include the tag in filenames and metadata', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + const handle = await dumpDirectRequest({ + affinity: 'ses-a', + bodyText: '{}', + tag: 'cachekeep', + }) + expect(handle?.tag).toBe('cachekeep') + expect(handle?.responsePath).toContain('-prewarm-cachekeep-') + const files = await readdir(dumpDir) + const metadata = JSON.parse( + await readFile( + join(dumpDir, files.find((name) => name.endsWith('.meta.json'))!), + 'utf8', + ), + ) + expect(metadata.tag).toBe('cachekeep') +}) + +test('dump sweep recognizes response artifacts', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + const response = join(dumpDir, dumpArtifactName(1, 'response')) + await writeFile(response, '12345678') + await utimes(response, new Date(1_000), new Date(1_000)) + expect(await sweepDumpDirectory({ dumpDir, maxBytes: 1 })).toEqual({ + removed: 1, + freedBytes: 8, + }) +}) + test('dump sweep deletes oldest files until the directory is under its cap', async () => { const dumpDir = await mkdtemp( join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), diff --git a/packages/opencode/README.md b/packages/opencode/README.md index a2ab7709..5fc4ace6 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -393,6 +393,76 @@ Request bodies, headers, and tokens remain in memory. A lease-backed file under Pre-warm requests preserve explicit cache anchors but remove response-only fields that Anthropic rejects with `max_tokens: 0`, such as streaming, enabled thinking, structured output format, and forced/any tool choice. The feature works only while OpenCode or Pi is running and the machine is awake, and cache writes are still billed when the cache entry is no longer warm. +### Cache diagnostics (beta) + +The `cache-diagnosis-2026-04-07` beta is measure-only. It asks Anthropic to report prompt-cache diagnostics; it does not change cache controls or routing. OpenCode captures the provider's top-level response ID as an opaque string and sends it as `diagnostics.previous_message_id` on the next request in the same session. The first request sends `null`. + +The `MC-CACHE-DIAG ` line is a versioned, one-line JSON record. Version `2` contains these fields: + +| Field | Type | Source | +| --- | --- | --- | +| `v` | `2` | Capture schema | +| `source` | string | Observation path; known values are `"turn"` and `"prewarm_cachekeep"`, but consumers must tolerate future values | +| `synthetic` | boolean | Whether this observation was generated by plugin machinery rather than a real turn | +| `account_id` | string | Persisted plugin-internal OAuth account identifier used by routing and the sidebar; stable across restarts and token refreshes, so consumers may key timelines on it. Opaque mixed key space (the main account is a sentinel string, fallbacks are UUIDs) — never validate its shape | +| `betas_hash` | 16-character lowercase hex | xxHash64 (seed `0`) of the sorted `anthropic-beta` list actually sent, truncated to its first 16 hexadecimal characters | +| `requested_model` | string, optional | Sent request-body model, present only when it differs from the served Anthropic response model | +| `session_id` | string | OpenCode session affinity | +| `ts_ms_received` | integer | Local response receipt time | +| `model` | string | Anthropic response | +| `is_subagent` | boolean | Original request context | +| `ttl_sent` | `"1h" \| "5m" \| null` | Last valid cache breakpoint in the sent body | +| `cache_read` | number | `usage.cache_read_input_tokens` | +| `cache_creation` | number | `usage.cache_creation_input_tokens` | +| `input_tokens` | number | `usage.input_tokens` | +| `ephemeral_5m_tokens` | number | `usage.cache_creation.ephemeral_5m_input_tokens` | +| `ephemeral_1h_tokens` | number | `usage.cache_creation.ephemeral_1h_input_tokens` | +| `message_id` | string | Anthropic response top-level `id` | +| `previous_message_id` | string \| null | Sent `diagnostics.previous_message_id` | +| `diag_state` | `"absent" \| "server_null" \| "pending" \| "populated"` | Anthropic response envelope | +| `miss_reason` | string, optional | `diagnostics.cache_miss_reason.type` | +| `cache_missed_input_tokens` | number, optional | `diagnostics.cache_miss_reason.cache_missed_input_tokens` | + +`cache_missed_input_tokens` is a byte-derived, pre-tokenization magnitude indicator, not a token count: it can differ from and occasionally exceed `input_tokens`, so never difference it against usage fields. + +All required usage counters must be finite, non-negative numbers. A malformed required counter rejects the whole record rather than emitting a partial receipt. + +Consumers classifying these records should check `cache_read` before `miss_reason`: `cache_read > 0` is a fact and `miss_reason` is an interpretation, and facts win. On multi-turn agent traffic a populated `*_changed` reason routinely coexists with a full prefix hit (each turn appends messages), so a populated-first classifier inverts every such record into a false miss. + +Known sources map to `synthetic` as follows: + +| `source` | `synthetic` | +| --- | --- | +| `turn` | `false` | +| `prewarm_cachekeep` | `true` | + +`synthetic` wins on conflict. A disagreement for a known source is an emitter defect: OpenCode writes one warning and still emits the record with the supplied `synthetic` value. Unknown future sources have no mapping and must not be rejected by consumers. + +The first observation of each `betas_hash` in a process also writes `MC-CACHE-DIAG-BETAS {"hash":"…","betas":[…]}` on the `cache-diagnostics` logger channel. Its sorted beta list makes an observed hash interpretable without reconstructing headers; repeated hashes do not emit another side-channel line. + +The trailing space in `MC-CACHE-DIAG ` is load-bearing: a record line has a space after `MC-CACHE-DIAG`, while the beta side-channel line has a hyphen. Do not trim the delimiter or write a trimming consumer. `grep -c "MC-CACHE-DIAG"` over-counts because it includes beta lines; count records with `grep -c "MC-CACHE-DIAG "` or an anchored equivalent. + +The four diagnostic states are: + +| State | Response shape | +| --- | --- | +| `absent` | No `diagnostics` property | +| `server_null` | `diagnostics: null` | +| `pending` | `diagnostics.cache_miss_reason: null` | +| `populated` | An object `diagnostics.cache_miss_reason` with a non-empty string `type`, including `"unavailable"` | + +Only valid Message envelopes produce an `MC-CACHE-DIAG ` record. Malformed or error responses are not mapped to `absent`; they retain ordinary response handling and dumps. `ttl_sent` is reduced from the last valid breakpoint in the actual body sent. A short-gap `previous_message_not_found` result is also recorded as a canary when the previous provider ID was captured less than five minutes earlier. + +`unavailable` is expected when any prompt-affecting parameter changes between requests. This includes the active Anthropic beta-header set; structured-output requests use a different beta set and can therefore produce `unavailable`. Diagnostics are scoped to the provider's cache fingerprint, organization, and workspace. Fingerprints expire, so a later request can miss after a long gap even when the body is unchanged. These records measure the result; they do not guarantee a cache hit. + +Diagnostics comparison requires a cacheable prefix. Requests below the model's cacheable minimum or without cache breakpoints return `diagnostics: null` even when the request genuinely changed; consumers must gate interpretation of `null` on observed cache activity. + +`diag_state` is always a string and never JSON null: `absent | server_null | pending | populated`. + +Version 1 records come from the unversioned-source era and cannot distinguish prewarms from turns; consumers must treat their source as unknown and cannot split machinery from traffic retroactively. Version 2 always states the source. + +When request dumps are enabled, each response gets a `.response.json` artifact containing status and parsed response metadata, but no response content. Cache keepalive prewarms are tagged `-prewarm-cachekeep` in dump filenames and metadata. If Prime prewarming is enabled in a build that supports it, those artifacts use `-prewarm-prime`. Treat request bodies and related dump files as sensitive local debugging data. + ## Claude fast mode Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: diff --git a/packages/opencode/src/cache-diagnostics.ts b/packages/opencode/src/cache-diagnostics.ts new file mode 100644 index 00000000..54a87a59 --- /dev/null +++ b/packages/opencode/src/cache-diagnostics.ts @@ -0,0 +1,325 @@ +import { + setBounded, + stickyRetryAfterWithJitter, +} from '@cortexkit/anthropic-auth-core' + +export const CACHE_DIAGNOSTICS_BETA = 'cache-diagnosis-2026-04-07' +export const CACHE_DIAGNOSTICS_LOG_PREFIX = 'MC-CACHE-DIAG ' +export const CACHE_DIAGNOSTICS_SESSION_LIMIT = 1_000 +export const CACHE_DIAGNOSTICS_SHORT_GAP_MS = 5 * 60_000 + +export type CacheDiagnosticsState = + | 'absent' + | 'server_null' + | 'pending' + | 'populated' + +export type CacheDiagnosticsSource = string + +export const CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC = { + turn: false, + prewarm_cachekeep: true, +} as const + +export const CACHE_DIAGNOSTICS_BETAS_LIMIT = 1_000 + +export type CacheDiagnosticsRecord = { + v: 2 + source: CacheDiagnosticsSource + account_id: string + synthetic: boolean + betas_hash: string + requested_model?: string + session_id: string + ts_ms_received: number + model: string + is_subagent: boolean + ttl_sent: '1h' | '5m' | null + cache_read: number + cache_creation: number + input_tokens: number + ephemeral_5m_tokens: number + ephemeral_1h_tokens: number + message_id: string + previous_message_id: string | null + diag_state: CacheDiagnosticsState + miss_reason?: string + cache_missed_input_tokens?: number +} + +export type CacheDiagnosticsRequestContext = { + sessionId: string + previousMessageId: string | null + previousMessageReceivedAt?: number + isSubagent: boolean + ttlSent: '1h' | '5m' | null +} + +type CapturedMessage = { messageId: string; receivedAt: number } + +export class CacheDiagnosticsTracker { + private readonly entries = new Map() + + previousFor(sessionId: string): CapturedMessage | null { + return this.entries.get(sessionId) ?? null + } + + capture(sessionId: string, messageId: string, receivedAt: number): void { + setBounded( + this.entries, + sessionId, + { messageId, receivedAt }, + CACHE_DIAGNOSTICS_SESSION_LIMIT, + ) + } +} + +export class CacheDiagnosticsBetaTracker { + private readonly hashes = new Map() + + capture(hash: string, betas: string[]): string | null { + if (this.hashes.has(hash)) return null + setBounded(this.hashes, hash, true, CACHE_DIAGNOSTICS_BETAS_LIMIT) + return `MC-CACHE-DIAG-BETAS ${JSON.stringify({ + hash, + betas: [...betas].sort(), + })}` + } +} + +export function copyCacheDiagnosticsContext( + contexts: WeakMap, + source: Response, + destination: Response, +): void { + const context = contexts.get(source) + if (context) contexts.set(destination, context) +} + +export async function withStickyRetryAfter( + response: Response, + sessionId: string, + retryAfterSeconds: number, + streamingRateLimit: boolean, + contexts: WeakMap, +) { + const headers = new Headers(response.headers) + headers.set( + 'retry-after', + String(stickyRetryAfterWithJitter(sessionId, retryAfterSeconds)), + ) + if (streamingRateLimit) { + await response.body?.cancel().catch(() => {}) + headers.set('content-type', 'application/json') + return new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'rate_limit_error', + message: + 'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.', + }, + }), + { status: 429, headers }, + ) + } + const retriedResponse = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + copyCacheDiagnosticsContext(contexts, response, retriedResponse) + return retriedResponse +} + +export function applyCacheDiagnosticsOptIn( + body: Record, + previousMessageId: string | null, +): void { + body.diagnostics = { previous_message_id: previousMessageId } +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function isBreakpoint(value: unknown): value is Record { + return isRecord(value) && value.type === 'ephemeral' +} + +function appendBreakpoint(value: unknown, output: Array<'1h' | '5m'>) { + if (!isBreakpoint(value)) return + output.push(value.ttl === '1h' ? '1h' : '5m') +} + +function appendArrayBreakpoints(value: unknown, output: Array<'1h' | '5m'>) { + if (!Array.isArray(value)) return + for (const entry of value) { + if (isRecord(entry)) appendBreakpoint(entry.cache_control, output) + } +} + +export function summarizeCacheTtl(body: unknown): '1h' | '5m' | null { + if (!isRecord(body)) return null + const breakpoints: Array<'1h' | '5m'> = [] + appendBreakpoint(body.cache_control, breakpoints) + appendArrayBreakpoints(body.system, breakpoints) + if (Array.isArray(body.messages)) { + for (const message of body.messages) { + if (isRecord(message)) + appendArrayBreakpoints(message.content, breakpoints) + } + } + return breakpoints.at(-1) ?? null +} + +function classifyDiagnostics(message: Record): { + state: CacheDiagnosticsState + missReason?: string + cacheMissedInputTokens?: number +} | null { + if (!Object.hasOwn(message, 'diagnostics')) return { state: 'absent' } + const diagnostics = message.diagnostics + if (diagnostics === null) return { state: 'server_null' } + if (!isRecord(diagnostics)) return null + if (!Object.hasOwn(diagnostics, 'cache_miss_reason')) return null + const reason = diagnostics.cache_miss_reason + if (reason === null) return { state: 'pending' } + if (!isRecord(reason) || typeof reason.type !== 'string' || !reason.type) + return null + const missed = reason.cache_missed_input_tokens + if (missed !== undefined && typeof missed !== 'number') return null + return { + state: 'populated', + missReason: reason.type, + cacheMissedInputTokens: missed, + } +} + +function numericUsage( + usage: Record, + key: string, +): number | null { + const value = usage[key] + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : null +} + +export function buildCacheDiagnosticsRecord(input: { + request: CacheDiagnosticsRequestContext + source: CacheDiagnosticsSource + accountId: string + synthetic: boolean + betasHash: string + requestedModel?: string + onWarning?: (message: string) => void + message: unknown + receivedAt: number +}): { + record?: CacheDiagnosticsRecord + messageId?: string + canary?: { messageId: string; previousMessageId: string } +} { + if ( + typeof input.source !== 'string' || + input.source.length === 0 || + typeof input.accountId !== 'string' || + input.accountId.length === 0 || + typeof input.synthetic !== 'boolean' || + typeof input.betasHash !== 'string' || + input.betasHash.length === 0 + ) + return {} + const expectedSynthetic = + CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC[ + input.source as keyof typeof CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC + ] + if ( + expectedSynthetic !== undefined && + expectedSynthetic !== input.synthetic + ) { + input.onWarning?.( + `cache diagnostics source/synthetic mismatch: source=${input.source} expected=${expectedSynthetic} actual=${input.synthetic}`, + ) + } + const receivedAt = Math.trunc(input.receivedAt) + if (!Number.isFinite(input.receivedAt) || receivedAt !== input.receivedAt) + return {} + if (!isRecord(input.message)) return {} + const messageId = input.message.id + const model = input.message.model + const usage = input.message.usage + if (typeof messageId !== 'string' || messageId.length === 0) return {} + if (typeof model !== 'string' || model.length === 0 || !isRecord(usage)) + return {} + const inputTokens = numericUsage(usage, 'input_tokens') + const cacheRead = numericUsage(usage, 'cache_read_input_tokens') + const cacheCreation = numericUsage(usage, 'cache_creation_input_tokens') + const cacheCreationDetails = usage.cache_creation + if ( + inputTokens === null || + cacheRead === null || + cacheCreation === null || + !isRecord(cacheCreationDetails) + ) + return {} + const ephemeral5m = numericUsage( + cacheCreationDetails, + 'ephemeral_5m_input_tokens', + ) + const ephemeral1h = numericUsage( + cacheCreationDetails, + 'ephemeral_1h_input_tokens', + ) + if (ephemeral5m === null || ephemeral1h === null) return {} + const diagnostics = classifyDiagnostics(input.message) + if (!diagnostics) return {} + + const record: CacheDiagnosticsRecord = { + v: 2, + source: input.source, + account_id: input.accountId, + synthetic: input.synthetic, + betas_hash: input.betasHash, + session_id: input.request.sessionId, + ts_ms_received: receivedAt, + model, + is_subagent: input.request.isSubagent, + ttl_sent: input.request.ttlSent, + cache_read: cacheRead, + cache_creation: cacheCreation, + input_tokens: inputTokens, + ephemeral_5m_tokens: ephemeral5m, + ephemeral_1h_tokens: ephemeral1h, + message_id: messageId, + previous_message_id: input.request.previousMessageId, + diag_state: diagnostics.state, + } + if (diagnostics.missReason !== undefined) + record.miss_reason = diagnostics.missReason + if (diagnostics.cacheMissedInputTokens !== undefined) + record.cache_missed_input_tokens = diagnostics.cacheMissedInputTokens + if (input.requestedModel !== undefined && input.requestedModel !== model) + record.requested_model = input.requestedModel + + const canary = + diagnostics.missReason === 'previous_message_not_found' && + input.request.previousMessageId !== null && + input.request.previousMessageReceivedAt !== undefined && + input.receivedAt - input.request.previousMessageReceivedAt < + CACHE_DIAGNOSTICS_SHORT_GAP_MS && + input.receivedAt >= input.request.previousMessageReceivedAt + ? { + messageId, + previousMessageId: input.request.previousMessageId, + } + : undefined + return { record, messageId, ...(canary ? { canary } : {}) } +} + +export function formatCacheDiagnosticsLogLine( + record: CacheDiagnosticsRecord, +): string { + return `${CACHE_DIAGNOSTICS_LOG_PREFIX}${JSON.stringify(record)}` +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index abced905..5c5ec5a5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -25,10 +25,13 @@ import { CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + computeXxhash64Hex, createEmptyStorage, createStickyNoRouteResponse, + type DumpHandle, decideStickyQuotaFailure, dumpDirectRequest, + dumpResponseArtifact, exchange, executeAccountCommand, executeCache1hCommand, @@ -131,11 +134,23 @@ import { setRoutingMode, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, - stickyRetryAfterWithJitter, stickyRouteFamilyForModel, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import type { Plugin } from '@opencode-ai/plugin' +import { + applyCacheDiagnosticsOptIn, + buildCacheDiagnosticsRecord, + CACHE_DIAGNOSTICS_BETA, + CacheDiagnosticsBetaTracker, + type CacheDiagnosticsRequestContext, + type CacheDiagnosticsSource, + CacheDiagnosticsTracker, + copyCacheDiagnosticsContext, + formatCacheDiagnosticsLogLine, + summarizeCacheTtl, + withStickyRetryAfter, +} from './cache-diagnostics.ts' import { FableFallbackManager, type FableFallbackPlan, @@ -1162,6 +1177,150 @@ const anthropicAuthPlugin = async ( }, }) fallbackManager.startBackgroundRefresh() + const cacheDiagnosticsTracker = new CacheDiagnosticsTracker() + const cacheDiagnosticsBetaTracker = new CacheDiagnosticsBetaTracker() + type CacheDiagnosticsResponse = { + request?: CacheDiagnosticsRequestContext + trackSessionId?: string + source: CacheDiagnosticsSource + accountId: string + synthetic: boolean + betasHash: string + betas: string[] + requestedModel?: string + dump: DumpHandle | null + status: number + streaming: boolean + dumpWrite: Promise + } + const cacheDiagnosticsResponses = new WeakMap< + Response, + CacheDiagnosticsResponse + >() + const cacheKeepDiagnosticsRequests = new Map< + string, + CacheDiagnosticsRequestContext & { + accountId: string + synthetic: boolean + betasHash?: string + betas?: string[] + requestedModel?: string + } + >() + + async function getCacheDiagnosticsBetas(headers: Headers) { + const betas = (headers.get('anthropic-beta') ?? '') + .split(',') + .map((beta) => beta.trim()) + .filter(Boolean) + .sort() + return { + betas, + betasHash: await computeXxhash64Hex(betas.join(',')), + } + } + + function observeCacheDiagnosticsMessage(input: { + source: CacheDiagnosticsSource + accountId: string + synthetic: boolean + betasHash: string + betas: string[] + requestedModel?: string + request?: CacheDiagnosticsRequestContext + trackSessionId?: string + status: number + message: unknown + receivedAt: number + dump?: DumpHandle | null + dumpWrite?: Promise + }) { + try { + if (input.dumpWrite) { + void input.dumpWrite + .then(() => + dumpResponseArtifact(input.dump ?? null, { + status: input.status, + message: input.message, + }), + ) + .catch(() => {}) + } + if (!input.request) return + const observed = buildCacheDiagnosticsRecord({ + request: input.request, + source: input.source, + accountId: input.accountId, + synthetic: input.synthetic, + betasHash: input.betasHash, + requestedModel: input.requestedModel, + onWarning: (message) => logger.warn('cache-diagnostics', message), + message: input.message, + receivedAt: input.receivedAt, + }) + if (!observed.record || !observed.messageId) { + logger.debug('cache-diagnostics', 'skipped invalid response envelope', { + status: input.status, + }) + return + } + logger.info( + 'cache-diagnostics', + formatCacheDiagnosticsLogLine(observed.record), + ) + const betaLine = cacheDiagnosticsBetaTracker.capture( + input.betasHash, + input.betas, + ) + if (betaLine) logger.info('cache-diagnostics', betaLine) + if (observed.canary) { + logger.warn( + 'cache-diagnostics', + 'short-gap previous_message_not_found', + { + message_id: observed.canary.messageId, + previous_message_id: observed.canary.previousMessageId, + }, + ) + } + if (input.trackSessionId) { + cacheDiagnosticsTracker.capture( + input.trackSessionId, + observed.messageId, + input.receivedAt, + ) + } + } catch (error) { + logger.debug('cache-diagnostics', 'response observation failed', { + status: input.status, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + function observeCacheDiagnosticsResponse( + response: Response, + message: unknown, + ) { + const context = cacheDiagnosticsResponses.get(response) + if (!context) return + observeCacheDiagnosticsMessage({ + ...context, + message, + receivedAt: Date.now(), + }) + } + + function attachCacheDiagnosticsResponse( + response: Response, + input: Omit, + ) { + const dumpWrite = Promise.resolve( + dumpResponseArtifact(input.dump, { status: input.status, message: null }), + ).catch(() => {}) + cacheDiagnosticsResponses.set(response, { ...input, dumpWrite }) + } + let latestRefreshMainAccessToken: (() => Promise) | null = null const cacheKeepRegistry = new CacheKeepSessionRegistry({ directory: @@ -1179,6 +1338,82 @@ const anthropicAuthPlugin = async ( await cacheKeepRegistry.publish(sessions) aggregateCacheKeepSessions = await cacheKeepRegistry.list(sessions) }, + prepareBody: (bodyText, target) => { + if ( + !new Headers(target.headers) + .get('anthropic-beta') + ?.split(',') + .map((beta) => beta.trim()) + .includes(CACHE_DIAGNOSTICS_BETA) + ) { + return bodyText + } + try { + const body = JSON.parse(bodyText) as Record + const previous = cacheDiagnosticsTracker.previousFor(target.id) + const previousMessageId = previous?.messageId ?? null + applyCacheDiagnosticsOptIn(body, previousMessageId) + cacheKeepDiagnosticsRequests.set(target.id, { + sessionId: target.id, + previousMessageId, + ...(previous + ? { previousMessageReceivedAt: previous.receivedAt } + : {}), + isSubagent: target.isSubagent, + ttlSent: summarizeCacheTtl(body), + accountId: target.oauthAccountId ?? 'main', + synthetic: true, + requestedModel: + typeof body.model === 'string' ? body.model : undefined, + }) + return JSON.stringify(body) + } catch { + return bodyText + } + }, + onResponse: ({ target, bodyText, status, data, receivedAt }) => { + const prepared = cacheKeepDiagnosticsRequests.get(target.id) + if (!prepared?.betasHash || !prepared.betas) { + cacheKeepDiagnosticsRequests.delete(target.id) + return + } + try { + const sentBody = JSON.parse(bodyText) + const diagnostics = + sentBody && typeof sentBody === 'object' && !Array.isArray(sentBody) + ? (sentBody as { diagnostics?: unknown }).diagnostics + : undefined + const previousMessageId = + diagnostics && + typeof diagnostics === 'object' && + !Array.isArray(diagnostics) && + (diagnostics as { previous_message_id?: unknown }) + .previous_message_id === prepared.previousMessageId + ? prepared.previousMessageId + : undefined + if (previousMessageId === undefined) return + observeCacheDiagnosticsMessage({ + source: 'prewarm_cachekeep', + accountId: prepared.accountId, + synthetic: prepared.synthetic, + betasHash: prepared.betasHash, + betas: prepared.betas, + requestedModel: prepared.requestedModel, + request: { + ...prepared, + previousMessageId, + ttlSent: summarizeCacheTtl(sentBody), + }, + trackSessionId: target.id, + status, + message: data, + receivedAt, + }) + } catch { + } finally { + cacheKeepDiagnosticsRequests.delete(target.id) + } + }, prepareHeaders: async (headers, target) => { let accessToken: string | undefined const accountId = target.oauthAccountId @@ -1242,6 +1477,12 @@ const anthropicAuthPlugin = async ( ]), ) if (parsedBody.speed === 'fast') addFastModeBetaHeader(headers) + const prepared = cacheKeepDiagnosticsRequests.get(target.id) + if (prepared) { + const { betas, betasHash } = await getCacheDiagnosticsBetas(headers) + prepared.betas = betas + prepared.betasHash = betasHash + } } catch { setOAuthHeaders(headers, accessToken) } @@ -3252,12 +3493,18 @@ const anthropicAuthPlugin = async ( bytes, rateLimited: true, }) + const inspectedResponse = new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + copyCacheDiagnosticsContext( + cacheDiagnosticsResponses, + response, + inspectedResponse, + ) return { - response: new Response(stream, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }), + response: inspectedResponse, rateLimited: true, } } @@ -3284,12 +3531,18 @@ const anthropicAuthPlugin = async ( bytes, rateLimited: false, }) + const inspectedResponse = new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + copyCacheDiagnosticsContext( + cacheDiagnosticsResponses, + response, + inspectedResponse, + ) return { - response: new Response(stream, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }), + response: inspectedResponse, rateLimited: false, } } @@ -3332,6 +3585,8 @@ const anthropicAuthPlugin = async ( requestHeaders.delete('x-session-affinity') requestHeaders.delete('x-opencode-session') let body = init?.body + let streaming = false + let dump: DumpHandle | null = null const originalBytes = typeof body === 'string' ? body.length : undefined @@ -3358,6 +3613,9 @@ const anthropicAuthPlugin = async ( mergeAnthropicBetas(requestHeaders.get('anthropic-beta'), []), ) if (fastModeRequested) addFastModeBetaHeader(requestHeaders) + try { + streaming = JSON.parse(body).stream === true + } catch {} trace?.mark('rewrite_body', { route, ms: roundMs(nowMs() - rewriteStart), @@ -3372,6 +3630,8 @@ const anthropicAuthPlugin = async ( configureApiRouteHeaders(requestHeaders, account) } + const cacheDiagnosticsBetas = + await getCacheDiagnosticsBetas(requestHeaders) const rewritten = rewriteUrl(input, { baseURL: account.baseURL }) const sendStart = nowMs() let response: Response @@ -3398,7 +3658,7 @@ const anthropicAuthPlugin = async ( throw error } if (typeof body === 'string') { - await dumpDirectRequest({ + dump = await dumpDirectRequest({ affinity: directAffinity, route, status: response.status, @@ -3409,6 +3669,16 @@ const anthropicAuthPlugin = async ( headers: requestHeaders, }) } + attachCacheDiagnosticsResponse(response, { + source: 'turn', + accountId: account.id, + synthetic: false, + ...cacheDiagnosticsBetas, + requestedModel: parseRequestModel(body), + dump, + status: response.status, + streaming, + }) trace?.mark('send_headers_received', { route, ms: roundMs(nowMs() - sendStart), @@ -3445,6 +3715,17 @@ const anthropicAuthPlugin = async ( requestHeaders.delete('x-session-affinity') requestHeaders.delete('x-opencode-session') let body = init?.body + const previousDiagnosticsMessage = relayAffinity + ? cacheDiagnosticsTracker.previousFor(relayAffinity) + : null + const cacheDiagnosticsPreviousMessageId = + previousDiagnosticsMessage?.messageId ?? null + let cacheDiagnosticsRequest: + | CacheDiagnosticsRequestContext + | undefined + let streaming = false + let directDump: DumpHandle | null = null + let relayDump: DumpHandle | null = null let modelForIdentity: string | undefined if (body && typeof body === 'string') { const modelParseStart = nowMs() @@ -3498,6 +3779,7 @@ const anthropicAuthPlugin = async ( identity, hybridStandbyAnchor: standbyCacheAnchor, serverSideFallbackEnabled: fallbackMode === 'server', + cacheDiagnosticsPreviousMessageId, perf: (stage, data) => { trace?.mark(`rewrite_body_${stage}`, { route, ...data }) if ( @@ -3530,10 +3812,42 @@ const anthropicAuthPlugin = async ( } const headerBodyParseStart = nowMs() try { + const finalBody = JSON.parse(body) as Record setOAuthHeaders(requestHeaders, accessToken, { - body: JSON.parse(body), + body: finalBody, identity, }) + const diagnostics = finalBody.diagnostics + const sentPreviousMessageId = + diagnostics && + typeof diagnostics === 'object' && + !Array.isArray(diagnostics) && + (diagnostics as { previous_message_id?: unknown }) + .previous_message_id === cacheDiagnosticsPreviousMessageId + ? cacheDiagnosticsPreviousMessageId + : undefined + if ( + sentPreviousMessageId !== undefined && + requestHeaders + .get('anthropic-beta') + ?.split(',') + .map((beta) => beta.trim()) + .includes(CACHE_DIAGNOSTICS_BETA) + ) { + cacheDiagnosticsRequest = { + sessionId: relayAffinity ?? 'session-unknown', + previousMessageId: sentPreviousMessageId, + ...(previousDiagnosticsMessage + ? { + previousMessageReceivedAt: + previousDiagnosticsMessage.receivedAt, + } + : {}), + isSubagent: subagentRequest, + ttlSent: summarizeCacheTtl(finalBody), + } + } + streaming = finalBody.stream === true trace?.mark('set_oauth_headers_body_parse', { route, ms: roundMs(nowMs() - headerBodyParseStart), @@ -3562,6 +3876,8 @@ const anthropicAuthPlugin = async ( }) } + const cacheDiagnosticsBetas = + await getCacheDiagnosticsBetas(requestHeaders) const rewritten = rewriteUrl(input) if (fableRequest && typeof body === 'string') { fableRequest.warmTarget = { @@ -3587,6 +3903,7 @@ const anthropicAuthPlugin = async ( storage, cacheMode: 'hybrid', oauthAccountId, + isSubagent: subagentRequest, }) trace?.mark('cachekeep_track', { session: relayAffinity, @@ -3609,7 +3926,7 @@ const anthropicAuthPlugin = async ( ...(isInsecure() && { tls: { rejectUnauthorized: false } }), }) if (typeof body === 'string') { - await dumpDirectRequest({ + directDump = await dumpDirectRequest({ affinity: relayAffinity, route, status: response.status, @@ -3657,6 +3974,9 @@ const anthropicAuthPlugin = async ( optimisticResponse: relayConfig?.transport === 'websocket', onResponseHeaders: (headers) => harvestQuotaHeaders(headers, served), + onDumpCreated: (handle) => { + relayDump = handle + }, }) trace?.mark('send_headers_received', { route, @@ -3667,6 +3987,18 @@ const anthropicAuthPlugin = async ( }) if (usedDirectFetch) harvestQuotaHeaders(response.headers, served) + attachCacheDiagnosticsResponse(response, { + source: 'turn', + accountId: oauthAccountId, + synthetic: false, + ...cacheDiagnosticsBetas, + requestedModel: parseRequestModel(body), + request: cacheDiagnosticsRequest, + trackSessionId: relayAffinity ?? undefined, + dump: usedDirectFetch ? directDump : relayDump, + status: response.status, + streaming, + }) return response } @@ -3928,39 +4260,6 @@ const anthropicAuthPlugin = async ( } } - async function withStickyRetryAfter( - response: Response, - sessionId: string, - retryAfterSeconds: number, - streamingRateLimit = false, - ) { - const headers = new Headers(response.headers) - headers.set( - 'retry-after', - String(stickyRetryAfterWithJitter(sessionId, retryAfterSeconds)), - ) - if (streamingRateLimit) { - await response.body?.cancel().catch(() => {}) - headers.set('content-type', 'application/json') - return new Response( - JSON.stringify({ - type: 'error', - error: { - type: 'rate_limit_error', - message: - 'Sticky OAuth account five-hour quota resets shortly; retaining session affinity.', - }, - }), - { status: 429, headers }, - ) - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }) - } - async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -4201,9 +4500,23 @@ const anthropicAuthPlugin = async ( ? initialBody.length : undefined, }) - const wrapResponse = (response: Response) => - createStrippedStream(response, { + const wrapResponse = (response: Response) => { + const diagnosticsContext = + cacheDiagnosticsResponses.get(response) + return createStrippedStream(response, { perf: (stage, data) => trace.mark(stage, data), + ...(diagnosticsContext + ? diagnosticsContext.streaming + ? { + onMessageStart: (message) => + observeCacheDiagnosticsResponse(response, message), + } + : { + onMessageResponse: (message) => + observeCacheDiagnosticsResponse(response, message), + responseMode: 'json' as const, + } + : {}), contentFilterModel: fablePlan?.requestedModel, ...(!fablePlan?.downgraded && fablePlan ? { @@ -4317,6 +4630,7 @@ const anthropicAuthPlugin = async ( } : {}), }) + } const authStart = nowMs() const auth = await getAuth() trace.mark('get_auth', { @@ -4553,6 +4867,8 @@ const anthropicAuthPlugin = async ( ), sessionId, proactiveQuotaDecision.retryAfterSeconds, + false, + cacheDiagnosticsResponses, ), false, ) @@ -4647,6 +4963,7 @@ const anthropicAuthPlugin = async ( sessionId, decision.retryAfterSeconds, inspected.streamingRateLimit, + cacheDiagnosticsResponses, ), ) } diff --git a/packages/opencode/src/server-fallback.ts b/packages/opencode/src/server-fallback.ts index 6e52130a..c0a5d2e5 100644 --- a/packages/opencode/src/server-fallback.ts +++ b/packages/opencode/src/server-fallback.ts @@ -216,10 +216,14 @@ function sameRequestedModel(requestedModel: string, servedModel: string) { export function createServerSideFallbackStreamRewriter(options: { requestedModel: string + maxPendingBytes?: number onOutcome?: (outcome: ServerSideFallbackOutcome) => void onRefusalAfterToolUse?: () => boolean | undefined }) { let pending = '' + let pendingBytes = 0 + let resync = false + let resyncCarry = '' let servedModel: string | undefined let handoff: ServerSideFallbackMarker | undefined let fallbackIterationModel: string | undefined @@ -323,21 +327,59 @@ export function createServerSideFallbackStreamRewriter(options: { boundary.index + boundary.length, ) pending = pending.slice(boundary.index + boundary.length) + pendingBytes = new TextEncoder().encode(pending).byteLength output += rewriteEvent(rawEvent, delimiter) } return output } return { + pendingLength() { + return pendingBytes + }, push(text: string) { + let output = '' + if (resync) { + const window = resyncCarry + text + const boundary = findSseBoundary(window) + if (!boundary) { + resyncCarry = window.slice(-3) + return text + } + const end = boundary.index + boundary.length + output = text.slice(0, Math.max(0, end - resyncCarry.length)) + text = window.slice(end) + resync = false + resyncCarry = '' + } + if (!text) return output + const textBytes = new TextEncoder().encode(text).byteLength pending += text - return drain() + pendingBytes += textBytes + output += drain() + if ( + options.maxPendingBytes !== undefined && + pendingBytes > options.maxPendingBytes + ) { + const tail = pending + pending = '' + pendingBytes = 0 + resync = true + resyncCarry = '' + return output + tail + } + return output }, flush() { + if (resync) { + resyncCarry = '' + return '' + } const output = drain() if (!pending) return output const tail = rewriteEvent(pending, '') pending = '' + pendingBytes = 0 return output + tail }, } diff --git a/packages/opencode/src/tests/cache-diagnostics.test.ts b/packages/opencode/src/tests/cache-diagnostics.test.ts new file mode 100644 index 00000000..f7601760 --- /dev/null +++ b/packages/opencode/src/tests/cache-diagnostics.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, test } from 'bun:test' +import { + applyCacheDiagnosticsOptIn, + buildCacheDiagnosticsRecord, + CACHE_DIAGNOSTICS_LOG_PREFIX, + CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC, + CacheDiagnosticsBetaTracker, + CacheDiagnosticsTracker, + formatCacheDiagnosticsLogLine, + summarizeCacheTtl, + withStickyRetryAfter, +} from '../cache-diagnostics' + +const usage = { + input_tokens: 10, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 30, + cache_creation: { + ephemeral_5m_input_tokens: 40, + ephemeral_1h_input_tokens: 50, + }, +} + +const message = (diagnostics?: unknown, model = 'claude-opus-4-7') => ({ + id: 'provider-response-id-with-no-msg-prefix', + model, + usage, + ...(diagnostics === undefined ? {} : { diagnostics }), +}) + +const request = { + sessionId: 'ses-a', + previousMessageId: null, + isSubagent: false, + ttlSent: '1h' as const, +} + +const input = (overrides: Record = {}) => ({ + request, + source: 'turn', + accountId: 'oauth-account-a', + synthetic: false, + betasHash: '0123456789abcdef', + message: message({ cache_miss_reason: { type: 'unavailable' } }), + receivedAt: 100, + ...overrides, +}) + +describe('CacheDiagnosticsTracker', () => { + test('returns null before capture and preserves opaque provider ids', () => { + const tracker = new CacheDiagnosticsTracker() + expect(tracker.previousFor('ses-a')).toBeNull() + tracker.capture('ses-a', 'provider-response-id-with-no-msg-prefix', 100) + expect(tracker.previousFor('ses-a')).toEqual({ + messageId: 'provider-response-id-with-no-msg-prefix', + receivedAt: 100, + }) + }) + + test('evicts only the oldest unique session at the bounded limit', () => { + const tracker = new CacheDiagnosticsTracker() + for (let index = 0; index < 1_000; index += 1) + tracker.capture(`ses-${index}`, `provider-${index}`, index) + tracker.capture('ses-0', 'provider-0-updated', 2_000) + tracker.capture('ses-1000', 'provider-1000', 2_001) + expect(tracker.previousFor('ses-0')).toBeNull() + expect(tracker.previousFor('ses-1')?.messageId).toBe('provider-1') + }) + + test('copies response context across a sticky retry rewrap', async () => { + const contexts = new WeakMap() + const source = new Response('{}') + contexts.set(source, { sessionId: 'ses-retry' }) + + const destination = await withStickyRetryAfter( + source, + 'ses-retry', + 60, + false, + contexts, + ) + + expect(contexts.get(destination)).toEqual({ sessionId: 'ses-retry' }) + }) +}) + +describe('cache diagnostics v2 contract', () => { + test('writes required v2 metadata and omits a matching requested model', () => { + const result = buildCacheDiagnosticsRecord( + input({ requestedModel: 'claude-opus-4-7' }), + ) + + expect(result.record).toMatchObject({ + v: 2, + source: 'turn', + account_id: 'oauth-account-a', + synthetic: false, + betas_hash: '0123456789abcdef', + cache_read: 20, + cache_creation: 30, + input_tokens: 10, + }) + expect(result.record).not.toHaveProperty('requested_model') + }) + + test('records requested_model only when the served response model differs', () => { + const result = buildCacheDiagnosticsRecord( + input({ requestedModel: 'claude-sonnet-4-7' }), + ) + + expect(result.record?.requested_model).toBe('claude-sonnet-4-7') + }) + + test.each([ + ['turn', false, 'account-turn'], + ['prewarm_cachekeep', true, 'account-prewarm'], + ] as const)('records account_id for %s observations', (source, synthetic, accountId) => { + const result = buildCacheDiagnosticsRecord( + input({ source, synthetic, accountId }), + ) + + expect(result.record).toMatchObject({ + source, + synthetic, + account_id: accountId, + }) + }) + + test('warns on a known source synthetic mismatch while preserving synthetic', () => { + const warnings: string[] = [] + const result = buildCacheDiagnosticsRecord( + input({ + synthetic: true, + onWarning: (message: string) => warnings.push(message), + }), + ) + + expect(result.record?.synthetic).toBe(true) + expect(warnings).toEqual([ + 'cache diagnostics source/synthetic mismatch: source=turn expected=false actual=true', + ]) + }) + + test('does not warn for an unknown source', () => { + const warnings: string[] = [] + const result = buildCacheDiagnosticsRecord( + input({ + source: 'future_machine', + synthetic: true, + onWarning: (message: string) => warnings.push(message), + }), + ) + + expect(result.record?.source).toBe('future_machine') + expect(warnings).toEqual([]) + }) + + test('exports the known source synthetic mapping', () => { + expect(CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC).toEqual({ + turn: false, + prewarm_cachekeep: true, + }) + }) + + test('emits beta side-channel once per hash and retains differing sets', () => { + const tracker = new CacheDiagnosticsBetaTracker() + + expect(tracker.capture('0123456789abcdef', ['beta-b', 'beta-a'])).toBe( + 'MC-CACHE-DIAG-BETAS {"hash":"0123456789abcdef","betas":["beta-a","beta-b"]}', + ) + expect(tracker.capture('0123456789abcdef', ['beta-a', 'beta-b'])).toBeNull() + expect(tracker.capture('fedcba9876543210', ['beta-c'])).toBe( + 'MC-CACHE-DIAG-BETAS {"hash":"fedcba9876543210","betas":["beta-c"]}', + ) + }) + + test('formats a v2 record with the load-bearing record prefix', () => { + const result = buildCacheDiagnosticsRecord(input()) + expect(result.record).toBeDefined() + const line = formatCacheDiagnosticsLogLine(result.record!) + expect(line.startsWith(CACHE_DIAGNOSTICS_LOG_PREFIX)).toBe(true) + expect( + JSON.parse(line.slice(CACHE_DIAGNOSTICS_LOG_PREFIX.length)), + ).toMatchObject({ + v: 2, + session_id: 'ses-a', + message_id: 'provider-response-id-with-no-msg-prefix', + }) + }) + + test.each([ + [{}, 'absent'], + [{ diagnostics: null }, 'server_null'], + [{ diagnostics: { cache_miss_reason: null } }, 'pending'], + [ + { diagnostics: { cache_miss_reason: { type: 'unavailable' } } }, + 'populated', + ], + ] as const)('classifies diagnostics %j as %s', (extra, state) => { + expect( + buildCacheDiagnosticsRecord( + input({ message: { ...message(undefined), ...extra } }), + ).record?.diag_state, + ).toBe(state) + }) + + test('rejects invalid required v2 metadata', () => { + expect(buildCacheDiagnosticsRecord(input({ accountId: '' }))).toEqual({}) + expect(buildCacheDiagnosticsRecord(input({ betasHash: '' }))).toEqual({}) + expect(buildCacheDiagnosticsRecord(input({ source: '' }))).toEqual({}) + }) + + test('copies usage values by property and keeps unavailable ordinary', () => { + const result = buildCacheDiagnosticsRecord(input()) + + expect(result.record).toMatchObject({ + cache_read: 20, + cache_creation: 30, + input_tokens: 10, + ephemeral_5m_tokens: 40, + ephemeral_1h_tokens: 50, + miss_reason: 'unavailable', + }) + }) + + test('parses the captured populated API response', () => { + const result = buildCacheDiagnosticsRecord( + input({ + message: { + id: 'msg_011SampleAnthropicId0000', + model: 'claude-opus-5', + usage: { + input_tokens: 14, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 24837, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 24837, + }, + }, + diagnostics: { + cache_miss_reason: { + type: 'system_changed', + cache_missed_input_tokens: 23963, + }, + }, + }, + }), + ) + + expect(result.record).toMatchObject({ + diag_state: 'populated', + miss_reason: 'system_changed', + cache_missed_input_tokens: 23963, + }) + }) + + test('rejects malformed diagnostics without fabricating absent state', () => { + expect( + buildCacheDiagnosticsRecord( + input({ message: message({ cache_miss_reason: 42 }) }), + ), + ).toEqual({}) + }) + + test.each([ + { cache_miss_reason: 'system_changed' }, + { cache_miss_reason: {} }, + { cache_miss_reason: { type: 42 } }, + ])('rejects non-wire populated reason %j', (diagnostics) => { + expect( + buildCacheDiagnosticsRecord(input({ message: message(diagnostics) })), + ).toEqual({}) + }) + + test.each([ + { diagnostics: 'pending' }, + { diagnostics: [] }, + { diagnostics: 42 }, + ])('rejects malformed diagnostics shape %j', (extra) => { + expect( + buildCacheDiagnosticsRecord( + input({ message: { ...message(), ...extra } }), + ), + ).toEqual({}) + }) + + test('rejects non-finite and fractional receipt timestamps', () => { + expect(buildCacheDiagnosticsRecord(input({ receivedAt: 100.5 }))).toEqual( + {}, + ) + expect( + buildCacheDiagnosticsRecord( + input({ receivedAt: Number.POSITIVE_INFINITY }), + ), + ).toEqual({}) + }) + + test.each([ + ['negative cache_read', { cache_read_input_tokens: -1 }], + ['non-finite input_tokens', { input_tokens: Number.POSITIVE_INFINITY }], + ])('rejects a record with %s', (_name, usageOverrides) => { + expect( + buildCacheDiagnosticsRecord( + input({ + message: { + ...message({ cache_miss_reason: { type: 'unavailable' } }), + usage: { ...usage, ...usageOverrides }, + }, + }), + ), + ).toEqual({}) + }) + + test('applies opt-in without replacing existing body fields', () => { + const body: Record = { model: 'x' } + applyCacheDiagnosticsOptIn(body, null) + expect(body).toEqual({ + model: 'x', + diagnostics: { previous_message_id: null }, + }) + }) + + test.each([ + [{ cache_control: { type: 'ephemeral', ttl: '1h' } }, '1h'], + [{ cache_control: { type: 'ephemeral' } }, '5m'], + [{}, null], + ] as const)('summarizes TTL as %s', (body, expected) => { + expect(summarizeCacheTtl(body)).toBe(expected) + }) + + test.each([ + [ + { + system: [{ cache_control: { type: 'ephemeral' } }], + messages: [ + { content: [{ cache_control: { type: 'ephemeral', ttl: '1h' } }] }, + ], + }, + '1h', + ], + [ + { + system: [{ cache_control: { type: 'ephemeral', ttl: '1h' } }], + messages: [{ content: [{ cache_control: { type: 'ephemeral' } }] }], + }, + '5m', + ], + ] as const)('uses the last cache breakpoint as TTL %s', (body, expected) => { + expect(summarizeCacheTtl(body)).toBe(expected) + }) + + test('emits a short-gap canary only for previous_message_not_found', () => { + const result = buildCacheDiagnosticsRecord( + input({ + request: { + ...request, + previousMessageId: 'provider-previous', + previousMessageReceivedAt: 1_000, + }, + message: message({ + cache_miss_reason: { type: 'previous_message_not_found' }, + }), + receivedAt: 1_000 + 5 * 60_000 - 1, + }), + ) + + expect(result.canary).toEqual({ + messageId: 'provider-response-id-with-no-msg-prefix', + previousMessageId: 'provider-previous', + }) + }) + + test.each([ + { reason: 'unavailable', previousMessageId: 'provider-previous', age: 1 }, + { + reason: 'previous_message_not_found', + previousMessageId: 'provider-previous', + age: 5 * 60_000, + }, + { + reason: 'previous_message_not_found', + previousMessageId: null, + age: 1, + }, + ])('does not emit a canary for %j', ({ reason, previousMessageId, age }) => { + const previousMessageReceivedAt = 1_000 + const result = buildCacheDiagnosticsRecord( + input({ + request: { + ...request, + previousMessageId, + previousMessageReceivedAt, + }, + message: message({ cache_miss_reason: { type: reason } }), + receivedAt: previousMessageReceivedAt + age, + }), + ) + + expect(result.canary).toBeUndefined() + }) +}) diff --git a/packages/opencode/src/tests/cachekeep.test.ts b/packages/opencode/src/tests/cachekeep.test.ts index a350dad8..636ce782 100644 --- a/packages/opencode/src/tests/cachekeep.test.ts +++ b/packages/opencode/src/tests/cachekeep.test.ts @@ -1,4 +1,7 @@ import { describe, expect, mock, test } from 'bun:test' +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { type AccountStorage, buildCacheKeepPrewarmBody, @@ -7,6 +10,8 @@ import { CacheKeepManager, executeCacheKeepCommand, parseCacheKeepCommandAction, + resetDumpState, + setDumpEnabled, } from '@cortexkit/anthropic-auth-core' const hybridStorage = (): AccountStorage => ({ @@ -128,6 +133,138 @@ describe('cachekeep prewarm body', () => { }) describe('CacheKeepManager', () => { + test('writes cachekeep-tagged prewarm request and response artifacts', async () => { + const dumpDir = await mkdtemp(join(tmpdir(), 'cachekeep-dump-test-')) + const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + try { + const manager = new CacheKeepManager({ + loadStorage: () => Promise.resolve(hybridStorage()), + fetchImpl: mock( + async () => new Response('malformed', { status: 429 }), + ) as unknown as typeof fetch, + }) + await manager.prewarmNow({ + sessionId: 'ses_dump', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers(), + bodyText: JSON.stringify({ + system: [ + { + type: 'text', + text: 'stable', + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: 'hello' }], + }), + }) + const files = await readdir(dumpDir) + expect(files.some((file) => file.includes('-prewarm-cachekeep-'))).toBe( + true, + ) + const metadataPath = files.find((file) => file.endsWith('.meta.json')) + expect(metadataPath).toBeDefined() + const metadata = JSON.parse( + await readFile(join(dumpDir, metadataPath!), 'utf8'), + ) + expect(metadata.tag).toBe('cachekeep') + const responsePath = files.find((file) => file.endsWith('.response.json')) + expect(responsePath).toBeDefined() + expect( + JSON.parse(await readFile(join(dumpDir, responsePath!), 'utf8')), + ).toEqual({ status: 429 }) + } finally { + resetDumpState() + if (originalDumpDir === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + else process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = originalDumpDir + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('prepares the tracked body and observes the exact sent body', async () => { + const sent: string[] = [] + const observed: unknown[] = [] + const body = JSON.stringify({ + model: 'claude-opus-4-7', + max_tokens: 100, + stream: true, + system: [ + { type: 'text', text: 'stable', cache_control: { type: 'ephemeral' } }, + ], + messages: [{ role: 'user', content: 'hello' }], + }) + const manager = new CacheKeepManager({ + loadStorage: () => Promise.resolve(hybridStorage()), + fetchImpl: mock(async (_input, init) => { + sent.push(String(init?.body)) + return new Response( + JSON.stringify({ + usage: { + input_tokens: 2, + cache_creation_input_tokens: 1, + cache_read_input_tokens: 3, + cache_creation: { + ephemeral_5m_input_tokens: 4, + ephemeral_1h_input_tokens: 5, + }, + }, + }), + { status: 200 }, + ) + }) as unknown as typeof fetch, + prepareBody: (value) => value.replace('hello', 'prepared'), + onResponse: (input) => { + observed.push(input) + }, + }) + const result = await manager.prewarmNow({ + sessionId: 'ses_prepare', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers(), + bodyText: body, + }) + expect(result.ok).toBe(true) + expect(sent[0]).toContain('prepared') + expect(observed).toHaveLength(1) + expect(sent[0]).toBeDefined() + expect((observed[0] as { bodyText: string }).bodyText).toBe(sent[0]!) + expect( + (observed[0] as { data: { usage: { cache_creation: unknown } } }).data + .usage.cache_creation, + ).toEqual({ ephemeral_5m_input_tokens: 4, ephemeral_1h_input_tokens: 5 }) + }) + + test('observer errors do not break malformed responses or scheduling', async () => { + const manager = new CacheKeepManager({ + loadStorage: () => Promise.resolve(hybridStorage()), + fetchImpl: mock( + async () => new Response('not-json', { status: 429 }), + ) as unknown as typeof fetch, + onResponse: () => { + throw new Error('observer failure') + }, + }) + const result = await manager.prewarmNow({ + sessionId: 'ses_observer', + url: 'https://api.anthropic.com/v1/messages', + headers: new Headers(), + bodyText: JSON.stringify({ + system: [ + { + type: 'text', + text: 'stable', + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: 'hello' }], + }), + }) + expect(result).toMatchObject({ ok: false, status: 429 }) + }) + test('tracks hybrid sessions and prewarms five minutes before expiry', async () => { let now = new Date('2026-05-18T10:00:00').getTime() const calls: Array<{ url: string; body: string }> = [] diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 89e1c61e..3bd6603d 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9403,6 +9403,647 @@ describe('auth.loader', () => { }) }) +describe('cache diagnostics', () => { + const originalFetch = globalThis.fetch + const originalDateNow = Date.now + + const message = ( + id: string, + diagnostics: unknown = { cache_miss_reason: null }, + ) => ({ + id, + model: 'claude-opus-4-8', + usage: { + input_tokens: 101, + cache_read_input_tokens: 75, + cache_creation_input_tokens: 26, + cache_creation: { + ephemeral_5m_input_tokens: 20, + ephemeral_1h_input_tokens: 6, + }, + }, + diagnostics, + }) + + const sseResponse = (data: Record, status = 200) => + new Response( + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: data })}\n\n` + + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + { status }, + ) + + const oauthLoader = () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }) + + beforeEach(async () => { + globalThis.fetch = originalFetch + Date.now = originalDateNow + pluginTimerOverrides = { + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + resetCache1hState() + resetDumpState() + setLogLevel('info') + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + await useTempAccountFile( + createFallbackStorage({ accounts: [], quota: { enabled: false } }), + ) + }) + + afterEach(async () => { + __setLogTestSink(null) + globalThis.fetch = originalFetch + Date.now = originalDateNow + pluginTimerOverrides = {} + resetDumpState() + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await drainSidebarWrites() + restoreProcessTestFiles() + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }) + tempConfigDir = undefined + } + }) + + test('cache diagnostics binds the provider predecessor at request time', async () => { + const sentBodies: Record[] = [] + const records: LogTestRecord[] = [] + const delayedReleases = new Map void>() + const delayedResponse = (providerId: string) => { + const body = new ReadableStream({ + start(controller) { + return new Promise((resolve) => { + delayedReleases.set(providerId, () => { + const payload = message(providerId) + controller.enqueue( + new TextEncoder().encode( + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: payload })}\n\n` + + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ), + ) + controller.close() + resolve() + }) + }) + }, + }) + return new Response(body, { status: 200 }) + } + globalThis.fetch = mock((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + const responsePlan = [ + ['ses-diag-A', 'provider-A'], + ['ses-diag-B', 'provider-B'], + ['ses-diag-A', 'provider-A-after'], + ['ses-diag-B', 'provider-B-after'], + ] as const + const response = responsePlan[sentBodies.length - 1] + if (!response) throw new Error('unexpected request') + return Promise.resolve( + sentBodies.length <= 2 + ? sseResponse(message(response[1])) + : delayedResponse(response[1]), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin( + createMockClient([ + { info: { id: 'msg_opencode_decoy', role: 'assistant' } }, + ]), + ) + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const request = (sessionId: string) => ({ + method: 'POST', + headers: { 'x-session-affinity': sessionId }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + await (await result.fetch(MESSAGES_URL, request('ses-diag-A'))).text() + await (await result.fetch(MESSAGES_URL, request('ses-diag-B'))).text() + const delayedA = await result.fetch(MESSAGES_URL, request('ses-diag-A')) + const delayedB = await result.fetch(MESSAGES_URL, request('ses-diag-B')) + delayedReleases.get('provider-A-after')?.() + await delayedA.text() + delayedReleases.get('provider-B-after')?.() + await delayedB.text() + + expect(sentBodies.map((body) => body.diagnostics)).toEqual([ + { previous_message_id: null }, + { previous_message_id: null }, + { previous_message_id: 'provider-A' }, + { previous_message_id: 'provider-B' }, + ]) + const lines = records + .filter( + (record) => + record.channel === 'cache-diagnostics' && + record.message.startsWith('MC-CACHE-DIAG '), + ) + .map((record) => JSON.parse(record.message.replace('MC-CACHE-DIAG ', ''))) + expect(lines).toHaveLength(4) + expect( + lines.find((line) => line.message_id === 'provider-A-after'), + ).toMatchObject({ + previous_message_id: 'provider-A', + ttl_sent: null, + cache_read: 75, + cache_creation: 26, + input_tokens: 101, + ephemeral_5m_tokens: 20, + ephemeral_1h_tokens: 6, + session_id: 'ses-diag-A', + is_subagent: false, + v: 2, + source: 'turn', + synthetic: false, + account_id: 'main', + betas_hash: expect.stringMatching(/^[0-9a-f]{16}$/), + }) + expect( + lines.find((line) => line.message_id === 'provider-B-after'), + ).toMatchObject({ + previous_message_id: 'provider-B', + session_id: 'ses-diag-B', + }) + }) + + test('cache diagnostics isolates missing affinity and strips the parent header', async () => { + const sentBodies: Record[] = [] + const sentHeaders: Headers[] = [] + const records: LogTestRecord[] = [] + let requestNumber = 0 + globalThis.fetch = mock((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + sentHeaders.push(new Headers(init.headers)) + requestNumber++ + return Promise.resolve(sseResponse(message(`provider-${requestNumber}`))) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const body = JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }) + await ( + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-parent-session-id': 'parent-1' }, + body, + }) + ).text() + await (await result.fetch(MESSAGES_URL, { method: 'POST', body })).text() + + expect(sentBodies.map((entry) => entry.diagnostics)).toEqual([ + { previous_message_id: null }, + { previous_message_id: null }, + ]) + expect(sentHeaders[0]?.has('x-parent-session-id')).toBe(false) + const lines = records + .filter( + (record) => + record.channel === 'cache-diagnostics' && + record.message.startsWith('MC-CACHE-DIAG '), + ) + .map((record) => JSON.parse(record.message.replace('MC-CACHE-DIAG ', ''))) + expect(lines).toHaveLength(2) + expect(lines[0]).toMatchObject({ + session_id: 'session-unknown', + is_subagent: true, + }) + expect(lines[1]).toMatchObject({ + session_id: 'session-unknown', + is_subagent: false, + }) + }) + + test('cache diagnostics emits the opt-in beta for normal and structured OAuth requests', async () => { + const sentBodies: Record[] = [] + const betaHeaders: string[] = [] + const records: LogTestRecord[] = [] + globalThis.fetch = mock((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + betaHeaders.push(new Headers(init.headers).get('anthropic-beta') ?? '') + return Promise.resolve( + sseResponse(message(`provider-${sentBodies.length}`)), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + for (const output_config of [ + undefined, + { format: { type: 'json_schema' } }, + ]) { + await ( + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + ...(output_config ? { output_config } : {}), + }), + }) + ).text() + } + + expect(sentBodies.map((body) => body.diagnostics)).toEqual([ + { previous_message_id: null }, + { previous_message_id: null }, + ]) + expect( + betaHeaders.every((beta) => beta.includes('cache-diagnosis-2026-04-07')), + ).toBe(true) + const betaLines = records + .filter( + (record) => + record.channel === 'cache-diagnostics' && + record.message.startsWith('MC-CACHE-DIAG-BETAS '), + ) + .map((record) => + JSON.parse(record.message.replace('MC-CACHE-DIAG-BETAS ', '')), + ) + expect(betaLines).toHaveLength(2) + expect(new Set(betaLines.map((line) => line.hash)).size).toBe(2) + }) + + test('cache diagnostics observes non-streaming envelopes and carries their provider id forward', async () => { + const sentBodies: Record[] = [] + const records: LogTestRecord[] = [] + globalThis.fetch = mock((_input: any, init: RequestInit) => { + sentBodies.push(JSON.parse(String(init.body))) + const id = sentBodies.length === 1 ? 'provider-json-A' : 'provider-json-B' + return Promise.resolve( + new Response(JSON.stringify(message(id)), { status: 200 }), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses-json' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: false, + messages: [{ role: 'user', content: 'hello' }], + }), + } + await (await result.fetch(MESSAGES_URL, request)).text() + await (await result.fetch(MESSAGES_URL, request)).text() + + expect(sentBodies[1]?.diagnostics).toEqual({ + previous_message_id: 'provider-json-A', + }) + expect( + records.filter( + (record) => + record.channel === 'cache-diagnostics' && + record.message.startsWith('MC-CACHE-DIAG '), + ), + ).toHaveLength(2) + }) + + test('cache diagnostics records cachekeep prewarms and carries their provider id forward', async () => { + let now = 1_000 + const intervals: Array<{ callback: () => unknown; ms: number }> = [] + const sentBodies: Record[] = [] + const records: LogTestRecord[] = [] + let prewarmStartedFlag = false + let resolvePrewarmStarted: (() => void) | undefined + const prewarmStarted = new Promise((resolve) => { + resolvePrewarmStarted = () => { + prewarmStartedFlag = true + resolve() + } + }) + Date.now = mock(() => now) as unknown as typeof Date.now + pluginTimerOverrides = { + setInterval: mock((callback: () => unknown, ms: number) => { + intervals.push({ callback, ms }) + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true, subagents: true }, + }), + ) + let normalRequests = 0 + globalThis.fetch = mock((_input: any, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as Record + sentBodies.push(body) + if (body.max_tokens === 0) { + resolvePrewarmStarted?.() + return Promise.resolve( + new Response(JSON.stringify(message('provider-warm-B')), { + status: 200, + }), + ) + } + normalRequests++ + return Promise.resolve( + sseResponse( + message(normalRequests === 1 ? 'provider-real-A' : 'provider-real-C'), + ), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses-cachekeep' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + } + await (await result.fetch(MESSAGES_URL, request)).text() + now += 55 * 60_000 + const cacheKeepTick = intervals.at(-1) + if (!cacheKeepTick) throw new Error('missing cachekeep interval') + cacheKeepTick.callback() + await Bun.sleep(20) + await prewarmStarted + expect(prewarmStartedFlag).toBe(true) + for ( + let attempt = 0; + attempt < 50 && + !records.some( + (record) => + record.channel === 'cache-diagnostics' && + record.message.includes('provider-warm-B'), + ); + attempt++ + ) { + await Bun.sleep(10) + } + await (await result.fetch(MESSAGES_URL, request)).text() + + const prewarmBody = sentBodies.find((body) => body.max_tokens === 0) + expect(prewarmBody?.diagnostics).toEqual({ + previous_message_id: 'provider-real-A', + }) + expect(sentBodies.at(-1)?.diagnostics).toEqual({ + previous_message_id: 'provider-warm-B', + }) + const prewarmRecord = records.find( + (record) => + record.channel === 'cache-diagnostics' && + record.message.includes('provider-warm-B'), + ) + expect(prewarmRecord).toBeDefined() + expect( + JSON.parse(prewarmRecord!.message.replace('MC-CACHE-DIAG ', '')), + ).toMatchObject({ + is_subagent: false, + ttl_sent: '1h', + previous_message_id: 'provider-real-A', + source: 'prewarm_cachekeep', + synthetic: true, + account_id: 'main', + betas_hash: expect.stringMatching(/^[0-9a-f]{16}$/), + }) + }) + + test('cache diagnostics logs a short-gap previous-message canary but not unavailable', async () => { + const records: LogTestRecord[] = [] + let now = 1_000 + Date.now = mock(() => now) as unknown as typeof Date.now + const responses = [ + message('provider-canary-A'), + message('provider-canary-B', { + cache_miss_reason: { type: 'previous_message_not_found' }, + }), + message('provider-canary-C', { + cache_miss_reason: { type: 'unavailable' }, + }), + message('provider-canary-D', { + cache_miss_reason: { type: 'previous_message_not_found' }, + }), + ] + globalThis.fetch = mock(() => { + const response = responses.shift() + if (!response) throw new Error('unexpected request') + return Promise.resolve(sseResponse(response)) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses-canary' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + } + await (await result.fetch(MESSAGES_URL, request)).text() + now += 299_999 + await (await result.fetch(MESSAGES_URL, request)).text() + now += 300_000 + await (await result.fetch(MESSAGES_URL, request)).text() + now += 300_000 + await (await result.fetch(MESSAGES_URL, request)).text() + + const warnings = records.filter( + (record) => + record.level === 'warn' && record.channel === 'cache-diagnostics', + ) + expect(warnings).toHaveLength(1) + expect(warnings[0]?.payload).toMatchObject({ + message_id: 'provider-canary-B', + previous_message_id: 'provider-canary-A', + }) + }) + + test('cache diagnostics stays disabled on API-key fallback while preserving its response artifact', async () => { + const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp(join(tmpdir(), 'cache-diagnostics-api-dump-')) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + dump: { enabled: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { usedPercent: 100, remainingPercent: 0 }, + seven_day: { usedPercent: 50, remainingPercent: 50 }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('main-access'), + } as AccountStorage['quota'], + accounts: [ + { + id: 'kie-opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + }), + ) + const records: LogTestRecord[] = [] + let sentBody: Record | undefined + let sentBeta = '' + globalThis.fetch = mock((_input: any, init: RequestInit) => { + sentBody = JSON.parse(String(init.body)) + sentBeta = new Headers(init.headers).get('anthropic-beta') ?? '' + return Promise.resolve(sseResponse(message('provider-api'))) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + await ( + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + ).text() + + expect(sentBody?.diagnostics).toBeUndefined() + expect(sentBeta).not.toContain('cache-diagnosis-2026-04-07') + expect( + records.filter((record) => record.channel === 'cache-diagnostics'), + ).toHaveLength(0) + for (let attempt = 0; attempt < 50; attempt++) { + if ( + (await readdir(dumpDir)).some((file) => + file.endsWith('.response.json'), + ) + ) + break + await Bun.sleep(10) + } + const responseFile = (await readdir(dumpDir)).find((file) => + file.endsWith('.response.json'), + ) + expect(responseFile).toBeString() + expect( + JSON.parse(await readFile(join(dumpDir, responseFile!), 'utf8')), + ).toMatchObject({ + status: 200, + message_id: 'provider-api', + }) + } finally { + if (originalDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = originalDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('cache diagnostics writes sanitized response artifacts for valid and malformed direct responses', async () => { + const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp( + join(tmpdir(), 'cache-diagnostics-dump-test-'), + ) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + dump: { enabled: true }, + }), + ) + const responses = [ + sseResponse({ + ...message('provider-dump'), + content: [{ text: 'secret' }], + }), + new Response('not an envelope', { status: 503 }), + ] + globalThis.fetch = mock(() => { + const response = responses.shift() + if (!response) throw new Error('unexpected request') + return Promise.resolve(response) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const request = { + method: 'POST', + headers: { 'x-session-affinity': 'ses-dump' }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }), + } + await (await result.fetch(MESSAGES_URL, request)).text() + await (await result.fetch(MESSAGES_URL, request)).text() + + let responseFiles: string[] = [] + for (let attempt = 0; attempt < 50; attempt++) { + responseFiles = (await readdir(dumpDir)).filter((file) => + file.endsWith('.response.json'), + ) + if (responseFiles.length === 2) break + await Bun.sleep(10) + } + expect(responseFiles).toHaveLength(2) + const artifacts = await Promise.all( + responseFiles.map(async (file) => + JSON.parse(await readFile(join(dumpDir, file), 'utf8')), + ), + ) + expect(artifacts).toContainEqual( + expect.objectContaining({ status: 200, message_id: 'provider-dump' }), + ) + expect(artifacts).toContainEqual({ status: 503 }) + expect(JSON.stringify(artifacts)).not.toContain('secret') + } finally { + if (originalDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = originalDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) +}) + describe('killswitch fetch gate', () => { const originalFetch = globalThis.fetch diff --git a/packages/opencode/src/tests/plugin-exports.test.ts b/packages/opencode/src/tests/plugin-exports.test.ts index b7b2bd6b..5569889c 100644 --- a/packages/opencode/src/tests/plugin-exports.test.ts +++ b/packages/opencode/src/tests/plugin-exports.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from 'bun:test' describe('plugin module exports', () => { - test('does not expose a state-mutating test hook to the OpenCode loader', async () => { + test('plugin module exports remain limited to loader-compatible factories and helpers', async () => { const pluginModule = await import('../index') - expect('__setBootProfileHydrationForTest' in pluginModule).toBe(false) + expect(Object.keys(pluginModule).sort()).toEqual([ + 'AnthropicAuthPlugin', + 'formatKillswitchBlockMessage', + 'resolveScopedDrivenBlock', + ]) }) }) diff --git a/packages/opencode/src/tests/relay.test.ts b/packages/opencode/src/tests/relay.test.ts index 554e9d97..0ed21908 100644 --- a/packages/opencode/src/tests/relay.test.ts +++ b/packages/opencode/src/tests/relay.test.ts @@ -283,6 +283,46 @@ describe('relay client', () => { } }) + test('reports the created HTTP relay dump handle without replacing the response', async () => { + const originalFetch = globalThis.fetch + const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = await mkdtemp( + join(tmpdir(), 'anthropic-auth-relay-handle-'), + ) + setDumpEnabled(true) + globalThis.fetch = mock( + async () => new Response('relay', { status: 201 }), + ) as unknown as typeof fetch + const handles: unknown[] = [] + try { + const response = await sendViaRelay({ + config, + input: 'https://api.anthropic.com/v1/messages?beta=true', + init: { method: 'POST' }, + headers: headers('session-relay-handle'), + body: '{}', + fallback: async () => new Response('direct'), + onDumpCreated: (handle) => { + handles.push(handle) + throw new Error('observer failure') + }, + }) + expect(response.status).toBe(201) + expect(handles).toHaveLength(1) + expect((handles[0] as { responsePath: string }).responsePath).toEndWith( + '.response.json', + ) + } finally { + const dumpDir = getDumpDirectory() + resetDumpState() + globalThis.fetch = originalFetch + if (originalDumpDir === undefined) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + else process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = originalDumpDir + await rm(dumpDir, { recursive: true, force: true }) + } + }) + test('retries with full sync when relay reports state mismatch', async () => { const calls: unknown[] = [] const originalFetch = globalThis.fetch @@ -1686,6 +1726,7 @@ describe('relay client', () => { const dumpDir = await mkdtemp(join(tmpdir(), 'anthropic-auth-dump-test-')) process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir const sentPayloads: unknown[] = [] + const handles: unknown[] = [] setDumpEnabled(true) class DumpingWebSocket extends EventTarget { @@ -1754,6 +1795,7 @@ describe('relay client', () => { headers: headers('session-relay-ws-dump-exact'), body: JSON.stringify({ messages: ['one'] }), fallback: async () => new Response('direct'), + onDumpCreated: (handle) => handles.push(handle), }) const files = await readdir(getDumpDirectory()) @@ -1774,6 +1816,10 @@ describe('relay client', () => { expect(relay).toMatchObject({ protocol: 2, mode: 'full_sync' }) expect(relay.id).toBeString() expect(meta.relayBytes).toBe(JSON.stringify(sentPayloads[0]).length) + expect(handles).toHaveLength(1) + expect((handles[0] as { responsePath: string }).responsePath).toEndWith( + '.response.json', + ) } finally { resetDumpState() globalThis.WebSocket = originalWebSocket diff --git a/packages/opencode/src/tests/server-fallback.test.ts b/packages/opencode/src/tests/server-fallback.test.ts index 9c97ee92..e925a085 100644 --- a/packages/opencode/src/tests/server-fallback.test.ts +++ b/packages/opencode/src/tests/server-fallback.test.ts @@ -450,4 +450,60 @@ describe('createServerSideFallbackStreamRewriter', () => { }), ]) }) + + test.each([ + ['LF', '\n\n', 1], + ['CRLF', '\r\n\r\n', 2], + ])('resyncs after an oversized frame across a split %s boundary', (_name, delimiter, splitAt) => { + let handled = 0 + const skipped = `data: ${'x'.repeat(256)}` + const later = [ + sse('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_after_overflow', + name: 'mcp_Bash', + input: {}, + }, + }), + sse('content_block_stop', { type: 'content_block_stop', index: 0 }), + sse('content_block_start', { + type: 'content_block_start', + index: 1, + content_block: { + type: 'fallback', + from: { model: 'claude-fable-5' }, + to: { model: 'claude-opus-4-8' }, + }, + }), + sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'refusal' }, + }), + ].join('') + const rewriter = createServerSideFallbackStreamRewriter({ + requestedModel: 'claude-fable-5', + maxPendingBytes: 64, + onRefusalAfterToolUse: () => { + handled++ + return true + }, + }) + + const output = + rewriter.push(skipped) + + rewriter.push(delimiter.slice(0, splitAt)) + + rewriter.push(`${delimiter.slice(splitAt)}${later}`) + + rewriter.flush() + + expect(output.slice(0, skipped.length + delimiter.length)).toBe( + `${skipped}${delimiter}`, + ) + expect(output).toContain(SERVER_FALLBACK_SIGNATURE_PREFIX) + expect(output).toContain('"stop_reason":"tool_use"') + expect(output).not.toContain('"stop_reason":"refusal"') + expect(handled).toBe(1) + }) }) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 2368f3d3..471d2dd7 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -8,6 +8,7 @@ import { } from '@cortexkit/anthropic-auth-core' import dedent from 'dedent' import { + createServerSideFallbackStreamRewriter, SERVER_FALLBACK_MARKER_TEXT, SERVER_FALLBACK_SIGNATURE_PREFIX, SERVER_SIDE_FALLBACK_BETA, @@ -21,6 +22,7 @@ import { isInsecure, mergeBetaHeaders, mergeHeaders, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, prefixToolNames, prepareFableCacheWarmSource, prependClaudeCodeIdentity, @@ -483,6 +485,287 @@ describe('isInsecure', () => { }) describe('createStrippedStream', () => { + test('observes a split message_start envelope exactly once', async () => { + const message = { + id: 'msg_provider_1', + usage: { input_tokens: 3 }, + diagnostics: { cache_miss_reason: null }, + } + const payload = sse('message_start', { type: 'message_start', message }) + const seen: unknown[] = [] + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode(payload.slice(0, 17))) + controller.enqueue(encoder.encode(payload.slice(17))) + controller.close() + }, + }) + await createStrippedStream(new Response(stream), { + onMessageStart: (value) => seen.push(value), + }).text() + expect(seen).toEqual([message]) + }) + + test('captures message_start before disabling diagnostics on an oversized stream tail', async () => { + const start = sse('message_start', { + type: 'message_start', + message: { id: 'msg_stream_start', usage: { input_tokens: 1 } }, + }) + const body = `${start}${'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)}` + const seen: unknown[] = [] + const perf: Array> = [] + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)) + controller.close() + }, + }), + ), + { + onMessageStart: (message) => seen.push(message), + perf: (_stage, stats) => perf.push(stats ?? {}), + }, + ) + + expect(await response.text()).toBe(body) + expect(seen).toEqual([ + { id: 'msg_stream_start', usage: { input_tokens: 1 } }, + ]) + expect( + Math.max(...perf.map((stats) => Number(stats.ssePendingChars ?? 0))), + ).toBeLessThanOrEqual(NON_STREAMING_DIAGNOSTICS_MAX_BYTES) + expect(perf.at(-1)?.ssePendingOverflowCount).toBe(1) + }) + + test('observes a split non-streaming message response without changing bytes', async () => { + const message = { + id: 'msg_provider_2', + usage: { input_tokens: 3 }, + diagnostics: { cache_miss_reason: { type: 'unavailable' } }, + } + const body = JSON.stringify(message) + const seen: unknown[] = [] + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode(body.slice(0, 9))) + controller.enqueue(encoder.encode(body.slice(9))) + controller.close() + }, + }) + const response = createStrippedStream(new Response(stream), { + responseMode: 'json', + onMessageResponse: (value) => seen.push(value), + }) + expect(await response.text()).toBe(body) + expect(seen).toEqual([message]) + }) + + test('passes over-cap non-streaming bytes unchanged without observing diagnostics', async () => { + const body = JSON.stringify({ + id: 'msg_provider_over_cap', + usage: { input_tokens: 3 }, + diagnostics: { cache_miss_reason: { type: 'unavailable' } }, + padding: 'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2), + }) + const seen: unknown[] = [] + const perf: Array> = [] + const response = createStrippedStream(new Response(body), { + responseMode: 'json', + serverSideFallbackModel: 'claude-fable-5', + onComplete: () => {}, + onMessageResponse: (value) => seen.push(value), + perf: (_stage, stats) => perf.push(stats ?? {}), + }) + expect(await response.text()).toBe(body) + expect(seen).toEqual([]) + expect( + Math.max(...perf.map((stats) => Number(stats.ssePendingChars ?? 0))), + ).toBe(0) + expect( + Math.max(...perf.map((stats) => Number(stats.sseErrorPending ?? 0))), + ).toBe(0) + expect( + Math.max(...perf.map((stats) => Number(stats.sseFinishPending ?? 0))), + ).toBeLessThanOrEqual(NON_STREAMING_DIAGNOSTICS_MAX_BYTES) + expect( + Math.max( + ...perf.map((stats) => Number(stats.serverFallbackPending ?? 0)), + ), + ).toBeLessThanOrEqual(NON_STREAMING_DIAGNOSTICS_MAX_BYTES) + }) + + test('bounds server fallback pending state', () => { + const rewriter = createServerSideFallbackStreamRewriter({ + requestedModel: 'claude-fable-5', + maxPendingBytes: NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + }) + rewriter.push('x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)) + expect(rewriter.pendingLength()).toBeLessThanOrEqual( + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + ) + }) + + test('drains complete fallback frames before passing through an oversized tail', async () => { + const outcomes: ServerSideFallbackOutcome[] = [] + const frames = [ + sse('message_start', { + type: 'message_start', + message: { id: 'msg_overflow', model: 'claude-opus-5' }, + }), + sse('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'fallback', + from: { model: 'claude-fable-5' }, + to: { model: 'claude-opus-5' }, + }, + }), + sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { + iterations: [{ type: 'fallback_message', model: 'claude-opus-5' }], + }, + }), + ].join('') + const tail = 'unparseable-tail'.repeat(600_000) + const body = `${frames}${tail}` + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)) + controller.close() + }, + }), + ), + { + serverSideFallbackModel: 'claude-fable-5', + onServerSideFallbackOutcome: (outcome) => outcomes.push(outcome), + }, + ) + + const text = await response.text() + expect(text).toContain(SERVER_FALLBACK_SIGNATURE_PREFIX) + expect(text).toContain(tail) + expect(outcomes).toHaveLength(1) + }) + + test('drains an over-cap refusal terminal frame before disabling the tail', async () => { + const contentFilters: boolean[] = [] + const perf: Array> = [] + const terminal = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'refusal' }, + }) + const body = `${terminal}${'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)}` + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)) + controller.close() + }, + }), + ), + { + onContentFilter: () => { + contentFilters.push(true) + return false + }, + perf: (_stage, stats) => perf.push(stats ?? {}), + }, + ) + + expect(await response.text()).toBe(body) + expect(contentFilters).toEqual([true]) + expect(perf.at(-1)).toMatchObject({ + sseFinishPending: 0, + sseFinishDisabled: true, + }) + }) + + test('drains an over-cap ordinary terminal frame before disabling the tail', async () => { + const completed: string[] = [] + const perf: Array> = [] + const terminal = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + }) + const body = `${terminal}${'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)}` + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)) + controller.close() + }, + }), + ), + { + onComplete: (finishReason) => completed.push(finishReason), + perf: (_stage, stats) => perf.push(stats ?? {}), + }, + ) + + expect(await response.text()).toBe(body) + expect(completed).toEqual(['end_turn']) + expect(perf.at(-1)).toMatchObject({ + sseFinishPending: 0, + sseFinishDisabled: true, + }) + }) + + test.each([ + ['LF', '\n\n', 1], + ['CRLF', '\r\n\r\n', 2], + ])('resyncs finish detection across a split %s boundary', async (_name, delimiter, splitAt) => { + const completed: string[] = [] + const skipped = `data: ${'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)}` + const terminal = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + }) + const body = `${skipped}${delimiter}${terminal}` + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode(skipped)) + controller.enqueue(encoder.encode(delimiter.slice(0, splitAt))) + controller.enqueue( + encoder.encode(`${delimiter.slice(splitAt)}${terminal}`), + ) + controller.close() + }, + }), + ), + { onComplete: (finishReason) => completed.push(finishReason) }, + ) + + expect(await response.text()).toBe(body) + expect(completed).toEqual(['end_turn']) + }) + + test('swallows observation callback errors', async () => { + const payload = sse('message_start', { + type: 'message_start', + message: { id: 'msg_provider_3' }, + }) + const response = createStrippedStream(new Response(payload), { + onMessageStart: () => { + throw new Error('observer failure') + }, + }) + expect(await response.text()).toBe(payload) + }) + test('strips tool prefixes from streamed response body', async () => { const chunks = [ 'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_bash"}}\n\n', @@ -666,6 +949,49 @@ describe('createStrippedStream', () => { ).toBe(true) }) + test.each([ + ['LF', '\n\n', 1], + ['CRLF', '\r\n\r\n', 2], + ])('resyncs retryable stream errors across a split %s boundary', async (_name, delimiter, splitAt) => { + const encoder = new TextEncoder() + const perf: Array> = [] + const skipped = `data: ${'x'.repeat(NON_STREAMING_DIAGNOSTICS_MAX_BYTES * 2)}` + const error = sse('error', { + type: 'error', + error: { type: 'overloaded_error', message: 'temporarily overloaded' }, + }) + const response = createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(skipped)) + controller.enqueue(encoder.encode(delimiter.slice(0, splitAt))) + controller.enqueue( + encoder.encode(`${delimiter.slice(splitAt)}${error}`), + ) + controller.close() + }, + }), + ), + { perf: (_stage, stats) => perf.push(stats ?? {}) }, + ) + + let caught: unknown + try { + await response.text() + } catch (error) { + caught = error + } + + expect((caught as { code?: string }).code).toBe('ECONNRESET') + expect((caught as { providerErrorType?: string }).providerErrorType).toBe( + 'overloaded_error', + ) + expect( + Math.max(...perf.map((stats) => Number(stats.sseErrorPending ?? 0))), + ).toBeLessThanOrEqual(NON_STREAMING_DIAGNOSTICS_MAX_BYTES) + }) + test('reports server-side fallback outcomes without turning them into retryable errors', async () => { const encoder = new TextEncoder() const outcomes: ServerSideFallbackOutcome[] = [] @@ -1264,6 +1590,58 @@ describe('prependClaudeCodeIdentity', () => { }) describe('rewriteRequestBody', () => { + test('injects cache diagnostics with a null previous message id', async () => { + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }), + { cacheDiagnosticsPreviousMessageId: null }, + ), + ) + expect(result.diagnostics).toEqual({ previous_message_id: null }) + }) + + test('copies an opaque cache diagnostics message id by identity', async () => { + const id = 'msg_opaque_provider_value' + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }), + { cacheDiagnosticsPreviousMessageId: id }, + ), + ) + expect(result.diagnostics).toEqual({ previous_message_id: id }) + }) + + test('injects diagnostics for structured output bodies', async () => { + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ + messages: [{ role: 'user', content: 'hi' }], + output_config: { format: { type: 'json_schema' } }, + }), + { cacheDiagnosticsPreviousMessageId: null }, + ), + ) + expect(result.diagnostics).toEqual({ previous_message_id: null }) + }) + + test('does not inject diagnostics when the opt-in is omitted', async () => { + const result = JSON.parse( + await rewriteRequestBody( + JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }), + ), + ) + expect(result.diagnostics).toBeUndefined() + }) + + test('returns invalid JSON unchanged when diagnostics injection is requested', async () => { + const body = '{not json' + expect( + await rewriteRequestBody(body, { + cacheDiagnosticsPreviousMessageId: null, + }), + ).toBe(body) + }) + test('prefixes tool names and rewrites system prompt', async () => { const body = JSON.stringify({ tools: [{ name: 'bash', type: 'function' }], diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 06f366e9..6a865912 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -22,10 +22,15 @@ import { orderClaudeCodeBody, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, + selectClaudeCodeBetas, signRequestBody, TEXT_REPLACEMENTS, TOOL_PREFIX, } from '@cortexkit/anthropic-auth-core' +import { + applyCacheDiagnosticsOptIn, + CACHE_DIAGNOSTICS_BETA, +} from './cache-diagnostics' import { makeByteBoundedMemo } from './sanitize-memo' import { applyServerSideFallbackToBody, @@ -34,6 +39,8 @@ import { type ServerSideFallbackOutcome, } from './server-fallback' +export const NON_STREAMING_DIAGNOSTICS_MAX_BYTES = 8 * 1024 * 1024 + /** * Prefix a tool name with TOOL_PREFIX and uppercase the first character. * Claude Code uses PascalCase tool names (e.g. mcp_Bash, mcp_Read); @@ -1177,6 +1184,7 @@ export async function rewriteRequestBody( perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean + cacheDiagnosticsPreviousMessageId?: string | null } = {}, ): Promise { try { @@ -1286,6 +1294,16 @@ export async function rewriteRequestBody( delete parsed.speed } + if ( + options.cacheDiagnosticsPreviousMessageId !== undefined && + selectClaudeCodeBetas(parsed).split(',').includes(CACHE_DIAGNOSTICS_BETA) + ) { + applyCacheDiagnosticsOptIn( + parsed, + options.cacheDiagnosticsPreviousMessageId, + ) + } + const metadataStart = rewriteNowMs() if (options.identity) applyClaudeCodeMetadata(parsed, options.identity) options.perf?.('metadata', { @@ -1328,10 +1346,13 @@ type SseEventSummary = { inputJsonDeltaBytes?: number signatureDeltaBytes?: number redactedThinkingBytes?: number + message?: Record } type SseDiagnosticState = { pending: string + pendingOverflowCount: number + disabled: boolean events: number parseErrors: number eventCounts: Record @@ -1351,6 +1372,8 @@ const sseDiagnosticEncoder = new TextEncoder() function createSseDiagnosticState(): SseDiagnosticState { return { pending: '', + pendingOverflowCount: 0, + disabled: false, events: 0, parseErrors: 0, eventCounts: {}, @@ -1472,6 +1495,7 @@ function summarizeSseEvent(rawEvent: string): SseEventSummary | null { if (usage) { summary.stopReason ??= stringField(message, 'stop_reason') } + if (summary.type === 'message_start' && message) summary.message = message return summary } @@ -1484,8 +1508,13 @@ function findSseBoundary(value: string) { return { index: crlf, length: 4 } } -function updateSseDiagnostics(state: SseDiagnosticState, text: string) { - if (!text) return +function updateSseDiagnostics( + state: SseDiagnosticState, + text: string, + maxPendingBytes: number, + onEvent?: (summary: SseEventSummary) => void, +) { + if (!text || state.disabled) return state.pending += text while (true) { @@ -1508,19 +1537,30 @@ function updateSseDiagnostics(state: SseDiagnosticState, text: string) { state.signatureDeltaBytes += summary.signatureDeltaBytes ?? 0 state.redactedThinkingBytes += summary.redactedThinkingBytes ?? 0 state.last = summary + onEvent?.(summary) if (summary.dataBytes > 0 && !summary.type && !summary.event) { state.parseErrors++ } } + + if (sseDiagnosticEncoder.encode(state.pending).byteLength > maxPendingBytes) { + state.pending = '' + state.disabled = true + state.pendingOverflowCount++ + } } type SseErrorState = { pending: string + disabled: boolean + resyncCarry: string } type SseFinishState = { pending: string completed: boolean + disabled: boolean + resyncCarry: string } type SseFinishUpdate = @@ -1528,28 +1568,49 @@ type SseFinishUpdate = | { type: 'complete'; finishReason: string } function createSseFinishState(): SseFinishState { - return { pending: '', completed: false } + return { pending: '', completed: false, disabled: false, resyncCarry: '' } } function updateSseFinishState( state: SseFinishState, text: string, + maxPendingBytes: number, ): SseFinishUpdate | null { if (!text || state.completed) return null + if (state.disabled) { + const window = state.resyncCarry + text + const boundary = findSseBoundary(window) + if (!boundary) { + state.resyncCarry = window.slice(-3) + return null + } + state.disabled = false + state.resyncCarry = '' + text = window.slice(boundary.index + boundary.length) + if (!text) return null + } state.pending += text + let update: SseFinishUpdate | null = null while (true) { const boundary = findSseBoundary(state.pending) - if (!boundary) return null + if (!boundary) break const rawEvent = state.pending.slice(0, boundary.index) state.pending = state.pending.slice(boundary.index + boundary.length) const summary = summarizeSseEvent(rawEvent) if (summary?.type !== 'message_delta' || !summary.stopReason) continue state.completed = true - return summary.stopReason === 'refusal' - ? { type: 'content-filter' } - : { type: 'complete', finishReason: summary.stopReason } + update ??= + summary.stopReason === 'refusal' + ? { type: 'content-filter' } + : { type: 'complete', finishReason: summary.stopReason } } + if (new TextEncoder().encode(state.pending).byteLength > maxPendingBytes) { + state.pending = '' + state.disabled = true + state.resyncCarry = '' + } + return update } type RetryableAnthropicStreamError = Error & { @@ -1559,7 +1620,7 @@ type RetryableAnthropicStreamError = Error & { } function createSseErrorState(): SseErrorState { - return { pending: '' } + return { pending: '', disabled: false, resyncCarry: '' } } function isRetryableAnthropicStreamError( @@ -1654,9 +1715,23 @@ function retryableAnthropicStreamErrorFromRawEvent( function updateSseErrorState( state: SseErrorState, text: string, + maxPendingBytes: number, ): RetryableAnthropicStreamError | null { if (!text) return null + if (state.disabled) { + const window = state.resyncCarry + text + const boundary = findSseBoundary(window) + if (!boundary) { + state.resyncCarry = window.slice(-3) + return null + } + state.disabled = false + state.resyncCarry = '' + text = window.slice(boundary.index + boundary.length) + if (!text) return null + } state.pending += text + let retryable: RetryableAnthropicStreamError | null = null while (true) { const boundary = findSseBoundary(state.pending) @@ -1665,16 +1740,22 @@ function updateSseErrorState( const rawEvent = state.pending.slice(0, boundary.index) state.pending = state.pending.slice(boundary.index + boundary.length) const error = retryableAnthropicStreamErrorFromRawEvent(rawEvent) - if (error) return error + retryable ??= error } - return null + if (new TextEncoder().encode(state.pending).byteLength > maxPendingBytes) { + state.pending = '' + state.disabled = true + state.resyncCarry = '' + } + return retryable } function sseDiagnosticStats(state: SseDiagnosticState) { return { sseEvents: state.events, ssePendingChars: state.pending.length, + ssePendingOverflowCount: state.pendingOverflowCount, sseParseErrors: state.parseErrors, sseEventCounts: { ...state.eventCounts }, sseTypeCounts: { ...state.typeCounts }, @@ -1703,6 +1784,9 @@ export function createStrippedStream( contentFilterModel?: unknown serverSideFallbackModel?: string onServerSideFallbackOutcome?: (outcome: ServerSideFallbackOutcome) => void + onMessageStart?: (message: Record) => void + onMessageResponse?: (message: Record) => void + responseMode?: 'json' } = {}, ): Response { if (!response.body) return response @@ -1710,6 +1794,7 @@ export function createStrippedStream( const reader = response.body.getReader() const decoder = new TextDecoder() const encoder = new TextEncoder() + const jsonMode = options.responseMode === 'json' let pending = '' let chunkCount = 0 let pullCount = 0 @@ -1720,7 +1805,19 @@ export function createStrippedStream( let readerReleased = false let lastProgressAt = rewriteNowMs() const streamStart = rewriteNowMs() - const sseDiagnostics = options.perf ? createSseDiagnosticState() : undefined + const sseDiagnostics = + options.perf || options.onMessageStart + ? createSseDiagnosticState() + : undefined + let responseText = '' + let responseTextBytes = 0 + let responseTextOverflowed = false + const observe = (callback: (() => void) | undefined) => { + if (!callback) return + try { + callback() + } catch {} + } const sseErrors = createSseErrorState() const sseFinish = options.onContentFilter || options.onComplete @@ -1740,6 +1837,7 @@ export function createStrippedStream( const serverSideFallback = options.serverSideFallbackModel ? createServerSideFallbackStreamRewriter({ requestedModel: options.serverSideFallbackModel, + maxPendingBytes: NON_STREAMING_DIAGNOSTICS_MAX_BYTES, onOutcome: options.onServerSideFallbackOutcome, onRefusalAfterToolUse: options.onContentFilter ? () => invokeContentFilter(true) @@ -1749,7 +1847,11 @@ export function createStrippedStream( const updateFinish = (text: string) => { if (!sseFinish) return null - const update = updateSseFinishState(sseFinish, text) + const update = updateSseFinishState( + sseFinish, + text, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + ) if (update?.type === 'content-filter' && options.onContentFilter) { return invokeContentFilter() ? retryableFableContentFilterError(options.contentFilterModel) @@ -1779,6 +1881,10 @@ export function createStrippedStream( inputBytes, outputBytes, pendingChars: pending.length, + sseErrorPending: sseErrors.pending.length, + sseFinishPending: sseFinish?.pending.length ?? 0, + sseFinishDisabled: sseFinish?.disabled ?? false, + serverFallbackPending: serverSideFallback?.pendingLength() ?? 0, rewriteMs: rewriteRoundMs(rewriteMs), totalMs: rewriteRoundMs(rewriteNowMs() - streamStart), ...(sseDiagnostics ? sseDiagnosticStats(sseDiagnostics) : {}), @@ -1808,16 +1914,52 @@ export function createStrippedStream( const readMs = rewriteRoundMs(rewriteNowMs() - readStart) if (done) { const finalDecoded = decoder.decode() - if (sseDiagnostics) - updateSseDiagnostics(sseDiagnostics, finalDecoded) + if (sseDiagnostics && !jsonMode) + updateSseDiagnostics( + sseDiagnostics, + finalDecoded, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + (summary) => { + const message = summary.message + if (summary.type === 'message_start' && message) + observe(() => options.onMessageStart?.(message)) + }, + ) + if (jsonMode) { + const finalBytes = encoder.encode(finalDecoded).byteLength + if ( + responseTextBytes + finalBytes <= + NON_STREAMING_DIAGNOSTICS_MAX_BYTES + ) { + responseText += finalDecoded + responseTextBytes += finalBytes + } else { + responseTextOverflowed = true + } + } + if (jsonMode && !responseTextOverflowed) { + try { + const message = JSON.parse(responseText) + if ( + message && + typeof message === 'object' && + !Array.isArray(message) + ) + observe(() => options.onMessageResponse?.(message)) + } catch {} + } const rewriteStart = rewriteNowMs() const serverRewritten = serverSideFallback ? serverSideFallback.push(finalDecoded) + serverSideFallback.flush() : finalDecoded - const retryableStreamError = - updateSseErrorState(sseErrors, finalDecoded) ?? - updateFinish(serverRewritten) + const retryableStreamError = jsonMode + ? updateFinish(serverRewritten) + : (updateSseErrorState( + sseErrors, + finalDecoded, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + ) ?? updateFinish(serverRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, @@ -1845,14 +1987,40 @@ export function createStrippedStream( chunkCount++ inputBytes += value.byteLength const decoded = decoder.decode(value, { stream: true }) - if (sseDiagnostics) updateSseDiagnostics(sseDiagnostics, decoded) + if (jsonMode) { + const decodedBytes = value.byteLength + if ( + responseTextBytes + decodedBytes <= + NON_STREAMING_DIAGNOSTICS_MAX_BYTES + ) { + responseText += decoded + responseTextBytes += decodedBytes + } else { + responseTextOverflowed = true + } + } + if (sseDiagnostics && !jsonMode) + updateSseDiagnostics( + sseDiagnostics, + decoded, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + (summary) => { + const message = summary.message + if (summary.type === 'message_start' && message) + observe(() => options.onMessageStart?.(message)) + }, + ) const rewriteStart = rewriteNowMs() const serverRewritten = serverSideFallback ? serverSideFallback.push(decoded) : decoded - const retryableStreamError = - updateSseErrorState(sseErrors, decoded) ?? - updateFinish(serverRewritten) + const retryableStreamError = jsonMode + ? updateFinish(serverRewritten) + : (updateSseErrorState( + sseErrors, + decoded, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + ) ?? updateFinish(serverRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, From c1f9bfbedf0d833f2fcd8b926dfa4554749c0959 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:01:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(opencode):=20/claude-start=20=E2=80=94?= =?UTF-8?q?=20warm=20a=20session's=20cache=20lane=20with=20one=20synthetic?= =?UTF-8?q?=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an OpenCode-only /claude-start command: queue one synthetic one-token turn through the current session's normal model, agent, variant, quota, routing, cache, relay, signing, and response pipeline, so a resumed session's prompt cache is re-warmed and its TTL clock refreshed without an operator turn. The synthetic prompt ([lane start] - automated cache warm; no response needed.) is injected via the session SDK with synthetic:true and the session's context resolved from message history; OpenCode assembles the request exactly as a real turn, so the warm is byte-exact by construction. The fetch layer correlates the request by its synthetic message id (exact-match, one-shot, session-scoped, bounded) and shapes it fail-closed: max_tokens 1, thinking stripped, streaming kept. The terminal max_tokens stop is rewritten to end_turn for the correlated turn only, so the session records a clean micro-turn. CacheKeep adopts the session afterward; dumps tag start requests -start- on direct and relay paths; diagnostics records carry source:start with synthetic:true. API-key routes are unreachable by all of this. /claude-start off is persisted (and logged to the command audit trail); automatic is reserved and replies honestly that it is not yet wired. Verified live on a ~460K-token session: start read=459,602 write=419; next real turn read=460,021 = start read + write exactly - shaping does not fork the cache key. ~$0.48 per start vs ~$9.26 for a cold rewrite. Stacked on the cache-diagnostics capture branch (#159). --- CHANGELOG.md | 1 + README.md | 28 +- packages/core/src/accounts.ts | 24 + packages/core/src/dump.ts | 6 +- packages/core/src/index.ts | 1 + packages/core/src/relay.ts | 5 +- packages/core/src/start.ts | 85 +++ packages/core/src/tests/dump.test.ts | 25 + packages/opencode/README.md | 31 +- packages/opencode/src/cache-diagnostics.ts | 1 + packages/opencode/src/index.ts | 121 +++- packages/opencode/src/lane-start.ts | 84 +++ packages/opencode/src/prompt-context.ts | 63 +- packages/opencode/src/rpc/protocol.ts | 1 + packages/opencode/src/tests/accounts.test.ts | 46 ++ .../src/tests/cache-diagnostics.test.ts | 1 + packages/opencode/src/tests/cachekeep.test.ts | 2 +- packages/opencode/src/tests/index.test.ts | 626 +++++++++++++++++- packages/opencode/src/tests/info-logs.test.ts | 13 + .../opencode/src/tests/lane-start.test.ts | 194 ++++++ .../opencode/src/tests/prompt-context.test.ts | 133 ++++ packages/opencode/src/tests/relay.test.ts | 8 +- packages/opencode/src/tests/start.test.ts | 73 ++ packages/opencode/src/tests/transform.test.ts | 150 +++++ packages/opencode/src/transform.ts | 95 ++- 25 files changed, 1777 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/start.ts create mode 100644 packages/opencode/src/lane-start.ts create mode 100644 packages/opencode/src/tests/lane-start.test.ts create mode 100644 packages/opencode/src/tests/prompt-context.test.ts create mode 100644 packages/opencode/src/tests/start.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 172d35a4..eece870d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ## Unreleased +- Add the OpenCode-only `/claude-start` command for explicit synthetic one-token lane starts. The `automatic` form remains unavailable in the explicit-only v1 build, `off` persists the setting, and start requests use the `-start-` dump marker. - Capture Anthropic cache diagnostics in versioned `MC-CACHE-DIAG ` records, preserve provider response IDs across requests and cachekeep prewarms, and write response/request dump artifacts without response content. Document the beta states and known fingerprint, organization, workspace, and beta-set limitations. ## 1.19.1 diff --git a/README.md b/README.md index 7f76f28f..2d5b3d18 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-start`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Quota sidebar widget | OpenCode TUI plugin via `tui.json` | Not available | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | @@ -31,6 +31,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum. - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. +- **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. `/claude-start automatic` is unavailable in the explicit-only v1 build; `/claude-start off` persists the disabled setting. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 safety fallback (OpenCode)**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. @@ -49,7 +50,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - Support fallback Claude accounts stored in a local per-agent sidecar file. - Keep fallback OAuth tokens fresh in the background. - Apply quota thresholds before routing to main or fallback accounts. -- Add `/claude-cache`, `/claude-cachekeep`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. +- Add `/claude-cache`, `/claude-cachekeep`, `/claude-start`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands to OpenCode. - Optionally relay large requests through a Cloudflare Worker owned by the user. ## Install @@ -201,6 +202,9 @@ Example: "claudeFast": { "enabled": false }, + "claudeStart": { + "enabled": false + }, "costZeroing": { "enabled": true }, @@ -219,6 +223,10 @@ The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude- Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. `sticky-balanced` session assignments use a separate `anthropic-auth-routing-state.json`; session IDs are SHA-256 hashed in that file. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. +## OpenCode lane-start setting + +`claudeStart` stores the explicit-only `/claude-start off` setting in the OpenCode sidecar. `/claude-start automatic` replies exactly: `Automatic lane start is not yet wired in this build; no setting was changed.` and writes nothing. + ## Fallback accounts Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes managed by this plugin. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in sidecar order when the primary request returns a configured fallback status. @@ -487,6 +495,20 @@ Request bodies, headers, and tokens remain in memory. A lease-backed file under Pre-warm requests preserve explicit cache anchors but remove response-only fields that Anthropic rejects with `max_tokens: 0`, such as streaming, enabled thinking, structured output format, and forced/any tool choice. The feature works only while OpenCode or Pi is running and the machine is awake, and cache writes are still billed when the cache entry is no longer warm. +## OpenCode lane start + +`/claude-start` is an OpenCode-only command. It queues one synthetic turn for the current session: + +```text +/claude-start +/claude-start automatic +/claude-start off +``` + +The bare command fires immediately. The synthetic prompt uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued modal is a request to start the turn, not a provider-success claim. + +`/claude-start automatic` is unavailable in the explicit-only v1 build. It replies exactly: `Automatic lane start is not yet wired in this build; no setting was changed.` `/claude-start off` persists `claudeStart.enabled: false` in the OpenCode sidecar. Pi does not expose this command. + ## Claude fast mode Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: @@ -618,6 +640,8 @@ Each filename includes a sanitized session/affinity segment so dumps from differ - `*.relay.json` — redacted relay payload/frame metadata for relay requests. - `*.request.json` — redacted direct request URL, method, and headers for direct requests. +Lane-start requests use the `-start-` dump marker; CacheKeep keeps `-prewarm-cachekeep-`. Their cache-diagnostics records use `source: "start"` with `synthetic: true`, alongside ordinary `turn` records. + Dump state is persisted in the active sidecar config as `dump.enabled` (`~/.config/opencode/anthropic-auth.json` for OpenCode, `~/.pi/agent/anthropic-auth.json` for Pi). Dumps may contain prompt content and should be treated as sensitive local debugging artifacts. ## Environment variables diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index d7f0ea10..a4b9fce2 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -216,6 +216,9 @@ export type AccountStorage = { claudeFast?: { enabled?: boolean } + claudeStart?: { + enabled?: boolean + } /** * Zero out Anthropic OAuth model costs in the provider hook. Default: enabled * (OAuth usage is quota-based, not per-token billed, so costs show as $0). @@ -663,6 +666,7 @@ function normalizeStorage(value: unknown): AccountStorage | null { claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined, dump: isRecord(value.dump) ? value.dump : undefined, claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined, + claudeStart: isRecord(value.claudeStart) ? value.claudeStart : undefined, costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined, cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined, relay: isRecord(value.relay) ? value.relay : undefined, @@ -1056,6 +1060,7 @@ function configFromStorage(storage: AccountStorage): Record { dump: storage.dump, logging: storage.logging, claudeFast: storage.claudeFast, + claudeStart: storage.claudeStart, costZeroing: storage.costZeroing, cacheKeep: storage.cacheKeep, relay: storage.relay, @@ -1788,6 +1793,12 @@ export function isFastModePersistentlyEnabled(storage: AccountStorage | null) { return storage?.claudeFast?.enabled === true } +export function isStartAutomaticPersistentlyEnabled( + storage: AccountStorage | null, +) { + return storage?.claudeStart?.enabled === true +} + export async function setFastModePersistentEnabled( enabled: boolean, path = getAccountStoragePath(), @@ -1801,6 +1812,19 @@ export async function setFastModePersistentEnabled( return storage } +export async function setStartAutomaticPersistentEnabled( + enabled: boolean, + path = getAccountStoragePath(), +) { + const storage = (await loadAccounts(path)) ?? createEmptyStorage() + storage.claudeStart = { + ...(storage.claudeStart ?? {}), + enabled, + } + await saveAccounts(storage, path) + return storage +} + export async function setCacheKeepPersistentWindow( startHour: number, endHour: number, diff --git a/packages/core/src/dump.ts b/packages/core/src/dump.ts index 02699d50..821b6414 100644 --- a/packages/core/src/dump.ts +++ b/packages/core/src/dump.ts @@ -55,7 +55,7 @@ export type DumpCommandAction = | { type: 'disable' } | { type: 'usage' } -export type DumpTag = 'cachekeep' +export type DumpTag = 'cachekeep' | 'start' export type DumpHandle = { responsePath: string @@ -322,7 +322,9 @@ function dumpRequestSegment(input: { } function dumpTagSegment(tag: DumpTag | undefined) { - return tag ? `-prewarm-${tag}` : '' + if (tag === 'cachekeep') return '-prewarm-cachekeep' + if (tag === 'start') return '-start' + return '' } function directDumpPreviousKey(input: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e25acaa8..ec9e66fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,4 +21,5 @@ export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' export * from './routing.ts' +export * from './start.ts' export * from './sticky-routing.ts' diff --git a/packages/core/src/relay.ts b/packages/core/src/relay.ts index 085cd46d..2db67cb3 100644 --- a/packages/core/src/relay.ts +++ b/packages/core/src/relay.ts @@ -1109,7 +1109,8 @@ export async function sendViaRelay(options: { * within an attempt are ignored. */ onResponseHeaders?: (headers: Headers) => void - onDumpCreated?: (handle: { responsePath: string; tag?: 'cachekeep' }) => void + dumpTag?: import('./dump.ts').DumpTag + onDumpCreated?: (handle: import('./dump.ts').DumpHandle) => void setTimeoutImpl?: typeof globalThis.setTimeout clearTimeoutImpl?: typeof globalThis.clearTimeout }): Promise { @@ -1123,6 +1124,7 @@ export async function sendViaRelay(options: { affinity: explicitAffinity, optimisticResponse, onResponseHeaders, + dumpTag, onDumpCreated, setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout, @@ -1249,6 +1251,7 @@ export async function sendViaRelay(options: { previousBodyText: previous?.body, payload: result.payload, relayBytes: actualPayloadBytes, + tag: dumpTag, }) try { if (dumpHandle) onDumpCreated?.(dumpHandle) diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts new file mode 100644 index 00000000..23a3328c --- /dev/null +++ b/packages/core/src/start.ts @@ -0,0 +1,85 @@ +export const CLAUDE_START_COMMAND_NAME = 'claude-start' + +const START_STATUS_TITLE = '## Claude Lane Start Status' +const START_QUEUED_TITLE = '## Claude Lane Start Queued' +const START_OFF_TITLE = '## Claude Lane Start Disabled' +const START_USAGE_TITLE = '## Claude Lane Start Usage' +const START_USAGE = + 'Usage: `/claude-start`, `/claude-start automatic`, or `/claude-start off`.' + +export type LaneStartCommandAction = + | { type: 'fire' } + | { type: 'automatic' } + | { type: 'off' } + | { type: 'usage' } + +export function parseLaneStartCommandAction( + input: string, +): LaneStartCommandAction { + const normalized = input.trim().split(/\s+/).filter(Boolean) + if (normalized.length === 0) return { type: 'fire' } + if (normalized.length === 1 && normalized[0] === 'automatic') { + return { type: 'automatic' } + } + if (normalized.length === 1 && normalized[0] === 'off') return { type: 'off' } + return { type: 'usage' } +} + +export function buildLaneStartStatusSummary(input: { + automaticEnabled: boolean +}): string { + return [ + START_STATUS_TITLE, + '', + `- Enabled: ${input.automaticEnabled ? 'enabled' : 'disabled'}`, + '- Persisted: ~/.config/opencode/anthropic-auth.json', + '- Scope: queues one-token OAuth lane-start requests', + '- Note: automatic lane starts are not yet wired; explicit-only mode is active', + ].join('\n') +} + +export function executeLaneStartCommand(input: { + argumentsText: string + automaticEnabled: boolean +}): { action: LaneStartCommandAction; text: string } { + const action = parseLaneStartCommandAction(input.argumentsText) + if (action.type === 'fire') { + return { + action, + text: [ + START_QUEUED_TITLE, + '', + '- Queued an explicit lane-start request.', + ].join('\n'), + } + } + if (action.type === 'automatic') { + return { + action, + text: 'Automatic lane start is not yet wired in this build; no setting was changed.', + } + } + if (action.type === 'off') { + return { + action, + text: [ + START_OFF_TITLE, + '', + '- Automatic lane starts are disabled.', + '- Persisted: ~/.config/opencode/anthropic-auth.json', + '', + buildLaneStartStatusSummary({ automaticEnabled: false }), + ].join('\n'), + } + } + return { + action, + text: [ + START_USAGE_TITLE, + '', + START_USAGE, + '', + buildLaneStartStatusSummary(input), + ].join('\n'), + } +} diff --git a/packages/core/src/tests/dump.test.ts b/packages/core/src/tests/dump.test.ts index 349f1213..7db59e43 100644 --- a/packages/core/src/tests/dump.test.ts +++ b/packages/core/src/tests/dump.test.ts @@ -195,6 +195,31 @@ test('tagged prewarm dumps include the tag in filenames and metadata', async () expect(metadata.tag).toBe('cachekeep') }) +test('start dumps use the distinct start filename segment', async () => { + const dumpDir = await mkdtemp( + join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), + ) + dumpDirs.push(dumpDir) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + setDumpEnabled(true) + const handle = await dumpDirectRequest({ + affinity: 'ses-start', + bodyText: '{}', + tag: 'start', + }) + expect(handle?.tag).toBe('start') + expect(handle?.responsePath).toMatch(/-start-direct\.response\.json$/) + expect(handle?.responsePath).not.toContain('-prewarm-start') + const files = await readdir(dumpDir) + const metadata = JSON.parse( + await readFile( + join(dumpDir, files.find((name) => name.endsWith('.meta.json'))!), + 'utf8', + ), + ) + expect(metadata.tag).toBe('start') +}) + test('dump sweep recognizes response artifacts', async () => { const dumpDir = await mkdtemp( join(tmpdir(), 'opencode-anthropic-auth-dumps-test-'), diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 5fc4ace6..0c5143dc 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-start`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | ## What CortexKit adds over the original plugin @@ -30,6 +30,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum. - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. +- **Lane start (OpenCode only)**: use `/claude-start` to fire one synthetic, one-token turn through the current session's normal model, agent, variant, quota, routing, cache, and request pipeline. `/claude-start automatic` is unavailable in the explicit-only v1 build; `/claude-start off` persists the disabled setting. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 safety fallback**: eligible OAuth requests try Anthropic's server-side safety fallback first. The plugin preserves Anthropic's fallback conversation boundary across OpenCode history and automatically starts its deterministic 10-response Opus 4.8 recovery if the response still ends in refusal. The TUI sidebar and OpenCode Desktop report the active target model and restoration. Set `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` to bypass the server policy and use client-side recovery exclusively. @@ -199,6 +200,9 @@ Example: "claudeFast": { "enabled": false }, + "claudeStart": { + "enabled": false + }, "costZeroing": { "enabled": true }, @@ -217,6 +221,10 @@ The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude- Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. Sticky session assignments use `anthropic-auth-routing-state.json` and store only SHA-256 hashes of session IDs. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. +## OpenCode lane-start setting + +`claudeStart` stores the explicit-only `/claude-start off` setting in the OpenCode sidecar. `/claude-start automatic` replies exactly: `Automatic lane start is not yet wired in this build; no setting was changed.` and writes nothing. + ## Fallback accounts Fallback accounts are separate Claude OAuth accounts or Anthropic-compatible API-key routes managed by this plugin. By default, the main account is tried first unless quota policy says it is currently unusable. Fallbacks are then tried in sidecar order when the primary request returns a configured fallback status. @@ -393,6 +401,20 @@ Request bodies, headers, and tokens remain in memory. A lease-backed file under Pre-warm requests preserve explicit cache anchors but remove response-only fields that Anthropic rejects with `max_tokens: 0`, such as streaming, enabled thinking, structured output format, and forced/any tool choice. The feature works only while OpenCode or Pi is running and the machine is awake, and cache writes are still billed when the cache entry is no longer warm. +## OpenCode lane start + +`/claude-start` is an OpenCode-only command. It queues one synthetic turn for the current session: + +```text +/claude-start +/claude-start automatic +/claude-start off +``` + +The bare command fires immediately. The synthetic prompt uses the session's current model, agent, and variant, then travels through the ordinary quota, routing, cache, relay, signing, and response pipeline. OpenCode shapes that OAuth request to `max_tokens: 1` while keeping streaming enabled, and correlates the request by its synthetic message ID. A queued modal is a request to start the turn, not a provider-success claim. + +`/claude-start automatic` is unavailable in the explicit-only v1 build. It replies exactly: `Automatic lane start is not yet wired in this build; no setting was changed.` `/claude-start off` persists `claudeStart.enabled: false` in the OpenCode sidecar. Pi does not expose this command. + ### Cache diagnostics (beta) The `cache-diagnosis-2026-04-07` beta is measure-only. It asks Anthropic to report prompt-cache diagnostics; it does not change cache controls or routing. OpenCode captures the provider's top-level response ID as an opaque string and sends it as `diagnostics.previous_message_id` on the next request in the same session. The first request sends `null`. @@ -402,7 +424,7 @@ The `MC-CACHE-DIAG ` line is a versioned, one-line JSON record. Version `2` cont | Field | Type | Source | | --- | --- | --- | | `v` | `2` | Capture schema | -| `source` | string | Observation path; known values are `"turn"` and `"prewarm_cachekeep"`, but consumers must tolerate future values | +| `source` | string | Observation path; known values are `"turn"`, `"start"`, and `"prewarm_cachekeep"`, but consumers must tolerate future values | | `synthetic` | boolean | Whether this observation was generated by plugin machinery rather than a real turn | | `account_id` | string | Persisted plugin-internal OAuth account identifier used by routing and the sidebar; stable across restarts and token refreshes, so consumers may key timelines on it. Opaque mixed key space (the main account is a sentinel string, fallbacks are UUIDs) — never validate its shape | | `betas_hash` | 16-character lowercase hex | xxHash64 (seed `0`) of the sorted `anthropic-beta` list actually sent, truncated to its first 16 hexadecimal characters | @@ -434,8 +456,11 @@ Known sources map to `synthetic` as follows: | `source` | `synthetic` | | --- | --- | | `turn` | `false` | +| `start` | `true` | | `prewarm_cachekeep` | `true` | +Lane-start cache observations therefore appear as ordinary `MC-CACHE-DIAG ` records with `source: "start"`; the row above is the diagnostics-side companion to the `/claude-start` pipeline described earlier. + `synthetic` wins on conflict. A disagreement for a known source is an emitter defect: OpenCode writes one warning and still emits the record with the supplied `synthetic` value. Unknown future sources have no mapping and must not be rejected by consumers. The first observation of each `betas_hash` in a process also writes `MC-CACHE-DIAG-BETAS {"hash":"…","betas":[…]}` on the `cache-diagnostics` logger channel. Its sorted beta list makes an observed hash interpretable without reconstructing headers; repeated hashes do not emit another side-channel line. @@ -461,7 +486,7 @@ Diagnostics comparison requires a cacheable prefix. Requests below the model's c Version 1 records come from the unversioned-source era and cannot distinguish prewarms from turns; consumers must treat their source as unknown and cannot split machinery from traffic retroactively. Version 2 always states the source. -When request dumps are enabled, each response gets a `.response.json` artifact containing status and parsed response metadata, but no response content. Cache keepalive prewarms are tagged `-prewarm-cachekeep` in dump filenames and metadata. If Prime prewarming is enabled in a build that supports it, those artifacts use `-prewarm-prime`. Treat request bodies and related dump files as sensitive local debugging data. +When request dumps are enabled, each response gets a `.response.json` artifact containing status and parsed response metadata, but no response content. Lane-start requests are tagged `-start-`; CacheKeep prewarms retain `-prewarm-cachekeep-`. If Prime prewarming is enabled in a build that supports it, those artifacts use `-prewarm-prime`. Treat request bodies and related dump files as sensitive local debugging data. ## Claude fast mode diff --git a/packages/opencode/src/cache-diagnostics.ts b/packages/opencode/src/cache-diagnostics.ts index 54a87a59..0236c645 100644 --- a/packages/opencode/src/cache-diagnostics.ts +++ b/packages/opencode/src/cache-diagnostics.ts @@ -18,6 +18,7 @@ export type CacheDiagnosticsSource = string export const CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC = { turn: false, + start: true, prewarm_cachekeep: true, } as const diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5c5ec5a5..4973e216 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -25,6 +25,7 @@ import { CLAUDE_LOGGING_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + CLAUDE_START_COMMAND_NAME, computeXxhash64Hex, createEmptyStorage, createStickyNoRouteResponse, @@ -39,6 +40,7 @@ import { executeDumpCommand, executeFastModeCommand, executeKillswitchCommand, + executeLaneStartCommand, executeLoggingCommand, executeRoutingCommand, FallbackAccountManager, @@ -78,6 +80,7 @@ import { isOAuthAccount, isPermanentRefreshError, isQuotaBearingHeaderFrame, + isStartAutomaticPersistentlyEnabled, isValidApiBaseURL, KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, @@ -97,6 +100,7 @@ import { parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, + parseLaneStartCommandAction, parseLoggingCommandAction, parseRoutingCommandAction, type QuotaAccountSummary, @@ -132,6 +136,7 @@ import { setLogLevel, setLogLevelPersistent, setRoutingMode, + setStartAutomaticPersistentEnabled, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, stickyRouteFamilyForModel, @@ -158,6 +163,11 @@ import { isRecoverableRefusalModel, recoverableRefusalFamily, } from './fable-fallback.ts' +import { + fireLaneStart, + LANE_START_REQUEST_HEADER, + LaneStartTracker, +} from './lane-start.ts' import { resolvePromptContext } from './prompt-context.ts' import { drainNotifications, @@ -877,6 +887,7 @@ const anthropicAuthPlugin = async ( process.env.OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE, ) const fableFallbackManager = new FableFallbackManager() + const laneStartTracker = new LaneStartTracker() const serverFallbackTargets = new Map() const pendingDesktopNotices = new Map() const desktopNoticeFlushes = new Map>() @@ -2312,6 +2323,35 @@ const anthropicAuthPlugin = async ( return executeFastModeCommand({ argumentsText, enabled }) } + async function executePersistentStartCommand( + argumentsText: string, + sessionId?: string, + ) { + const action = parseLaneStartCommandAction(argumentsText) + const storage = await loadAccounts(accountStoragePath) + const automaticEnabled = isStartAutomaticPersistentlyEnabled(storage) + if (action.type === 'fire') { + if (!sessionId) { + return '## Claude Start Failed\n\n- OpenCode did not provide a session ID.' + } + try { + await fireLaneStart(ctx.client, sessionId) + return executeLaneStartCommand({ argumentsText, automaticEnabled }).text + } catch (error) { + return `## Claude Start Failed\n\n- ${error instanceof Error ? error.message : String(error)}` + } + } + if (action.type === 'off') { + await setStartAutomaticPersistentEnabled(false, accountStoragePath) + logger.info('commands', 'start automatic changed', { enabled: false }) + return executeLaneStartCommand({ + argumentsText, + automaticEnabled: false, + }).text + } + return executeLaneStartCommand({ argumentsText, automaticEnabled }).text + } + async function executePersistentRoutingCommand( argumentsText: string, sessionId?: string, @@ -2591,6 +2631,17 @@ const anthropicAuthPlugin = async ( ): Promise { if (command === 'claude-quota') return { command, text: await buildQuotaCommandSummary(), knobs: {} } + if (command === 'claude-start') { + const text = await executePersistentStartCommand(args, sessionId) + const storage = await loadAccounts(accountStoragePath) + return { + command, + text, + knobs: { + enabled: isStartAutomaticPersistentlyEnabled(storage), + }, + } + } if (command === 'claude-logging') { const text = await executePersistentLoggingCommand(args) const storage = await loadAccounts(accountStoragePath) @@ -2803,6 +2854,36 @@ const anthropicAuthPlugin = async ( } return { + 'chat.message': async ( + { + sessionID, + }: { + sessionID: string + }, + output: { message: { id: string }; parts: unknown[] }, + ) => { + laneStartTracker.observeSyntheticMessage({ + sessionId: sessionID, + messageId: output.message.id, + parts: output.parts, + }) + }, + 'chat.headers': async ( + { + sessionID, + message, + }: { + sessionID: string + message: { id: string } + }, + output: { headers: Record }, + ) => { + laneStartTracker.markHeaders({ + sessionId: sessionID, + messageId: message.id, + headers: output.headers, + }) + }, event: async ({ event }: { event: unknown }) => { const value = event as unknown as { type?: string @@ -2840,6 +2921,7 @@ const anthropicAuthPlugin = async ( } if (value.type === 'session.deleted') { + laneStartTracker.clearSession(sessionId) fableRecoveryNotices.delete(sessionId) pendingDesktopNotices.delete(sessionId) } @@ -2862,6 +2944,11 @@ const anthropicAuthPlugin = async ( description: 'Keep hybrid Claude cache warm always or during a local time window.', }, + [CLAUDE_START_COMMAND_NAME]: { + template: CLAUDE_START_COMMAND_NAME, + description: + 'Warm and renew the current Claude session cache with a one-token synthetic turn.', + }, [CLAUDE_QUOTAS_COMMAND_NAME]: { template: CLAUDE_QUOTAS_COMMAND_NAME, @@ -2934,6 +3021,7 @@ const anthropicAuthPlugin = async ( 'claude-account', 'claude-cache', 'claude-cachekeep', + 'claude-start', 'claude-quota', 'claude-dump', 'claude-fast', @@ -3699,6 +3787,7 @@ const anthropicAuthPlugin = async ( currentStorage?: Awaited>, oauthAccountId = 'main', fableRequest?: FableRequestContext, + laneStartRequest = false, ) { const start = nowMs() let requestStorage = currentStorage @@ -3779,6 +3868,7 @@ const anthropicAuthPlugin = async ( identity, hybridStandbyAnchor: standbyCacheAnchor, serverSideFallbackEnabled: fallbackMode === 'server', + laneStart: laneStartRequest, cacheDiagnosticsPreviousMessageId, perf: (stage, data) => { trace?.mark(`rewrite_body_${stage}`, { route, ...data }) @@ -3936,6 +4026,7 @@ const anthropicAuthPlugin = async ( fetchInputUrl(rewritten.input), method: fetchMethod(input, init), headers: requestHeaders, + tag: laneStartRequest ? 'start' : undefined, }) } return response @@ -3951,6 +4042,7 @@ const anthropicAuthPlugin = async ( fetchInputUrl(rewritten.input), method: fetchMethod(input, init), headers: requestHeaders, + tag: laneStartRequest ? 'start' : undefined, }) } throw error @@ -3977,6 +4069,7 @@ const anthropicAuthPlugin = async ( onDumpCreated: (handle) => { relayDump = handle }, + dumpTag: laneStartRequest ? 'start' : undefined, }) trace?.mark('send_headers_received', { route, @@ -3988,9 +4081,9 @@ const anthropicAuthPlugin = async ( if (usedDirectFetch) harvestQuotaHeaders(response.headers, served) attachCacheDiagnosticsResponse(response, { - source: 'turn', + source: laneStartRequest ? 'start' : 'turn', accountId: oauthAccountId, - synthetic: false, + synthetic: laneStartRequest, ...cacheDiagnosticsBetas, requestedModel: parseRequestModel(body), request: cacheDiagnosticsRequest, @@ -4260,6 +4353,8 @@ const anthropicAuthPlugin = async ( } } + const responseRouteKinds = new WeakMap() + async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -4274,6 +4369,7 @@ const anthropicAuthPlugin = async ( access?: string }) => void | Promise fableRequest?: FableRequestContext + laneStartRequest?: boolean }, ) { if (!accounts.length) return currentResponse ?? null @@ -4307,6 +4403,7 @@ const anthropicAuthPlugin = async ( storage, account.id, options?.fableRequest, + options?.laneStartRequest, ) } lastResponse = response @@ -4321,6 +4418,10 @@ const anthropicAuthPlugin = async ( fallbackAgain = inspected.rateLimited } if (!fallbackAgain) { + responseRouteKinds.set( + response, + isApiKeyAccount(account) ? 'api' : 'oauth', + ) await fallbackManager.markUsed(account) await options?.onSuccess?.(account) // Active-route every-N refresh: this fallback just served the @@ -4360,6 +4461,7 @@ const anthropicAuthPlugin = async ( }) => void, modelId?: string, fableRequest?: FableRequestContext, + laneStartRequest = false, ) { if (!isReplayableRequest(input, init?.body)) return mainResponse @@ -4458,6 +4560,7 @@ const anthropicAuthPlugin = async ( { onSuccess: onFallbackSuccess, fableRequest, + laneStartRequest, }, )) ?? currentResponse ) @@ -4467,6 +4570,10 @@ const anthropicAuthPlugin = async ( apiKey: '', async fetch(input: string | URL | Request, init?: RequestInit) { const incomingHeaders = mergeHeaders(input, init) + const laneStartRequest = + incomingHeaders.get(LANE_START_REQUEST_HEADER) === '1' + incomingHeaders.delete(LANE_START_REQUEST_HEADER) + init = { ...init, headers: incomingHeaders } const sessionId = incomingHeaders.get('x-session-affinity') || incomingHeaders.get('x-opencode-session') @@ -4505,6 +4612,9 @@ const anthropicAuthPlugin = async ( cacheDiagnosticsResponses.get(response) return createStrippedStream(response, { perf: (stage, data) => trace.mark(stage, data), + laneStart: laneStartRequest, + laneStartOAuthServed: + responseRouteKinds.get(response) !== 'api', ...(diagnosticsContext ? diagnosticsContext.streaming ? { @@ -4795,6 +4905,7 @@ const anthropicAuthPlugin = async ( stickyRoutes.storage, selected.id, fableRequest, + laneStartRequest, ) const completeRoute = async ( selected: StickyOAuthRoute, @@ -5027,6 +5138,7 @@ const anthropicAuthPlugin = async ( 'sticky-balanced', ), fableRequest, + laneStartRequest, }, ) if (apiResponse) { @@ -5085,6 +5197,7 @@ const anthropicAuthPlugin = async ( onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback-first'), fableRequest, + laneStartRequest, }, ) if (fallbackResponse) { @@ -5284,6 +5397,7 @@ const anthropicAuthPlugin = async ( onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), fableRequest, + laneStartRequest, }, ) if (fallbackResponse) { @@ -5427,6 +5541,7 @@ const anthropicAuthPlugin = async ( // is wrong once the killswitch hands off to a fallback. onSuccess: (account) => writeCurrentSidebarState(account.id, 'fallback'), + laneStartRequest, }, ) // The killswitch is a HARD block: it must never fall through to @@ -5496,6 +5611,7 @@ const anthropicAuthPlugin = async ( storage, 'main', fableRequest, + laneStartRequest, ) let fallbackServed = false const response = await tryFallbackAccounts( @@ -5512,6 +5628,7 @@ const anthropicAuthPlugin = async ( }, requestModelId, fableRequest, + laneStartRequest, ) if (!fallbackServed) writeCurrentSidebarState('main', 'main') diff --git a/packages/opencode/src/lane-start.ts b/packages/opencode/src/lane-start.ts new file mode 100644 index 00000000..a6b0c599 --- /dev/null +++ b/packages/opencode/src/lane-start.ts @@ -0,0 +1,84 @@ +import { resolvePromptContext } from './prompt-context' + +export const LANE_START_TEXT = + '[lane start] — automated cache warm; no response needed.' +export const LANE_START_REQUEST_HEADER = 'x-cortexkit-lane-start' + +const MAX_PENDING_MESSAGE_IDS = 1_000 + +type PluginSessionClient = { + promptAsync?: (request: unknown) => Promise | unknown +} + +function isLaneStartPart(part: unknown) { + if (!part || typeof part !== 'object') return false + const record = part as Record + return ( + record.type === 'text' && + record.text === LANE_START_TEXT && + record.synthetic === true + ) +} + +export async function fireLaneStart( + client: unknown, + sessionId: string, +): Promise { + const session = (client as { session?: PluginSessionClient } | null)?.session + if (typeof session?.promptAsync !== 'function') { + throw new Error( + 'OpenCode plugin client does not support session.promptAsync', + ) + } + + const promptContext = await resolvePromptContext(client, sessionId) + const body: Record = { + noReply: false, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + } + if (promptContext?.agent) body.agent = promptContext.agent + if (promptContext?.model) body.model = promptContext.model + if (promptContext?.variant) body.variant = promptContext.variant + + await Promise.resolve(session.promptAsync({ path: { id: sessionId }, body })) +} + +export class LaneStartTracker { + #pending = new Map() + + observeSyntheticMessage(input: { + sessionId: string + messageId: string + parts: unknown[] + }): boolean { + if (!input.parts.some(isLaneStartPart)) return false + const key = `${input.sessionId}\u0000${input.messageId}` + if ( + !this.#pending.has(key) && + this.#pending.size >= MAX_PENDING_MESSAGE_IDS + ) { + const oldest = this.#pending.keys().next().value + if (oldest !== undefined) this.#pending.delete(oldest) + } + this.#pending.set(key, input.sessionId) + return true + } + + markHeaders(input: { + sessionId: string + messageId: string + headers: Record + }): boolean { + const key = `${input.sessionId}\u0000${input.messageId}` + if (!this.#pending.has(key)) return false + this.#pending.delete(key) + input.headers[LANE_START_REQUEST_HEADER] = '1' + return true + } + + clearSession(sessionId: string): void { + for (const [key, pendingSessionId] of this.#pending) { + if (pendingSessionId === sessionId) this.#pending.delete(key) + } + } +} diff --git a/packages/opencode/src/prompt-context.ts b/packages/opencode/src/prompt-context.ts index 08267d2c..6a8c651a 100644 --- a/packages/opencode/src/prompt-context.ts +++ b/packages/opencode/src/prompt-context.ts @@ -22,7 +22,12 @@ interface RawInfo { variant?: string providerID?: string modelID?: string - model?: { providerID?: string; modelID?: string; variant?: string } + model?: { + providerID?: string + modelID?: string + id?: string + variant?: string + } } function isRecord(value: unknown): value is Record { @@ -65,9 +70,11 @@ function extractFromMessage(message: unknown): ResolvedPromptContext | null { const modelID = typeof modelInfo?.modelID === 'string' ? modelInfo.modelID - : typeof info.modelID === 'string' - ? info.modelID - : undefined + : typeof modelInfo?.id === 'string' + ? modelInfo.id + : typeof info.modelID === 'string' + ? info.modelID + : undefined const variant = typeof modelInfo?.variant === 'string' ? modelInfo.variant @@ -115,24 +122,26 @@ export async function resolvePromptContext( | Promise<{ data?: unknown[] } | unknown[]> | { data?: unknown[] } | unknown[] + get?: (input: { + path: { id: string } + }) => Promise<{ data?: unknown } | unknown> | { data?: unknown } | unknown } } - if (typeof typedClient.session?.messages !== 'function') return null - let messages: unknown[] = [] - try { - messages = extractMessages( - await Promise.resolve( - typedClient.session.messages({ - path: { id: sessionId }, - query: { limit: 100 }, - }), - ), - ) - } catch { - return null + if (typeof typedClient.session?.messages === 'function') { + try { + messages = extractMessages( + await Promise.resolve( + typedClient.session.messages({ + path: { id: sessionId }, + query: { limit: 100 }, + }), + ), + ) + } catch { + messages = [] + } } - if (messages.length === 0) return null let latestAssistantMessageId: string | undefined let latestUserMessageId: string | undefined @@ -170,6 +179,24 @@ export async function resolvePromptContext( if (isComplete(result)) return result } + if (typeof typedClient.session?.get === 'function') { + try { + const response = await Promise.resolve( + typedClient.session.get({ path: { id: sessionId } }), + ) + const session = + isRecord(response) && isRecord(response.data) + ? response.data + : isRecord(response) + ? response + : undefined + const metadata = session ? extractFromMessage({ info: session }) : null + if (metadata) result = mergeContexts(result, metadata) + } catch { + // Message-derived context remains usable when the metadata fallback fails. + } + } + if ( !result.agent && !result.model && diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index 29ca1b30..78d45b00 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -2,6 +2,7 @@ export type CommandModalName = | 'claude-account' | 'claude-cache' | 'claude-cachekeep' + | 'claude-start' | 'claude-quota' | 'claude-dump' | 'claude-fast' diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 3a8811b0..58951b5d 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -31,6 +31,7 @@ import { isCostZeroingEnabled, isFastModePersistentlyEnabled, isPermanentRefreshError, + isStartAutomaticPersistentlyEnabled, type KillswitchThresholds, killswitchPassesPolicy, loadAccounts, @@ -61,6 +62,7 @@ import { setFastModePersistentEnabled, setLogLevel, setLogLevelPersistent, + setStartAutomaticPersistentEnabled, shouldFallbackStatus, tokenFingerprint, upsertAccount, @@ -156,6 +158,50 @@ afterEach(async () => { mock.restore() }) +describe('claudeStart persistence', () => { + test('absent claudeStart reads as disabled', async () => { + const storage = await loadAccounts(accountPath) + expect(isStartAutomaticPersistentlyEnabled(storage)).toBe(false) + }) + + test('true round-trips through the config sidecar', async () => { + const storage = await setStartAutomaticPersistentEnabled(true, accountPath) + expect(storage.claudeStart).toEqual({ enabled: true }) + expect( + isStartAutomaticPersistentlyEnabled(await loadAccounts(accountPath)), + ).toBe(true) + }) + + test('false is explicitly serialized instead of dropped', async () => { + await setStartAutomaticPersistentEnabled(false, accountPath) + const config = JSON.parse(await readFile(accountPath, 'utf8')) + expect(config.claudeStart).toEqual({ enabled: false }) + }) + + test('preserves unrelated config fields and accounts', async () => { + const storage = baseStorage() + storage.routing = { mode: 'sticky-balanced' } + await saveAccounts(storage, accountPath) + await setStartAutomaticPersistentEnabled(false, accountPath) + const loaded = await loadAccounts(accountPath) + expect(loaded?.routing).toEqual({ mode: 'sticky-balanced' }) + expect(loaded?.accounts).toEqual([]) + }) + + test('writes claudeStart to config, not runtime state', async () => { + await setStartAutomaticPersistentEnabled(true, accountPath) + expect(JSON.parse(await readFile(accountPath, 'utf8')).claudeStart).toEqual( + { + enabled: true, + }, + ) + const state = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(state.claudeStart).toBeUndefined() + }) +}) + describe('OAuth account profiles', () => { const mainCapture = { account: { has_claude_max: true }, diff --git a/packages/opencode/src/tests/cache-diagnostics.test.ts b/packages/opencode/src/tests/cache-diagnostics.test.ts index f7601760..18aa4e08 100644 --- a/packages/opencode/src/tests/cache-diagnostics.test.ts +++ b/packages/opencode/src/tests/cache-diagnostics.test.ts @@ -158,6 +158,7 @@ describe('cache diagnostics v2 contract', () => { test('exports the known source synthetic mapping', () => { expect(CACHE_DIAGNOSTICS_SOURCE_SYNTHETIC).toEqual({ turn: false, + start: true, prewarm_cachekeep: true, }) }) diff --git a/packages/opencode/src/tests/cachekeep.test.ts b/packages/opencode/src/tests/cachekeep.test.ts index 636ce782..7c88c9ee 100644 --- a/packages/opencode/src/tests/cachekeep.test.ts +++ b/packages/opencode/src/tests/cachekeep.test.ts @@ -227,9 +227,9 @@ describe('CacheKeepManager', () => { bodyText: body, }) expect(result.ok).toBe(true) + expect(sent[0]).toBeDefined() expect(sent[0]).toContain('prepared') expect(observed).toHaveLength(1) - expect(sent[0]).toBeDefined() expect((observed[0] as { bodyText: string }).bodyText).toBe(sent[0]!) expect( (observed[0] as { data: { usage: { cache_creation: unknown } } }).data diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 3bd6603d..06dad8d6 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -24,6 +24,7 @@ import { tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' +import { LANE_START_REQUEST_HEADER, LANE_START_TEXT } from '../lane-start' import { drainNotifications, resetNotificationsForTest, @@ -1441,7 +1442,23 @@ describe('auth.loader', () => { createFallbackStorage({ accounts: [], dump: { enabled: true }, - quota: { enabled: false }, + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('main-access'), + }, }), ) @@ -3182,6 +3199,7 @@ describe('auth.loader', () => { 'claude-account', 'claude-cache', 'claude-cachekeep', + 'claude-start', 'claude-quota', 'claude-dump', 'claude-fast', @@ -3194,7 +3212,7 @@ describe('auth.loader', () => { } // The config hook must not register extra claude-* commands beyond the - // modalCommands set (drift in either direction is a bug). Exactly the 9 + // modalCommands set (drift in either direction is a bug). Exactly the 10 // required names should be claude-* keys — no more, no less. (The foreign // 'other-plugin-cmd' is excluded from this count via the claude- prefix.) const claudeRegistered = registered.filter((name) => @@ -3204,6 +3222,51 @@ describe('auth.loader', () => { expect([...claudeRegistered].sort()).toEqual([...required].sort()) }) + test('handles /claude-start by injecting one visible synthetic prompt', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + const plugin = await getPlugin(mockClient) + + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-start', + arguments: '', + sessionID: 'session-start', + }), + ) + + const promptCalls = ( + mockClient.session.promptAsync as unknown as { + mock: { + calls: Array<[{ body: { parts: Array> } }]> + } + } + ).mock.calls as Array< + [ + { + path: { id: string } + body: { noReply: boolean; parts: Array> } + }, + ] + > + const startCall = promptCalls + .map(([call]) => call) + .find((call) => call.body.parts[0]?.synthetic === true) + expect(startCall).toEqual({ + path: { id: 'session-start' }, + body: { + noReply: false, + parts: [ + { + type: 'text', + text: '[lane start] — automated cache warm; no response needed.', + synthetic: true, + }, + ], + }, + }) + }) + test('handles /claude-cachekeep command and persists window', async () => { await useTempAccountFile( createFallbackStorage({ @@ -9403,6 +9466,565 @@ describe('auth.loader', () => { }) }) +describe('claude-start integration', () => { + const originalFetch = globalThis.fetch + + beforeEach(async () => { + pluginTimerOverrides = { + setInterval: mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval, + clearInterval: mock(() => {}) as unknown as typeof clearInterval, + } + resetCache1hState() + resetDumpState() + setLogLevel('info') + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + claudeCache: { enabled: true, mode: 'hybrid' }, + cacheKeep: { enabled: true, always: true }, + }), + ) + }) + + afterEach(async () => { + __setLogTestSink(null) + globalThis.fetch = originalFetch + pluginTimerOverrides = {} + resetDumpState() + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await drainSidebarWrites() + restoreProcessTestFiles() + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }) + tempConfigDir = undefined + } + }) + + test('claude-start request shapes only its correlated OAuth turn and emits diagnostics', async () => { + const sent: Array<{ body: Record; headers: Headers }> = [] + const records: LogTestRecord[] = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve( + new Response( + `event: message_start\ndata: ${JSON.stringify({ + type: 'message_start', + message: { + id: 'provider-start', + model: 'claude-opus-4-8', + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 1, + }, + }, + diagnostics: { cache_miss_reason: null }, + }, + })}\n\nevent: message_stop\ndata: {"type":"message_stop"}\n\n`, + { status: 200 }, + ), + ) + }) as unknown as typeof fetch + __setLogTestSink((record) => records.push(record)) + + const client = createMockClient() + const plugin = await getPlugin(client) + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-start' }, + { + message: { id: 'msg-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-start', message: { id: 'msg-start' } }, + { headers }, + ) + expect(headers).toEqual({ [LANE_START_REQUEST_HEADER]: '1' }) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await ( + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-start', ...headers }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: 99, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }), + }) + ).text() + + expect(sent).toHaveLength(1) + expect(sent[0]?.body).toMatchObject({ max_tokens: 1, stream: true }) + expect(sent[0]?.body.thinking).toBeUndefined() + expect(sent[0]?.headers.has(LANE_START_REQUEST_HEADER)).toBe(false) + const record = records.find( + (entry) => + entry.channel === 'cache-diagnostics' && + entry.message.includes('provider-start'), + ) + expect(record).toBeDefined() + expect( + JSON.parse(record!.message.replace('MC-CACHE-DIAG ', '')), + ).toMatchObject({ + v: 2, + source: 'start', + synthetic: true, + account_id: 'main', + session_id: 'ses-start', + }) + + await expectHandledCommandResponse( + plugin['command.execute.before']({ + command: 'claude-cachekeep', + arguments: '', + sessionID: 'ses-start', + }), + ) + const latest = ( + client.session.promptAsync as unknown as { + mock: { calls: Array<[{ body: { parts: Array<{ text: string }> } }]> } + } + ).mock.calls.at(-1)?.[0] + expect(latest?.body.parts[0]?.text).toContain('ses-start') + }) + + test('claude-start concurrency does not shape an interleaved real turn', async () => { + const sent: Array<{ body: Record; headers: Headers }> = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + sent.push({ + body: JSON.parse(String(init?.body)), + headers: new Headers(init?.headers), + }) + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const startHeaders: Record = {} + const realHeaders: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-race' }, + { + message: { id: 'msg-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-race', message: { id: 'msg-start' } }, + { headers: startHeaders }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-race', message: { id: 'msg-real' } }, + { headers: realHeaders }, + ) + expect(realHeaders).toEqual({}) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const request = (headers: Record, maxTokens: number) => + result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-race', ...headers }, + body: JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: maxTokens, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'hello' }], + }), + }) + await Promise.all([request(startHeaders, 99), request(realHeaders, 77)]) + + expect(sent.map((entry) => entry.body.max_tokens).sort()).toEqual([1, 77]) + expect( + sent.find((entry) => entry.body.max_tokens === 77)?.body.thinking, + ).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect( + sent.every((entry) => !entry.headers.has(LANE_START_REQUEST_HEADER)), + ).toBe(true) + }) + + test('claude-start tags direct dumps', async () => { + const previousDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp(join(tmpdir(), 'anthropic-start-dump-test-')) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { enabled: false }, + dump: { enabled: true }, + }), + ) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('event: message_stop\ndata: {}\n\n', { status: 200 }), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin() + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-start-dump' }, + { + message: { id: 'msg-start-dump' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-start-dump', message: { id: 'msg-start-dump' } }, + { headers }, + ) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-start-dump', ...headers }, + body: JSON.stringify({ + messages: [{ role: 'user', content: 'start' }], + }), + }) + expect( + (await readdir(dumpDir)).some((file) => file.includes('-start-')), + ).toBe(true) + } finally { + if (previousDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = previousDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('claude-start keeps a fallback-first API-key send ordinary', async () => { + const previousDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + const dumpDir = await mkdtemp(join(tmpdir(), 'anthropic-api-start-dump-')) + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = dumpDir + try { + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'api-start', + type: 'api', + apiKey: 'api-start-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', + }, + ], + quota: { enabled: false }, + dump: { enabled: true }, + }), + ) + const sent: Array<{ body: Record; headers: Headers }> = + [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + sent.push({ body: JSON.parse(String(init?.body)), headers }) + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === 'Bearer main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const body = (maxTokens: number) => + JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: maxTokens, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }) + await result.fetch(MESSAGES_URL, { method: 'POST', body: body(50) }) + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-api-start' }, + { + message: { id: 'msg-api-start' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { sessionID: 'ses-api-start', message: { id: 'msg-api-start' } }, + { headers }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api-start', ...headers }, + body: body(99), + }) + + const apiSend = sent.find( + (entry) => entry.headers.get('x-api-key') === 'api-start-key', + ) + expect(apiSend?.body).toMatchObject({ max_tokens: 99 }) + expect(apiSend?.body.thinking).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect(apiSend?.headers.has(LANE_START_REQUEST_HEADER)).toBe(false) + const metadata = await Promise.all( + (await readdir(dumpDir)) + .filter((file) => file.endsWith('.meta.json')) + .map( + async (file) => + JSON.parse(await readFile(join(dumpDir, file), 'utf8')) as { + tag?: string + }, + ), + ) + expect(metadata.some((entry) => entry.tag === 'start')).toBe(false) + } finally { + if (previousDumpDir === undefined) { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR + } else { + process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR = previousDumpDir + } + await rm(dumpDir, { recursive: true, force: true }) + } + }) + + test('claude-start shapes the OAuth fallback after an API-key failure', async () => { + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'api-fails', + type: 'api', + apiKey: 'api-fails-key', + baseURL: 'https://api.example.test', + authHeader: 'x-api-key', + }, + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: Date.now(), + }, + }, + }, + ], + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: tokenFingerprint('main-access'), + }, + }), + ) + const sent: Array<{ body: Record; headers: Headers }> = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers) + const body = JSON.parse(String(init?.body)) as Record + sent.push({ body, headers }) + if (headers.get('x-api-key') === 'api-fails-key') { + return Promise.resolve(new Response('{}', { status: 429 })) + } + return Promise.resolve( + new Response('{}', { + status: 200, + headers: + headers.get('authorization') === 'Bearer main-access' + ? { + 'anthropic-ratelimit-unified-representative-claim': + 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '1', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + : undefined, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + const body = JSON.stringify({ + model: 'claude-opus-4-8', + stream: true, + max_tokens: 99, + thinking: { type: 'enabled', budget_tokens: 10 }, + messages: [{ role: 'user', content: 'start' }], + }) + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-api-then-oauth' }, + { + message: { id: 'msg-api-then-oauth' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin['chat.headers']( + { + sessionID: 'ses-api-then-oauth', + message: { id: 'msg-api-then-oauth' }, + }, + { headers }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { 'x-session-affinity': 'ses-api-then-oauth', ...headers }, + body, + }) + + const apiSend = sent.find( + (entry) => entry.headers.get('x-api-key') === 'api-fails-key', + ) + const oauthSend = sent.find( + (entry) => + entry.headers.get('authorization') === 'Bearer fallback-access', + ) + expect(apiSend?.body).toMatchObject({ max_tokens: 99 }) + expect(apiSend?.body.thinking).toEqual({ + type: 'enabled', + budget_tokens: 10, + }) + expect(oauthSend?.body).toMatchObject({ max_tokens: 1, stream: true }) + expect(oauthSend?.body.thinking).toBeUndefined() + }) + + test('claude-start clears the one-shot header before non-OAuth passthrough and session reuse', async () => { + const seenHeaders: Headers[] = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + seenHeaders.push(new Headers(init?.headers)) + return Promise.resolve(new Response('{}', { status: 200 })) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const headers: Record = {} + await plugin['chat.message']( + { sessionID: 'ses-deleted' }, + { + message: { id: 'reused-message' }, + parts: [{ type: 'text', text: LANE_START_TEXT, synthetic: true }], + }, + ) + await plugin.event({ + event: { + type: 'session.deleted', + properties: { sessionID: 'ses-deleted' }, + }, + }) + await plugin['chat.headers']( + { sessionID: 'ses-deleted', message: { id: 'reused-message' } }, + { headers }, + ) + expect(headers).toEqual({}) + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await result.fetch(MESSAGES_URL, { + method: 'POST', + headers: { [LANE_START_REQUEST_HEADER]: '1' }, + body: JSON.stringify({ max_tokens: 99, thinking: { type: 'enabled' } }), + }) + expect(seenHeaders[0]?.has(LANE_START_REQUEST_HEADER)).toBe(false) + }) +}) + describe('cache diagnostics', () => { const originalFetch = globalThis.fetch const originalDateNow = Date.now diff --git a/packages/opencode/src/tests/info-logs.test.ts b/packages/opencode/src/tests/info-logs.test.ts index 2962db98..7944b1f8 100644 --- a/packages/opencode/src/tests/info-logs.test.ts +++ b/packages/opencode/src/tests/info-logs.test.ts @@ -172,6 +172,19 @@ describe('setting-change INFO logs', () => { expect(rec!.payload).toEqual({ enabled: false }) }) + // -- start --------------------------------------------------------------- + + test('start off emits info log and persists', async () => { + await useTempAccountFile(createFallbackStorage()) + const plugin = await getPlugin() + await executeCommand(plugin, 'claude-start', 'off') + const rec = findCommandsLog('start automatic changed') + expect(rec).toBeDefined() + expect(rec!.payload).toEqual({ enabled: false }) + const raw = await readConfigFile() + expect(raw.claudeStart?.enabled).toBe(false) + }) + // -- routing ------------------------------------------------------------- test('routing mode change emits info log and persists', async () => { diff --git a/packages/opencode/src/tests/lane-start.test.ts b/packages/opencode/src/tests/lane-start.test.ts new file mode 100644 index 00000000..755c6bd1 --- /dev/null +++ b/packages/opencode/src/tests/lane-start.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'bun:test' +import { + fireLaneStart, + LANE_START_REQUEST_HEADER, + LANE_START_TEXT, + LaneStartTracker, +} from '../lane-start' + +function startParts() { + return [{ type: 'text', text: LANE_START_TEXT, synthetic: true }] +} + +describe('fireLaneStart', () => { + test('sends the exact visible synthetic prompt with resolved context', async () => { + const calls: unknown[] = [] + await fireLaneStart( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + agent: 'build', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + variant: 'high', + }, + }, + }), + promptAsync: async (request: unknown) => calls.push(request), + }, + }, + 'session-a', + ) + + expect(calls).toEqual([ + { + path: { id: 'session-a' }, + body: { + noReply: false, + parts: startParts(), + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + }, + }, + ]) + }) + + test('rejects when the plugin client cannot create the prompt', async () => { + await expect(fireLaneStart({ session: {} }, 'session-a')).rejects.toThrow( + 'OpenCode plugin client does not support session.promptAsync', + ) + }) +}) + +describe('LaneStartTracker', () => { + test('binds only the exact synthetic marker and consumes the matching header once', () => { + const tracker = new LaneStartTracker() + expect( + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'start-a', + parts: startParts(), + }), + ).toBe(true) + expect( + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'real-a', + parts: [{ type: 'text', text: LANE_START_TEXT }], + }), + ).toBe(false) + + const headers: Record = {} + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'real-a', + headers, + }), + ).toBe(false) + expect(headers).toEqual({}) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers, + }), + ).toBe(true) + expect(headers).toEqual({ [LANE_START_REQUEST_HEADER]: '1' }) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers: {}, + }), + ).toBe(false) + }) + + test('does not cross-mark concurrent sessions or an interleaved real turn', () => { + const tracker = new LaneStartTracker() + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'start-a', + parts: startParts(), + }) + tracker.observeSyntheticMessage({ + sessionId: 'session-b', + messageId: 'start-b', + parts: startParts(), + }) + + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'real-a', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'start-a', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'start-a', + headers: {}, + }), + ).toBe(true) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'start-b', + headers: {}, + }), + ).toBe(true) + }) + + test('tracks multiple starts, evicts the oldest pending ID, and clears one session', () => { + const tracker = new LaneStartTracker() + for (let index = 0; index <= 1000; index++) { + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: + index === 0 ? 'first' : index === 1 ? 'second' : `start-${index}`, + parts: startParts(), + }) + } + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'first', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'second', + headers: {}, + }), + ).toBe(true) + tracker.observeSyntheticMessage({ + sessionId: 'session-a', + messageId: 'second', + parts: startParts(), + }) + tracker.observeSyntheticMessage({ + sessionId: 'session-b', + messageId: 'other', + parts: startParts(), + }) + tracker.clearSession('session-a') + expect( + tracker.markHeaders({ + sessionId: 'session-a', + messageId: 'second', + headers: {}, + }), + ).toBe(false) + expect( + tracker.markHeaders({ + sessionId: 'session-b', + messageId: 'other', + headers: {}, + }), + ).toBe(true) + }) +}) diff --git a/packages/opencode/src/tests/prompt-context.test.ts b/packages/opencode/src/tests/prompt-context.test.ts new file mode 100644 index 00000000..62ebdfd1 --- /dev/null +++ b/packages/opencode/src/tests/prompt-context.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test' +import { resolvePromptContext } from '../prompt-context' + +describe('resolvePromptContext', () => { + test('falls back to empty-session metadata and normalizes modelID', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + agent: 'build', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + variant: 'high', + }, + }, + }), + }, + }, + 'session-empty', + ) + + expect(context).toEqual({ + agent: 'build', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + }) + }) + + test('accepts the session metadata id model shape', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ data: [] }), + get: async () => ({ + data: { + model: { + providerID: 'anthropic', + id: 'claude-opus-4-1', + variant: 'max', + }, + }, + }), + }, + }, + 'session-empty', + ) + + expect(context).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-opus-4-1' }, + variant: 'max', + }) + }) + + test('prefers message history while retaining metadata fields it does not supply', async () => { + const context = await resolvePromptContext( + { + session: { + messages: async () => ({ + data: [ + { + info: { + id: 'msg_2', + role: 'assistant', + agent: 'history-agent', + model: { + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + }, + }, + }, + ], + }), + get: async () => ({ + data: { + agent: 'metadata-agent', + model: { + providerID: 'anthropic', + modelID: 'claude-opus-4-1', + variant: 'high', + }, + }, + }), + }, + }, + 'session-history', + ) + + expect(context).toEqual({ + agent: 'history-agent', + model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }, + variant: 'high', + latestAssistantMessageId: 'msg_2', + latestUserMessageId: undefined, + }) + }) + + test('uses metadata when message history fails and returns null when both fail', async () => { + const fallback = await resolvePromptContext( + { + session: { + messages: async () => { + throw new Error('unavailable') + }, + get: async () => ({ data: { agent: 'metadata-agent' } }), + }, + }, + 'session-fallback', + ) + expect(fallback).toEqual({ + agent: 'metadata-agent', + latestAssistantMessageId: undefined, + latestUserMessageId: undefined, + }) + + const missing = await resolvePromptContext( + { + session: { + messages: async () => { + throw new Error('unavailable') + }, + get: async () => { + throw new Error('unavailable') + }, + }, + }, + 'session-missing', + ) + expect(missing).toBeNull() + }) +}) diff --git a/packages/opencode/src/tests/relay.test.ts b/packages/opencode/src/tests/relay.test.ts index 0ed21908..05dbb09b 100644 --- a/packages/opencode/src/tests/relay.test.ts +++ b/packages/opencode/src/tests/relay.test.ts @@ -223,6 +223,7 @@ describe('relay client', () => { input: 'https://api.anthropic.com/v1/messages?beta=true', init: { method: 'POST' }, headers: headers('session relay/dump:alpha'), + dumpTag: 'start', body: JSON.stringify({ model: 'claude-sonnet-4-6', stream: true, @@ -249,9 +250,9 @@ describe('relay client', () => { expect(metaPath).toBeString() expect(bodyPath).toBeString() expect(relayPath).toBeString() - expect(metaPath).toInclude('session-relay-dump-alpha') - expect(bodyPath).toInclude('session-relay-dump-alpha') - expect(relayPath).toInclude('session-relay-dump-alpha') + expect(metaPath).toInclude('session-relay-dump-alpha-start-http-p1-') + expect(bodyPath).toInclude('session-relay-dump-alpha-start-http-p1-') + expect(relayPath).toInclude('session-relay-dump-alpha-start-http-p1-') const meta = JSON.parse( await readFile(`${getDumpDirectory()}/${metaPath}`, 'utf8'), @@ -262,6 +263,7 @@ describe('relay client', () => { systemCount: 2, cch: 'abcde', }) + expect(meta.tag).toBe('start') expect( await readFile(`${getDumpDirectory()}/${bodyPath}`, 'utf8'), ).toContain('first') diff --git a/packages/opencode/src/tests/start.test.ts b/packages/opencode/src/tests/start.test.ts new file mode 100644 index 00000000..4ea6955f --- /dev/null +++ b/packages/opencode/src/tests/start.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { + buildLaneStartStatusSummary, + executeLaneStartCommand, + parseLaneStartCommandAction, +} from '@cortexkit/anthropic-auth-core' + +describe('claude-start command contract', () => { + test('bare and whitespace-only input queues a start turn', () => { + expect(parseLaneStartCommandAction('')).toEqual({ type: 'fire' }) + expect(parseLaneStartCommandAction(' \t')).toEqual({ type: 'fire' }) + expect( + executeLaneStartCommand({ argumentsText: ' ', automaticEnabled: false }), + ).toEqual({ + action: { type: 'fire' }, + text: expect.stringContaining('Queued'), + }) + }) + + test('automatic reports unavailable without claiming persistence', () => { + expect(parseLaneStartCommandAction('automatic')).toEqual({ + type: 'automatic', + }) + const result = executeLaneStartCommand({ + argumentsText: 'automatic', + automaticEnabled: false, + }) + expect(result.action).toEqual({ type: 'automatic' }) + expect(result.text).toBe( + 'Automatic lane start is not yet wired in this build; no setting was changed.', + ) + expect(result.text).not.toContain('Persisted:') + }) + + test('off reports disabled and preserves explicit persistence wording', () => { + expect(parseLaneStartCommandAction('off')).toEqual({ type: 'off' }) + const result = executeLaneStartCommand({ + argumentsText: 'off', + automaticEnabled: true, + }) + expect(result.action).toEqual({ type: 'off' }) + expect(result.text).toContain('Disabled') + expect(result.text).toContain( + 'Persisted: ~/.config/opencode/anthropic-auth.json', + ) + }) + + test('multiple or invalid arguments return usage', () => { + expect(parseLaneStartCommandAction('automatic now')).toEqual({ + type: 'usage', + }) + expect(parseLaneStartCommandAction('unknown')).toEqual({ type: 'usage' }) + const result = executeLaneStartCommand({ + argumentsText: 'unknown', + automaticEnabled: false, + }) + expect(result.action).toEqual({ type: 'usage' }) + expect(result.text).toContain( + 'Usage: `/claude-start`, `/claude-start automatic`, or `/claude-start off`.', + ) + }) + + test('status summary reports automatic state and side-effect scope', () => { + const enabled = buildLaneStartStatusSummary({ automaticEnabled: true }) + const disabled = buildLaneStartStatusSummary({ automaticEnabled: false }) + expect(enabled).toContain('Enabled: enabled') + expect(disabled).toContain('Enabled: disabled') + expect(enabled).toContain( + 'Persisted: ~/.config/opencode/anthropic-auth.json', + ) + expect(enabled).toContain('automatic lane starts are not yet wired') + }) +}) diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index 471d2dd7..41ed6f55 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -87,6 +87,75 @@ describe('mergeHeaders', () => { }) }) +describe('lane start request shaping', () => { + const laneStartBody = ( + model: string, + thinking: unknown = { type: 'enabled' }, + ) => + JSON.stringify({ + model, + max_tokens: 512, + stream: true, + thinking, + output_config: { effort: 'high' }, + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], + cache_control: { type: 'ephemeral' }, + speed: 'fast', + }) + + test('sets one streaming token and strips thinking after model normalization', async () => { + const thinkingShapes = [ + { type: 'enabled' }, + { type: 'adaptive' }, + { type: 'summarized' }, + { type: 'disabled' }, + ] + for (const model of [ + 'claude-fable-5', + 'claude-sonnet-5', + 'claude-opus-5', + ]) { + for (const thinking of thinkingShapes) { + const result = JSON.parse( + await rewriteRequestBody(laneStartBody(model, thinking), { + laneStart: true, + }), + ) + expect(result.max_tokens).toBe(1) + expect(result.stream).toBe(true) + expect(result.model).toBe(model) + expect(result.thinking).toBeUndefined() + } + } + }) + + test('preserves prompt and request features governed by existing paths', async () => { + const result = JSON.parse( + await rewriteRequestBody(laneStartBody('claude-opus-4-8'), { + laneStart: true, + fastModeEnabled: true, + }), + ) + expect(result.output_config).toEqual({ effort: 'high' }) + expect(result.tools).toHaveLength(1) + expect(result.messages).toHaveLength(1) + expect(result.cache_control).toBeUndefined() + expect(result.speed).toBe('fast') + }) + + test('leaves ordinary requests unchanged and fails closed on invalid JSON', async () => { + const body = laneStartBody('claude-opus-4-8') + expect(await rewriteRequestBody(body)).not.toContain('"max_tokens":1') + expect(await rewriteRequestBody(body, { laneStart: false })).not.toContain( + '"max_tokens":1', + ) + expect(await rewriteRequestBody('{not-json', { laneStart: true })).toBe( + '{not-json', + ) + }) +}) + describe('mergeBetaHeaders', () => { test('includes required betas when no incoming betas', () => { const headers = new Headers() @@ -485,6 +554,87 @@ describe('isInsecure', () => { }) describe('createStrippedStream', () => { + test('rewrites the lane-start max_tokens finish as end_turn before completion', async () => { + const finishReasons: string[] = [] + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const splitAt = body.indexOf('max_tokens') + 4 + const text = await createStrippedStream( + new Response( + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode(body.slice(0, splitAt))) + controller.enqueue(encoder.encode(body.slice(splitAt))) + controller.close() + }, + }), + ), + { + laneStart: true, + onComplete: (finishReason) => finishReasons.push(finishReason), + }, + ).text() + + expect(text).toContain('"stop_reason":"end_turn"') + expect(text).toContain('"output_tokens":1') + expect(finishReasons).toEqual(['end_turn']) + }) + + test('leaves API-key-served lane-start max_tokens finish unchanged', async () => { + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const text = await createStrippedStream(new Response(body), { + laneStart: true, + laneStartOAuthServed: false, + }).text() + + expect(text).toBe(body) + }) + + test('leaves max_tokens bytes unchanged without the lane-start marker', async () => { + const finishReasons: string[] = [] + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'max_tokens' }, + usage: { output_tokens: 1 }, + }) + + const text = await createStrippedStream(new Response(body), { + onComplete: (finishReason) => finishReasons.push(finishReason), + }).text() + + expect(text).toBe(body) + expect(finishReasons).toEqual(['max_tokens']) + }) + + test('preserves lane-start refusal handling', async () => { + let refusals = 0 + const body = sse('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'refusal' }, + }) + + const text = await createStrippedStream(new Response(body), { + laneStart: true, + onContentFilter: () => { + refusals++ + return false + }, + }).text() + + expect(text).toBe(body) + expect(refusals).toBe(1) + }) + test('observes a split message_start envelope exactly once', async () => { const message = { id: 'msg_provider_1', diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 6a865912..48e00806 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -1184,6 +1184,7 @@ export async function rewriteRequestBody( perf?: RewritePerfCallback hybridStandbyAnchor?: HybridMessageCacheAnchor serverSideFallbackEnabled?: boolean + laneStart?: boolean cacheDiagnosticsPreviousMessageId?: string | null } = {}, ): Promise { @@ -1244,6 +1245,11 @@ export async function rewriteRequestBody( hasOutputConfig: Object.hasOwn(parsed, 'output_config'), }) + if (options.laneStart === true) { + parsed.max_tokens = 1 + delete parsed.thinking + } + const billingStart = rewriteNowMs() const billingHeader = Array.isArray(parsed.messages) && @@ -1613,6 +1619,63 @@ function updateSseFinishState( return update } +type SseLaneStartFinishRewriteState = { + pending: string + disabled: boolean +} + +function createSseLaneStartFinishRewriteState(): SseLaneStartFinishRewriteState { + return { pending: '', disabled: false } +} + +function rewriteLaneStartFinishEvent(rawEvent: string) { + const summary = summarizeSseEvent(rawEvent) + if ( + summary?.type !== 'message_delta' || + summary.stopReason !== 'max_tokens' + ) { + return rawEvent + } + return rawEvent.replace(/("stop_reason"\s*:\s*)"max_tokens"/, '$1"end_turn"') +} + +function updateSseLaneStartFinishRewriteState( + state: SseLaneStartFinishRewriteState, + text: string, + maxPendingBytes: number, + flush = false, +) { + if (!text && !flush) return '' + if (state.disabled) return text + if ( + new TextEncoder().encode(state.pending + text).byteLength > maxPendingBytes + ) { + const passthrough = state.pending + text + state.pending = '' + state.disabled = true + return passthrough + } + state.pending += text + let rewritten = '' + while (true) { + const boundary = findSseBoundary(state.pending) + if (!boundary) break + const rawEvent = state.pending.slice(0, boundary.index) + const separator = state.pending.slice( + boundary.index, + boundary.index + boundary.length, + ) + state.pending = state.pending.slice(boundary.index + boundary.length) + rewritten += rewriteLaneStartFinishEvent(rawEvent) + rewritten += separator + } + if (flush && state.pending) { + rewritten += rewriteLaneStartFinishEvent(state.pending) + state.pending = '' + } + return rewritten +} + type RetryableAnthropicStreamError = Error & { code: 'ECONNRESET' syscall: 'anthropic-sse' @@ -1787,6 +1850,8 @@ export function createStrippedStream( onMessageStart?: (message: Record) => void onMessageResponse?: (message: Record) => void responseMode?: 'json' + laneStart?: boolean + laneStartOAuthServed?: boolean } = {}, ): Response { if (!response.body) return response @@ -1823,6 +1888,10 @@ export function createStrippedStream( options.onContentFilter || options.onComplete ? createSseFinishState() : undefined + const laneStartFinish = + options.laneStart && options.laneStartOAuthServed !== false + ? createSseLaneStartFinishRewriteState() + : undefined let contentFilterInvoked = false let contentFilterHandled = false const invokeContentFilter = (completedToolUse = false) => { @@ -1860,6 +1929,15 @@ export function createStrippedStream( if (update?.type === 'complete') options.onComplete?.(update.finishReason) return null } + const rewriteLaneStartFinish = (text: string, flush = false) => + laneStartFinish + ? updateSseLaneStartFinishRewriteState( + laneStartFinish, + text, + NON_STREAMING_DIAGNOSTICS_MAX_BYTES, + flush, + ) + : text const releaseReader = () => { if (readerReleased) return @@ -1953,13 +2031,17 @@ export function createStrippedStream( ? serverSideFallback.push(finalDecoded) + serverSideFallback.flush() : finalDecoded + const laneStartRewritten = rewriteLaneStartFinish( + serverRewritten, + true, + ) const retryableStreamError = jsonMode - ? updateFinish(serverRewritten) + ? updateFinish(laneStartRewritten) : (updateSseErrorState( sseErrors, finalDecoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, - ) ?? updateFinish(serverRewritten)) + ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, @@ -1969,7 +2051,7 @@ export function createStrippedStream( throw retryableStreamError } const flushed = splitToolPrefixRewriteBuffer( - `${pending}${serverRewritten}`, + `${pending}${laneStartRewritten}`, true, ) rewriteMs += rewriteNowMs() - rewriteStart @@ -2014,13 +2096,14 @@ export function createStrippedStream( const serverRewritten = serverSideFallback ? serverSideFallback.push(decoded) : decoded + const laneStartRewritten = rewriteLaneStartFinish(serverRewritten) const retryableStreamError = jsonMode - ? updateFinish(serverRewritten) + ? updateFinish(laneStartRewritten) : (updateSseErrorState( sseErrors, decoded, NON_STREAMING_DIAGNOSTICS_MAX_BYTES, - ) ?? updateFinish(serverRewritten)) + ) ?? updateFinish(laneStartRewritten)) if (retryableStreamError) { logProgress('stream_tool_prefix_retryable_error', { error: retryableStreamError.message, @@ -2031,7 +2114,7 @@ export function createStrippedStream( releaseReader() throw retryableStreamError } - const text = pending + serverRewritten + const text = pending + laneStartRewritten const rewritten = splitToolPrefixRewriteBuffer(text) rewriteMs += rewriteNowMs() - rewriteStart pending = rewritten.pending