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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ 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

### Patch Changes
Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -201,6 +202,9 @@ Example:
"claudeFast": {
"enabled": false
},
"claudeStart": {
"enabled": false
},
"costZeroing": {
"enabled": true
},
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1056,6 +1060,7 @@ function configFromStorage(storage: AccountStorage): Record<string, unknown> {
dump: storage.dump,
logging: storage.logging,
claudeFast: storage.claudeFast,
claudeStart: storage.claudeStart,
costZeroing: storage.costZeroing,
cacheKeep: storage.cacheKeep,
relay: storage.relay,
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
84 changes: 73 additions & 11 deletions packages/core/src/cachekeep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -328,6 +329,7 @@ export type CacheKeepTarget = {
cacheExpiresAt: number
dayKey: string
oauthAccountId?: string
isSubagent: boolean
}

export type CacheKeepPrewarmResult =
Expand Down Expand Up @@ -360,6 +362,17 @@ export class CacheKeepManager {
onTrackedSessionsChanged?: (
sessions: readonly CacheKeepTrackedSession[],
) => Promise<void> | void
prepareBody?: (
bodyText: string,
target: CacheKeepTarget,
) => string | Promise<string>
onResponse?: (input: {
target: CacheKeepTarget
bodyText: string
status: number
data: unknown
receivedAt: number
}) => void | Promise<void>
},
) {}

Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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()
Expand All @@ -530,6 +545,7 @@ export class CacheKeepManager {
headers: Headers
bodyText: string
oauthAccountId?: string
isSubagent?: boolean
}): Promise<CacheKeepPrewarmResult> {
const headers: Record<string, string> = {}
input.headers.forEach((value, key) => {
Expand All @@ -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)
}
Expand Down Expand Up @@ -584,11 +601,23 @@ export class CacheKeepManager {
private async sendPrewarm(
target: CacheKeepTarget,
): Promise<CacheKeepPrewarmResult> {
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),
Expand All @@ -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<string, unknown>)
: null
const usage = objectData?.usage as
| {
input_tokens?: number
cache_creation_input_tokens?: number
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/cch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export async function computeCCH(bodyBytes: Uint8Array): Promise<string> {
return (hash & 0xfffffn).toString(16).padStart(5, '0')
}

export async function computeXxhash64Hex(value: string): Promise<string> {
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}`)
}
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ export type ClaudeCodeIdentity = {
const IDENTITY_CACHE_LIMIT = 1_000
const identityCache = new Map<string, ClaudeCodeIdentity>()

function setBounded<K, V>(map: Map<K, V>, key: K, value: V) {
if (!map.has(key) && map.size >= IDENTITY_CACHE_LIMIT) {
export function setBounded<K, V>(
map: Map<K, V>,
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)
}
Expand Down
Loading