Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .downstream/native-domains.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ nothing. While no Codex runtime row is stored,

## Codex gateway borrowing

The Extensions navigation exposes three administrator pages: settings at
`/admin/codex-gateway-borrow`, route status at
`/admin/codex-gateway-borrow/status`, and manual comparison/history at
`/admin/codex-pelican-comparison`. Opening or navigating these pages only reads
local data. Settings drafts contain configuration IDs/options only, persist in
the browser session, and never affect the saved configuration used by status or
generation. Only explicit actions save, prepare, verify or generate. The status
page polls read-only status every five seconds while preparation/verification is
active; local countdowns expire displayed routes without renewing them. Leaving
the comparison page cancels its active generation.

`GET/PUT /admin/codex-gateway-borrow/config` owns one JSON setting,
`codex_gateway_borrow_config`: `enabled`, `source_account_ids`,
`target_account_ids` and `models`. A missing row is off with empty account
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/downstream-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ jobs:
src/api/admin/__tests__/codexGatewayBorrow.spec.ts \
src/components/admin/codex/__tests__/BorrowPelicanPreview.spec.ts \
src/views/admin/__tests__/CodexGatewayBorrowView.spec.ts \
src/views/admin/__tests__/CodexGatewayBorrowStatusView.spec.ts \
src/views/admin/__tests__/CodexPelicanComparisonView.spec.ts \
src/components/admin/__tests__/CodexBorrowNav.spec.ts \
src/router/__tests__/codex-borrow-routes.spec.ts \
src/components/account/__tests__/EditAccountModal.spec.ts \
src/utils/__tests__/openaiProtocolModeEdits.spec.ts \
src/components/account/__tests__/ModelWhitelistSelector.spec.ts \
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/components/admin/CodexBorrowNav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<nav :aria-label="t('admin.codexGatewayBorrow.navLabel')">
<ol class="grid grid-cols-1 gap-1 rounded-lg border border-gray-200 bg-white p-1 sm:grid-cols-3 dark:border-dark-600 dark:bg-dark-800">
<li v-for="(item, index) in items" :key="item.path" class="min-w-0">
<RouterLink
:to="item.path"
:aria-current="route.path === item.path ? 'page' : undefined"
class="flex min-h-11 items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500"
:class="route.path === item.path
? 'bg-primary-50 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-dark-700'"
>
<span aria-hidden="true" class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-current text-xs">{{ index + 1 }}</span>
<span>{{ t(item.label) }}</span>
</RouterLink>
</li>
</ol>
</nav>
</template>

<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
const route = useRoute()
const items = [
{ path: '/admin/codex-gateway-borrow', label: 'admin.codexGatewayBorrow.navSettings' },
{ path: '/admin/codex-gateway-borrow/status', label: 'admin.codexGatewayBorrow.navStatus' },
{ path: '/admin/codex-pelican-comparison', label: 'admin.codexGatewayBorrow.navComparison' },
]
</script>
52 changes: 52 additions & 0 deletions frontend/src/components/admin/__tests__/CodexBorrowNav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it, vi } from 'vitest'
import CodexBorrowNav from '../CodexBorrowNav.vue'

vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))

const paths = [
'/admin/codex-gateway-borrow',
'/admin/codex-gateway-borrow/status',
'/admin/codex-pelican-comparison',
]

async function renderNav(path: string) {
const router = createRouter({
history: createMemoryHistory(),
routes: paths.map(routePath => ({ path: routePath, component: { template: '<div />' } })),
})
await router.push(path)
await router.isReady()
const wrapper = mount(CodexBorrowNav, { global: { plugins: [router] } })
return { router, wrapper }
}

describe('Codex borrowing page navigation', () => {
it.each(paths)('renders real links and marks only the current page on %s', async path => {
const { wrapper } = await renderNav(path)

expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual(paths)
expect(wrapper.findAll('a[aria-current="page"]')).toHaveLength(1)
expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(path)
wrapper.unmount()
})

it('navigates between pages and supports browser Back', async () => {
const { router, wrapper } = await renderNav(paths[0])

await wrapper.get(`a[href="${paths[1]}"]`).trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe(paths[1])
await wrapper.get(`a[href="${paths[2]}"]`).trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe(paths[2])

router.back()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(paths[1]))
expect(wrapper.get('a[aria-current="page"]').attributes('href')).toBe(paths[1])
wrapper.unmount()
})
})
12 changes: 11 additions & 1 deletion frontend/src/components/layout/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
:key="item.path"
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:class="{ 'sidebar-link-active': route.path === item.path, 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
@click="handleMenuItemClick(item.path)"
>
Expand Down Expand Up @@ -507,6 +507,14 @@ const GatewayBorrowIcon = {
render: () => h(Icon, { name: 'link', 'aria-hidden': 'true' })
}

