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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ In OpenCode, this includes the main Anthropic account and sidecar fallback accou

Reset times are rendered as relative durations, such as `resets in 10m` or `resets in 1h 15m`.

### Quota header feed

The optional quota header feed writes one lease file per process under `${TMPDIR:-/tmp}/opencode-anthropic-auth/quota-header-feed/`. Set `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR` to override the directory. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes.

For each account, the newest entry wins for quota values. Resolve `anthropic_account_uuid` from any entry in that account's group that carries it. During a rollout, an older pre-restart publisher can write the newest entry without that field beside newer code that has it.

Each entry always includes `anthropic_account_uuid`; an absent key identifies an older producer. Its value is provider-derived or `null`, never a local substitute. A consumer that sees `null` must count a gap, not fall back to `account_ref`; that field is store-local and never a join key.

## Safety fallback (OpenCode)

Eligible Fable 5/5.1 and Opus 5 OAuth requests try Anthropic's server-side safety fallback first. The plugin sends `fallbacks: "default"` with Anthropic's server-side fallback beta, preserves fallback conversation boundaries in OpenCode history, and reports model handoffs and restoration in the TUI sidebar or OpenCode Desktop. Follow-up requests may remain on Anthropic's selected fallback model for approximately one hour.
Expand Down
77 changes: 74 additions & 3 deletions packages/core/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type AccountBase = {
export type OAuthAccount = AccountBase & {
type: 'oauth'
authLineageId?: string
anthropicAccountUuid?: string
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
claustrumHandle?: string
access?: string
refresh: string
Expand Down Expand Up @@ -556,6 +557,11 @@ function normalizeAccount(value: unknown): FallbackAccount | null {
typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
? value.claustrumHandle.trim()
: undefined,
anthropicAccountUuid:
typeof value.anthropicAccountUuid === 'string' &&
value.anthropicAccountUuid.trim()
? value.anthropicAccountUuid.trim()
: undefined,
access: typeof value.access === 'string' ? value.access : undefined,
refresh: value.refresh,
expires: typeof value.expires === 'number' ? value.expires : undefined,
Expand Down Expand Up @@ -1197,6 +1203,7 @@ function accountRuntimeState(account: FallbackAccount) {
}
return objectWithDefinedEntries({
authLineageId: account.authLineageId,
anthropicAccountUuid: account.anthropicAccountUuid,
claustrumHandle: account.claustrumHandle,
access: account.access,
refresh: account.refresh,
Expand Down Expand Up @@ -3714,10 +3721,16 @@ export function upsertAccount(
(account.label && candidate.label === account.label),
)
if (index >= 0) {
storage.accounts[index] = {
...storage.accounts[index],
const existing = storage.accounts[index]
if (!existing) return
const lineageChanged =
existing.type === 'oauth' &&
account.type === 'oauth' &&
existing.authLineageId !== account.authLineageId
const updated: FallbackAccount = {
...existing,
...account,
addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
addedAt: existing.addedAt ?? account.addedAt,
...(account.type === 'oauth' && {
quota: account.quota,
profile: account.profile,
Expand All @@ -3726,11 +3739,69 @@ export function upsertAccount(
lastQuotaRefreshError: account.lastQuotaRefreshError,
}),
}
if (lineageChanged && updated.type === 'oauth') {
delete updated.anthropicAccountUuid
}
Comment on lines +3743 to +3744

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an existing OAuth account has no authLineageId (legacy) but already persisted an anthropicAccountUuid, a same-id re-upsert that assigns a lineage for the first time (undefined -> lineage) is treated as lineageChanged and deletes the valid UUID even though no credential was replaced. Restrict the fence to genuine replacements by only deleting when the existing account already had a lineage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/accounts.ts, line 3743:

<comment>When an existing OAuth account has no `authLineageId` (legacy) but already persisted an `anthropicAccountUuid`, a same-id re-upsert that assigns a lineage for the first time (undefined -> lineage) is treated as `lineageChanged` and deletes the valid UUID even though no credential was replaced. Restrict the fence to genuine replacements by only deleting when the existing account already had a lineage.</comment>

<file context>
@@ -3733,11 +3739,69 @@ export function upsertAccount(
       }),
     }
+    if (lineageChanged && updated.type === 'oauth') {
+      delete updated.anthropicAccountUuid
+    }
+    storage.accounts[index] = updated
</file context>
Suggested change
delete updated.anthropicAccountUuid
}
if (lineageChanged && updated.type === 'oauth' && existing.authLineageId !== undefined) {
delete updated.anthropicAccountUuid
}

storage.accounts[index] = updated
return
}
storage.accounts.push(account)
}

export function persistFallbackQuotaHeaderPersistent(
input: {
accountId: string
authLineageId?: string
quota: OAuthQuotaSnapshot
anthropicAccountUuid?: string
},
path = getAccountStoragePath(),
): Promise<boolean> {
return enqueueSave(async () => {
const configLock = await acquireAccountConfigWriteLock(path)
try {
const stateLock = await acquireAccountStateWriteLock(path)
try {
const storage = await loadAccounts(path)
const account = storage?.accounts.find(
(candidate): candidate is OAuthAccount =>
candidate.id === input.accountId && isOAuthAccount(candidate),
)
if (
!storage ||
!account ||
account.authLineageId !== input.authLineageId
) {
return false
}
account.quota = {
...input.quota,
accountIdentity: account.id,
}
if (input.anthropicAccountUuid !== undefined) {
account.anthropicAccountUuid = input.anthropicAccountUuid
}
await saveAccountStateUnlocked(storage, path, {
accounts: [input.accountId],
})
return true
} finally {
await stateLock.release()
}
} finally {
await configLock.release()
}
})
}

export function fallbackAccountUuidForLineage(
account: OAuthAccount | undefined,
authLineageId?: string,
): string | null {
if (!account || account.authLineageId !== authLineageId) return null
return account.anthropicAccountUuid ?? null
}

export function removeAccount(storage: AccountStorage, id: string): boolean {
const index = storage.accounts.findIndex((c) => c.id === id)
if (index < 0) return false
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ export async function resolveClaudeCodeIdentity(
setBounded(identityCache, accessToken, identity)
}

if (!accessToken.startsWith('sk-ant-oat')) return identity
if (!accessToken.startsWith('sk-ant-oat')) {
return clearCachedAccountUuid(cacheKey, identity)
}

const now = Date.now()
// A slot-stable identity survives account replacement; bootstrap is the
Expand Down
83 changes: 76 additions & 7 deletions packages/core/src/quota-header-feed.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { randomUUID } from 'node:crypto'
import {
chmod,
lstat,
mkdir,
readdir,
readFile,
rename,
rm,
stat,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand All @@ -28,6 +30,13 @@ import {
export const QUOTA_HEADER_FEED_SCHEMA_VERSION = 3
export const QUOTA_HEADER_FEED_LEASE_MS = 180_000

/**
* Lease files are per process; each carries only the accounts whose response
* headers THAT process harvested. Consumers MUST union entries across all files
* inside `lease_horizon_ms`, deduplicating by account; "newest file wins" is wrong.
* `anthropic_account_uuid` is always present: null is unresolvable, while absence
* identifies an old producer. A fallback `account_ref` is store-local.
*/
export type QuotaHeaderFeedIdentity =
| { identity_source: 'credential_id'; credential_id: string }
| { identity_source: 'account_ref'; account_ref: string }
Expand Down Expand Up @@ -59,11 +68,14 @@ type QuotaHeaderFeedMetadata = {

export type QuotaHeaderFeedEntry = QuotaHeaderFeedIdentity &
QuotaHeaderFeedMetadata & {
/** Always present: null means UUID resolution failed; absence denotes an old producer. */
anthropic_account_uuid: string | null
quota: QuotaHeaderFeedQuota
}

export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity &
QuotaHeaderFeedMetadata & {
anthropic_account_uuid: string | null
quota: Omit<QuotaHeaderFeedQuota, 'provenance'> & {
fieldSources?: QuotaFieldSources
}
Expand All @@ -72,6 +84,7 @@ export type QuotaHeaderFeedPublishEntry = QuotaHeaderFeedIdentity &

type FeedRecord = {
version: typeof QUOTA_HEADER_FEED_SCHEMA_VERSION
lease_horizon_ms: number
entries: Record<string, QuotaHeaderFeedEntry>
}

Expand Down Expand Up @@ -105,6 +118,12 @@ function validIdentity(
)
return false
if (!entry.quota || typeof entry.quota !== 'object') return false
if (
!Object.hasOwn(entry, 'anthropic_account_uuid') ||
(entry.anthropic_account_uuid !== null &&
typeof entry.anthropic_account_uuid !== 'string')
)
return false
if (entry.identity_source === 'none') {
return !('credential_id' in entry) && !('account_ref' in entry)
}
Expand Down Expand Up @@ -292,6 +311,8 @@ export class QuotaHeaderFeedRegistry {
now?: () => number
leaseMs?: number
instanceId?: string
removeFile?: (path: string) => Promise<void>
beforeRemoveFile?: (path: string) => Promise<void>
} = {},
) {
const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}`
Expand All @@ -302,16 +323,33 @@ export class QuotaHeaderFeedRegistry {
}

publish(entry: QuotaHeaderFeedPublishEntry): Promise<void> {
const { accountKey, quota, ...entryWithoutQuota } = entry
const cleanEntry = {
...entryWithoutQuota,
quota: projectQuota(quota),
} as QuotaHeaderFeedEntry
const { accountKey, quota } = entry
try {
validatePublishEntry(cleanEntry)
validatePublishEntry(entry)
} catch (error) {
return Promise.reject(error)
}
const identity =
entry.identity_source === 'credential_id'
? {
identity_source: 'credential_id' as const,
credential_id: entry.credential_id,
}
: entry.identity_source === 'account_ref'
? {
identity_source: 'account_ref' as const,
account_ref: entry.account_ref,
}
: { identity_source: 'none' as const }
const cleanEntry: QuotaHeaderFeedEntry = {
...identity,
schema_version: entry.schema_version,
provider: entry.provider,
configured_account_count: entry.configured_account_count,
observed_at_ms: entry.observed_at_ms,
anthropic_account_uuid: entry.anthropic_account_uuid,
quota: projectQuota(quota),
}
if (!accountKey)
return Promise.reject(new Error('Invalid quota header feed account key'))
this.writeChain = this.writeChain
Expand All @@ -321,6 +359,7 @@ export class QuotaHeaderFeedRegistry {
this.options.directory ?? getDefaultQuotaHeaderFeedDirectory()
await mkdir(directory, { recursive: true, mode: 0o700 })
await chmod(directory, 0o700)
await this.reapStaleSiblingLeases(directory)
let entries: Record<string, QuotaHeaderFeedEntry> = {}
try {
const record = JSON.parse(
Expand All @@ -338,7 +377,7 @@ export class QuotaHeaderFeedRegistry {
try {
await writeFile(
tempPath,
`${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, entries })}\n`,
`${JSON.stringify({ version: QUOTA_HEADER_FEED_SCHEMA_VERSION, lease_horizon_ms: this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS, entries })}\n`,
{ mode: 0o600 },
)
await chmod(tempPath, 0o600)
Expand All @@ -350,6 +389,36 @@ export class QuotaHeaderFeedRegistry {
return this.writeChain
}

private async reapStaleSiblingLeases(directory: string): Promise<void> {
const now = this.options.now?.() ?? Date.now()
const leaseMs = this.options.leaseMs ?? QUOTA_HEADER_FEED_LEASE_MS
let names: string[]
try {
names = await readdir(directory)
} catch {
return
}
await Promise.all(
names
.filter((name) => /^\d+-[0-9a-f-]+\.json$/i.test(name))
.map(async (name) => {
const path = join(directory, name)
if (path === this.filePath) return
try {
const file = await stat(path)
if (file.mtimeMs > now || now - file.mtimeMs < leaseMs) return
await this.options.beforeRemoveFile?.(path)
const current = await lstat(path)
if (current.ino !== file.ino || current.mtimeMs !== file.mtimeMs)
return
await (this.options.removeFile ?? ((target) => rm(target)))(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When another process publishes concurrently, this stat/rm pair can delete its fresh lease. The reaper can stat an old inode, the publisher can rename a fresh file, and rm(path) then removes it. Coordinate reaping with publishing or use an atomic compare-and-delete protocol.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/quota-header-feed.ts, line 408:

<comment>When another process publishes concurrently, this `stat`/`rm` pair can delete its fresh lease. The reaper can stat an old inode, the publisher can rename a fresh file, and `rm(path)` then removes it. Coordinate reaping with publishing or use an atomic compare-and-delete protocol.</comment>

<file context>
@@ -350,6 +387,32 @@ export class QuotaHeaderFeedRegistry {
+          try {
+            const file = await stat(path)
+            if (file.mtimeMs > now || now - file.mtimeMs < leaseMs) return
+            await (this.options.removeFile ?? ((target) => rm(target)))(path)
+          } catch {
+            // A missed cleanup must not prevent this process from refreshing its lease.
</file context>

} catch {
// A missed cleanup must not prevent this process from refreshing its lease.
}
}),
)
}

async list(): Promise<QuotaHeaderFeedEntry[]> {
await this.writeChain.catch(() => {})
const directory =
Expand Down
Loading