Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,12 @@ 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 `/tmp/opencode-anthropic-auth/quota-header-feed/`. 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.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

Each entry always includes `anthropic_account_uuid`. A UUID identifies the Anthropic account. `null` means this producer could not resolve it. An absent key identifies an older producer. On a fallback entry, `account_ref` is the store-local sidecar account ID, not the Anthropic UUID.

## 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
7 changes: 7 additions & 0 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
77 changes: 70 additions & 7 deletions packages/core/src/quota-header-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
readFile,
rename,
rm,
stat,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand All @@ -28,6 +29,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 +67,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 +83,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 +117,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 +310,7 @@ export class QuotaHeaderFeedRegistry {
now?: () => number
leaseMs?: number
instanceId?: string
removeFile?: (path: string) => Promise<void>
} = {},
) {
const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}`
Expand All @@ -302,16 +321,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 +357,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 +375,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 +387,32 @@ 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.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
23 changes: 22 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ const anthropicAuthPlugin = async (
accountId: 'main' | string
accessToken: string
authLineageId?: string
anthropicAccountUuid?: string
mainQuotaIdentity?: MainQuotaIdentityBinding
},
entry: QuotaEntry,
Expand Down Expand Up @@ -1482,6 +1483,7 @@ const anthropicAuthPlugin = async (
...entry.quota,
accountIdentity: account.id,
}
account.anthropicAccountUuid = served.anthropicAccountUuid
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
await saveAccountState(storage, accountStoragePath, {
accounts: [served.accountId],
})
Expand Down Expand Up @@ -1523,6 +1525,7 @@ const anthropicAuthPlugin = async (
served: {
accountId: 'main' | string
accessToken: string
anthropicAccountUuid?: string
mainQuotaIdentity?: MainQuotaIdentityBinding
},
entry: QuotaEntry,
Expand Down Expand Up @@ -1573,6 +1576,7 @@ const anthropicAuthPlugin = async (
provider: 'anthropic',
configured_account_count: configuredAccountCount,
observed_at_ms: observedAtMs,
anthropic_account_uuid: served.anthropicAccountUuid ?? null,
quota,
accountKey,
}
Expand All @@ -1584,6 +1588,7 @@ const anthropicAuthPlugin = async (
provider: 'anthropic',
configured_account_count: configuredAccountCount,
observed_at_ms: observedAtMs,
anthropic_account_uuid: served.anthropicAccountUuid ?? null,
quota,
accountKey,
}
Expand All @@ -1594,6 +1599,7 @@ const anthropicAuthPlugin = async (
provider: 'anthropic',
configured_account_count: configuredAccountCount,
observed_at_ms: observedAtMs,
anthropic_account_uuid: served.anthropicAccountUuid ?? null,
quota,
accountKey,
}
Expand All @@ -1604,6 +1610,7 @@ const anthropicAuthPlugin = async (
provider: 'anthropic',
configured_account_count: configuredAccountCount,
observed_at_ms: observedAtMs,
anthropic_account_uuid: served.anthropicAccountUuid ?? null,
quota,
accountKey,
}
Expand All @@ -1622,6 +1629,7 @@ const anthropicAuthPlugin = async (
accountId: 'main' | string
accessToken: string
authLineageId?: string
anthropicAccountUuid?: string
mainQuotaIdentity?: MainQuotaIdentityBinding
},
): void {
Expand Down Expand Up @@ -5681,11 +5689,24 @@ const anthropicAuthPlugin = async (
}
}

const relayConfig = getRelayConfig(await getRequestStorage())
const requestStorageForIdentity = await getRequestStorage()
const relayConfig = getRelayConfig(requestStorageForIdentity)
const persistedFallbackAccountUuid =
oauthAccountId === 'main'
? undefined
: requestStorageForIdentity?.accounts.find(
(account): account is OAuthAccount =>
account.id === oauthAccountId && isOAuthAccount(account),
)?.anthropicAccountUuid
const served = {
accountId: oauthAccountId,
accessToken,
authLineageId: fallbackAuthLineageId,
anthropicAccountUuid:
identity.accountUuid ??
(oauthAccountId === 'main'
? mainQuotaIdentity?.accountIdentity
: persistedFallbackAccountUuid),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
...(oauthAccountId === 'main' && mainQuotaIdentity
? { mainQuotaIdentity }
: {}),
Expand Down
Loading