const GatewayBorrowStatusIcon = {
render: () => h(Icon, { name: 'checkCircle', 'aria-hidden': 'true' })
}

const PelicanComparisonIcon = {
render: () => h(Icon, { name: 'grid', 'aria-hidden': 'true' })
}

const BellIcon = {
render: () =>
h(
Expand Down Expand Up @@ -834,6 +842,8 @@ const adminExtensionNavItems = computed((): NavItem[] => [
{ path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon },
{ path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon },
{ path: '/admin/codex-gateway-borrow', label: t('nav.codexGatewayBorrow'), icon: GatewayBorrowIcon },
{ path: '/admin/codex-gateway-borrow/status', label: t('nav.codexGatewayBorrowStatus'), icon: GatewayBorrowStatusIcon },
{ path: '/admin/codex-pelican-comparison', label: t('nav.codexPelicanComparison'), icon: PelicanComparisonIcon },
{ path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon },
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ vi.mock('vue-i18n', async importOriginal => {
}
})

async function renderSidebar(options: { admin?: boolean } = {}) {
async function renderSidebar(options: { admin?: boolean; simple?: boolean; path?: string } = {}) {
mocks.authStore.isAdmin = options.admin === true
mocks.authStore.isSimpleMode = true
mocks.authStore.isSimpleMode = options.simple !== false

const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }],
})
await router.push(options.admin ? '/admin/dashboard' : '/dashboard')
await router.push(options.path ?? (options.admin ? '/admin/dashboard' : '/dashboard'))
await router.isReady()

return mount(AppSidebar, {
Expand All @@ -97,27 +97,42 @@ describe('AppSidebar simple mode extensions', () => {
document.documentElement.classList.remove('dark')
})

it('renders no extensions section for a regular user', async () => {
const wrapper = await renderSidebar()
it.each([true, false])('renders no extensions section for a regular user (simple: %s)', async simple => {
const wrapper = await renderSidebar({ simple })

expect(wrapper.find('[data-testid="sidebar-extensions"]').exists()).toBe(false)
expect(wrapper.find('a[href="/usage"]').exists()).toBe(false)
if (simple) expect(wrapper.find('a[href="/usage"]').exists()).toBe(false)
wrapper.unmount()
})

it('renders admin extensions for an admin in simple mode', async () => {
const wrapper = await renderSidebar({ admin: true })
it.each([true, false])('renders the three admin page entries in order (simple: %s)', async simple => {
const wrapper = await renderSidebar({ admin: true, simple })

expect(extensionLinks(wrapper)).toEqual([
'/admin/system-prompts',
'/admin/codex-runtime',
'/admin/codex-gateway-borrow',
'/admin/codex-gateway-borrow/status',
'/admin/codex-pelican-comparison',
'/admin/reasoning-recovery',
])
expect(wrapper.text()).not.toContain('nav.myAccount')
if (simple) expect(wrapper.text()).not.toContain('nav.myAccount')

wrapper.unmount()
const regularUser = await renderSidebar()
const regularUser = await renderSidebar({ simple })
expect(regularUser.find('a[href="/admin/codex-gateway-borrow"]').exists()).toBe(false)
expect(regularUser.find('a[href="/admin/codex-gateway-borrow/status"]').exists()).toBe(false)
expect(regularUser.find('a[href="/admin/codex-pelican-comparison"]').exists()).toBe(false)
regularUser.unmount()
})

it('marks only Borrow Status active on its own route', async () => {
const wrapper = await renderSidebar({ admin: true, path: '/admin/codex-gateway-borrow/status' })
const section = wrapper.get('[data-testid="sidebar-extensions"]')

expect(section.get('a[href="/admin/codex-gateway-borrow/status"]').classes()).toContain('sidebar-link-active')
expect(section.get('a[href="/admin/codex-gateway-borrow"]').classes()).not.toContain('sidebar-link-active')
expect(section.findAll('.sidebar-link-active')).toHaveLength(1)
wrapper.unmount()
})
})
95 changes: 95 additions & 0 deletions frontend/src/composables/useCodexBorrowUI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { accountsAPI } from '@/api/admin/accounts'
import type { BorrowTargetStatus } from '@/api/admin/codexGatewayBorrow'
import type { AccountListItem } from '@/types'
import { resolveOpenAIWSModeFromExtra } from '@/utils/openaiWsMode'

/** Inventory is local to each page; never discover models or test accounts here. */
export function useCodexBorrowInventory(signal: AbortSignal) {
const { t, te } = useI18n()
const accounts = ref<AccountListItem[]>([])
const accountMap = computed(() => new Map(accounts.value.map(account => [account.id, account])))
function accountById(id: number) { return accountMap.value.get(id) }
function accountName(id: number) { return accountById(id)?.name || t('admin.codexGatewayBorrow.unknownAccount') }
function accountStateLabel(account?: AccountListItem) {
if (!account) return t('admin.codexGatewayBorrow.unknownAccount')
const key = `admin.codexGatewayBorrow.states.${account.status}`
const parts = [te(key) ? t(key) : account.status]
if (!account.schedulable) parts.push(t('admin.codexGatewayBorrow.paused'))
if (account.rate_limit_reset_at && Date.parse(account.rate_limit_reset_at) > Date.now()) parts.push(t('admin.codexGatewayBorrow.rateLimited'))
return parts.join(' · ')
}
function proxyLabel(account?: AccountListItem) {
if (!account) return '—'
if (account.proxy) return `${account.proxy.name} #${account.proxy.id}`
if (account.proxy_id) return `#${account.proxy_id}`
return t('admin.codexGatewayBorrow.direct')
}
function wsLabel(account: AccountListItem) {
const keys = ['openai_oauth_responses_websockets_v2_mode', 'openai_oauth_responses_websockets_v2_enabled', 'responses_websockets_v2_enabled', 'openai_ws_enabled']
if (!keys.some(key => account.extra?.[key] !== undefined)) return t('admin.codexGatewayBorrow.wsUnspecified')
return resolveOpenAIWSModeFromExtra(account.extra, { modeKey: keys[0], enabledKey: keys[1], fallbackEnabledKeys: keys.slice(2) })
}
function modelMappingLabel(account: AccountListItem) {
const mapping = account.credentials?.model_mapping
if (!mapping || typeof mapping !== 'object' || !Object.keys(mapping).length) return t('admin.codexGatewayBorrow.noModelRestriction')
const entries = Object.entries(mapping).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
return `${t('admin.codexGatewayBorrow.localMappings')}: ${entries.map(([from, to]) => from === to ? from : `${from} → ${to}`).join(', ')}`
}
async function loadAccounts() {
const found: AccountListItem[] = []
for (let page = 1; !signal.aborted; page++) {
const response = await accountsAPI.list(page, 100, { platform: 'openai', types: 'oauth,setup-token' }, { signal })
found.push(...response.items.filter(account => account.platform === 'openai' && (account.type === 'oauth' || account.type === 'setup-token')))
if (!response.items.length || page * 100 >= response.total) break
}
if (!signal.aborted) accounts.value = found
}
return { accounts, loadAccounts, accountById, accountName, accountStateLabel, proxyLabel, wsLabel, modelMappingLabel }
}

/** A local tick expires the display without requesting or renewing any route. */
export function useCodexBorrowClock() {
const now = ref(Date.now())
const serverClockKnown = ref(false)
const serverOffsetMs = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
function receiveServerTime(generatedAt: string) {
const time = Date.parse(generatedAt)
serverClockKnown.value = Number.isFinite(time)
if (serverClockKnown.value) {
serverOffsetMs.value = time - Date.now()
now.value = time
}
}
function remainingSeconds(expiresAt?: string) {
const expires = expiresAt ? Date.parse(expiresAt) : NaN
return Number.isFinite(expires) ? Math.max(0, Math.ceil((expires - now.value) / 1000)) : 0
}
function cacheUsable(target?: BorrowTargetStatus) {
return !!target?.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now.value)
}
onMounted(() => { timer = setInterval(() => { now.value = Date.now() + serverOffsetMs.value }, 1000) })
onBeforeUnmount(() => { if (timer !== undefined) clearInterval(timer) })
return { now, serverClockKnown, serverOffsetMs, receiveServerTime, cacheUsable, remainingSeconds }
}

export function borrowTargetState(target: BorrowTargetStatus | undefined, now: number): 'ready' | 'expired' | 'validating' | 'waiting' | 'rejected' {
if (!target) return 'waiting'
if (target.cache_valid && (!target.expires_at || Date.parse(target.expires_at) > now)) return 'ready'
if (target.state === 'expired' || ((target.cache_valid || target.state === 'ready' || target.state === 'valid') && !!target.expires_at && Date.parse(target.expires_at) <= now)) return 'expired'
if (target.state === 'validating' || target.reason === 'validating') return 'validating'
if (['waiting', 'missing', 'disabled'].includes(target.state) || ['not_prepared', 'not_verified', 'not_configured', 'disabled'].includes(target.reason)) return 'waiting'
return 'rejected'
}

const knownReasons = new Set([
'not_prepared', 'not_verified', 'not_configured', 'disabled', 'route_expired',
'source_qualified', 'source_probe_failed', 'source_response_not_qualified', 'source_routing_cookie_unavailable',
'target_probe_passed', 'target_probe_failed', 'target_route_changed', 'target_state_missing', 'target_state_changed', 'validating',
'candidate_ready', 'validated', 'no_matched_cache'
])
export function borrowReasonLabel(reason: string, t: (key: string) => string): string {
return knownReasons.has(reason) ? t(`admin.codexGatewayBorrow.reasons.${reason}`) : reason
}
Loading
Loading