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
18 changes: 14 additions & 4 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,7 @@ export async function CodexAuthPlugin(

async function writeRequestSidebarRouting(
sessionId: string | undefined,
parentSessionId: string | undefined,
activeId: string,
route: RoutingMode,
accounts: readonly { id: string; enabled?: boolean }[],
Expand All @@ -1384,6 +1385,13 @@ export async function CodexAuthPlugin(
accounts,
boundSidebarFile,
)
if (parentSessionId && parentSessionId !== sessionId) {
await upsertSidebarActiveRouting(
{ sessionId: parentSessionId, ...input },
accounts,
boundSidebarFile,
)
}
return
}
await setSidebarLegacyRouting(input, boundSidebarFile)
Expand Down Expand Up @@ -1981,9 +1989,10 @@ export async function CodexAuthPlugin(
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
const sidebarSessionId = resolveSidebarSessionId(
effectiveRequestHeaders(requestInput, init),
)
const requestHeaders = effectiveRequestHeaders(requestInput, init)
const sidebarSessionId = resolveSidebarSessionId(requestHeaders)
const sidebarParentSessionId =
requestHeaders.get('x-parent-session-id')?.trim() || undefined
// Routing is purely mode-driven. The primary is ALWAYS the main
// account; fallback-first is handled by a proactive gate below that
// tries usable fallbacks before main. There is no per-account pin.
Expand Down Expand Up @@ -2191,10 +2200,11 @@ export async function CodexAuthPlugin(

await writeRequestSidebarRouting(
sidebarSessionId,
sidebarParentSessionId,
servedActiveId,
mode,
reqStorage?.accounts ?? [],
)
).catch(() => {})
return finalResponse
},
async dispose() {
Expand Down
31 changes: 29 additions & 2 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,22 @@ export function isUsableRoutingEntry(
)
}

export function isQuotaExhausted(
quota: AccountQuota | null | undefined,
now = Date.now(),
): boolean {
const primary = quota?.primary
if (
typeof primary?.resetsAt !== 'string' ||
!Number.isFinite(primary.usedPercent) ||
primary.usedPercent < 100
) {
return false
}
const resetsAt = Date.parse(primary.resetsAt)
return Number.isFinite(resetsAt) && resetsAt > now
}

export function resolveSessionSidebarRouting(
state: SidebarState,
sessionId?: string,
Expand All @@ -322,13 +338,24 @@ export function resolveSessionSidebarRouting(
return { activeId: state.activeId ?? 'main', route: state.route }
}
const own = sessionId ? state.activeRouting?.[sessionId] : undefined
if (own && isUsableRoutingEntry(own, state.fallbacks, now)) {
const ownQuota =
own?.activeId === 'main'
? state.main.quota
: state.fallbacks.find((account) => account.id === own?.activeId)?.quota
if (
own &&
isUsableRoutingEntry(own, state.fallbacks, now) &&
!isQuotaExhausted(ownQuota, now)
) {
return { activeId: own.activeId, route: own.route }
}

const fallback = state.fallbacks.find(
const enabledFallbacks = state.fallbacks.filter(
(account) => account.enabled && !account.killed,
)
const fallback =
enabledFallbacks.find((account) => !isQuotaExhausted(account.quota, now)) ??
enabledFallbacks[0]
return {
activeId:
state.route === 'fallback-first' && fallback ? fallback.id : 'main',
Expand Down
108 changes: 107 additions & 1 deletion packages/opencode/src/tests/integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, test } from 'bun:test'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import {
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs'
import { readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand Down Expand Up @@ -420,6 +426,106 @@ describe('integration: HTTP quota push', () => {
}
})

it('records served routing for child and parent sessions', async () => {
writeFileSync(
configFile,
JSON.stringify({
version: 1,
main: { type: 'opencode', provider: 'openai' },
accounts: [],
}),
)

const originalFetch = globalThis.fetch
globalThis.fetch = (async () =>
new Response('{}', {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof globalThis.fetch

let hooks: Hooks | undefined
try {
hooks = await CodexAuthPlugin(createMockPluginInput(), {
experimentalWebSockets: false,
})
const authHook = hooks.auth
if (!authHook?.loader) throw new Error('No auth loader')
const loaderResult = await authHook.loader(
async () => ({
type: 'oauth' as const,
provider: 'openai',
access: accessToken,
refresh: refreshToken,
expires: Date.now() + 3600_000,
}),
{
id: 'openai',
label: 'OpenAI',
models: [],
} as unknown as Parameters<NonNullable<(typeof authHook)['loader']>>[1],
)
const fetchOverride = (loaderResult as Record<string, unknown>).fetch as
| ((url: RequestInfo | URL, init?: RequestInit) => Promise<Response>)
| undefined
if (!fetchOverride) throw new Error('No fetch in loader result')

const serve = async (sessionId: string, parentId?: string) => {
const headers = new Headers({
'content-type': 'application/json',
'session-id': sessionId,
})
if (parentId) headers.set('x-parent-session-id', parentId)
const response = await fetchOverride(
'https://api.openai.com/v1/responses',
{
method: 'POST',
headers,
body: JSON.stringify({ model: 'gpt-5.5', input: [] }),
},
)
expect(response.status).toBe(200)
await response.body?.cancel()
}

await serve('child-session', 'parent-session')
await serve('child-only-session')
await serve('same-session', 'same-session')
await drainSidebarWrites()

const sidebar = normalizeSidebarState(
JSON.parse(readFileSync(sidebarFile, 'utf8')),
)
expect(Object.keys(sidebar.activeRouting ?? {}).sort()).toEqual([
'child-only-session',
'child-session',
'parent-session',
'same-session',
])
expect(sidebar.activeRouting?.['child-session']).toMatchObject({
activeId: 'main',
route: 'main-first',
})
expect(sidebar.activeRouting?.['parent-session']).toEqual(
sidebar.activeRouting?.['child-session'],
)
expect(sidebar.activeRouting?.['child-only-session']).toMatchObject({
activeId: 'main',
route: 'main-first',
})
expect(sidebar.activeRouting?.['same-session']).toMatchObject({
activeId: 'main',
route: 'main-first',
})

renameSync(sidebarFile, `${sidebarFile}.saved`)
mkdirSync(sidebarFile)
await serve('unwritable-child', 'unwritable-parent')
} finally {
globalThis.fetch = originalFetch
await hooks?.dispose?.()
}
})

it('a complete header frame reporting every window retired clears cached windows', async () => {
const store = {
version: 1,
Expand Down
Loading