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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ web/

deploy/docker-compose.override.yml
.zcode/

# OrcaRouter console evidence (scripts/orca_evidence). Produced by the acceptance
# run rather than committed: the delivery validator must be able to regenerate
# the screenshots from the tree it is testing, so a checked-in PNG would prove
# nothing about that tree.
orca-evidence/
scripts/orca_evidence/__pycache__/
.pytest_cache/
30 changes: 26 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
.PHONY: help test vet lint build dev sync favicon-sync start docker-build docker-up docker-down docker-logs changelog
.PHONY: help test vet lint build dev sync favicon-sync start docker-build docker-up docker-down docker-logs changelog orca-evidence verify test-live test-architecture worker-test

# Go toolchain. Override to point at a specific toolchain, e.g.
# `make test GO=/opt/go/bin/go`, for hosts that do not put `go` on PATH.
GO ?= go

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'

# ── Go ──────────────────────────────────────────────────────────────

test: ## Run Go and worker tests
go test ./...
$(GO) test ./...
$(MAKE) -C worker test

vet: ## Run Go vet
go vet ./...
$(GO) vet ./...

test-architecture: ## Run the architecture/import boundary tests
$(GO) test ./internal/app -run 'TestImportConstraints|TestDutyBoundaries' -count=1

test-live: ## Run the live OrcaRouter acceptance tests (requires ORCAROUTER_API_KEY)
$(GO) test ./internal/providers/orcarouter/ -run TestLive -count=1 -v

worker-test: ## Run the worker daemon tests
cd worker && npm test

# ── Frontend ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -48,6 +61,15 @@ docker-logs: ## Follow service logs

lint: vet frontend-lint ## Run all linters

verify: ## Run the full local gate: vet, Go + worker tests, frontend build and lint
$(GO) vet ./...
$(GO) test ./...
$(MAKE) -C worker test
cd frontend && npm install --no-package-lock --no-audit --no-fund && npm run build && npm run lint

orca-evidence: ## Capture the OrcaRouter console evidence screenshots
GO_BIN="$(GO)" python3 scripts/orca_evidence/test_orcarouter_gui.py

changelog: ## Validate bilingual changelog
python3 scripts/release-notes.py self-test
python3 scripts/release-notes.py validate
Expand All @@ -58,5 +80,5 @@ start: ## Start Docker deployment and print a first-run API key
./scripts/start.sh

dev: ## Run Go server locally (requires .env or env vars)
go run ./cmd/server
$(GO) run ./cmd/server

9 changes: 9 additions & 0 deletions changelog/unreleased/orcarouter-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Add OrcaRouter as a first-class provider with two credential entries: paste an `sk-orca-…` API key, or connect with an OrcaRouter account through OAuth 2.0 + PKCE (loopback redirect, no client secret).
- Populate the model selector from the live OrcaRouter catalog, filtered per entry (`chat`, image/audio/video understanding, embedding, image generation, video, rerank), instead of free-text model entry.

### 中文

- 新增 OrcaRouter 一等 provider,提供两种凭据入口:粘贴 `sk-orca-…` 密钥,或用 OrcaRouter 账号通过 OAuth 2.0 + PKCE 连接(loopback 回调,无需 client secret)。
- 模型下拉由 OrcaRouter 实时目录生成,并按入口能力过滤(chat、图片/音频/视频理解、embedding、图片生成、视频、rerank),不再需要手填模型名。
38 changes: 28 additions & 10 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ export function loginWithPat(pat: string, accountId?: string) {
})
}

// cancelLogin releases the server-side "login already in progress" lock. The
// `keepalive` flag lets the request survive a page being unloaded, which is the
// pagehide path: the generation guard stops the stale request from mutating UI
// state, but the server work still has to be cancelled explicitly.
export function cancelLogin(accountId?: string, keepalive = false) {
if (!accountId) return Promise.resolve(null)
return api(`/api/accounts/${encodeURIComponent(accountId)}/login/cancel`, {
method: 'POST',
body: '{}',
keepalive,
}).catch(() => null)
}

type ModelsResponse = { data?: Overview['models'] }

type ModelsMemoryEntry = {
Expand All @@ -51,27 +64,32 @@ type ModelsMemoryEntry = {
const modelsMemoryTTL = 30_000
const modelsMemoryCache = new Map<string, ModelsMemoryEntry>()

function modelsMemoryKey(accountId?: string, view?: 'regional') {
return `${accountId || '*'}@${view || 'merged'}`
function modelsMemoryKey(accountId?: string, view?: 'regional', capability?: string) {
return `${accountId || '*'}@${view || 'merged'}@${capability || 'chat'}`
}

export function fetchModels(accountId?: string, refresh = false, view?: 'regional') {
export type ModelCapability = 'chat' | 'vision' | 'embedding' | 'image' | 'video' | 'rerank'

export function fetchModels(accountId?: string, refresh = false, view?: 'regional', capability?: ModelCapability) {
const q = new URLSearchParams()
if (refresh) q.set('refresh', '1')
if (accountId) q.set('account', accountId)
if (view) q.set('view', view)
// The selector for one entry point only ever shows models whose catalog
// metadata declares that capability. `chat` is the default server-side filter.
if (capability) q.set('capability', capability)
const query = q.toString()
return api<ModelsResponse>(`/api/models${query ? `?${query}` : ''}`)
}

export function fetchModelsCached(accountId?: string, view?: 'regional') {
const key = modelsMemoryKey(accountId, view)
export function fetchModelsCached(accountId?: string, view?: 'regional', capability?: ModelCapability) {
const key = modelsMemoryKey(accountId, view, capability)
const cached = modelsMemoryCache.get(key)
if (cached && Date.now() - cached.at < modelsMemoryTTL) {
return Promise.resolve(cached.data)
}
if (cached?.pending) return cached.pending
const pending = fetchModels(accountId, false, view).then((data) => {
const pending = fetchModels(accountId, false, view, capability).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
}).finally(() => {
Expand All @@ -84,10 +102,10 @@ export function fetchModelsCached(accountId?: string, view?: 'regional') {
return pending
}

export function refreshModels(accountId?: string, view?: 'regional') {
const key = modelsMemoryKey(accountId, view)
export function refreshModels(accountId?: string, view?: 'regional', capability?: ModelCapability) {
const key = modelsMemoryKey(accountId, view, capability)
modelsMemoryCache.delete(key)
return fetchModels(accountId, true, view).then((data) => {
return fetchModels(accountId, true, view, capability).then((data) => {
modelsMemoryCache.set(key, { data, at: Date.now() })
return data
})
Expand Down Expand Up @@ -151,7 +169,7 @@ export function refreshAccount(accountId: string, options?: { quota?: boolean })
})
}

export function testChat(model: string, content: string, accountId?: string) {
export function testChat(model: string, content: string | unknown[], accountId?: string) {
const headers: Record<string, string> = {}
if (accountId) headers['X-Qoder-Account'] = accountId
return api('/api/chat', {
Expand Down
125 changes: 118 additions & 7 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FormRow } from '@/components/ui/FormRow'
import { OptionTiles } from '@/components/ui/OptionTiles'
import { useI18n } from '@/hooks/useI18n'
import {
cancelLogin,
createAccount,
fetchLoginStatus,
fetchProviders,
Expand Down Expand Up @@ -53,6 +54,8 @@ const labelKeys: Record<string, string> = {
'devin-global': 'accountTypeDevinGlobal',
'command-global': 'accountTypeCommandGlobal',
'codex-global': 'accountTypeCodexGlobal',
'orcarouter-global': 'accountTypeOrcaRouterGlobal',
'orcarouter-oauth-global': 'accountTypeOrcaRouterOAuthGlobal',
}

const hintKeys: Record<string, string> = {
Expand All @@ -64,6 +67,8 @@ const hintKeys: Record<string, string> = {
'devin-global': 'accountTypeDevinGlobalHint',
'command-global': 'accountTypeCommandGlobalHint',
'codex-global': 'accountTypeCodexGlobalHint',
'orcarouter-global': 'accountTypeOrcaRouterGlobalHint',
'orcarouter-oauth-global': 'accountTypeOrcaRouterOAuthGlobalHint',
}

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
Expand Down Expand Up @@ -143,6 +148,9 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const createdId = useRef<string>('')
const pollTimer = useRef<number | null>(null)
const fileInput = useRef<HTMLInputElement>(null)
// Monotonic login generation. Every async response and poll tick must still
// belong to the current generation before touching credentials or UI state.
const browserGeneration = useRef(0)

function stopPolling() {
if (pollTimer.current !== null) {
Expand All @@ -151,10 +159,42 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}
}

// releaseLogin invalidates the generation, stops the poll timer, and tells the
// server to drop its in-flight attempt for the account this wizard created.
// keepalive lets the request outlive a page being unloaded. It never writes UI
// state, so it is safe on unmount.
function releaseLogin(keepalive: boolean) {
browserGeneration.current += 1
stopPolling()
const id = createdId.current
if (id) void cancelLogin(id, keepalive)
}

useEffect(() => {
return () => stopPolling()
}, [])

useEffect(() => {
if (!isOpen) return
// The back-forward cache can restore this page without remounting. Clear the
// busy flag and the authorization hint synchronously in the handler itself —
// do not rely on an invalidated request's guarded finally, which correctly
// refuses to mutate state and would leave the restored page stuck busy — then
// send the server cancellation with keepalive.
const onPageHide = () => {
releaseLogin(true)
setPhase('idle')
setMessage('')
setAuthUrl('')
}
window.addEventListener('pagehide', onPageHide)
return () => {
window.removeEventListener('pagehide', onPageHide)
// A real unmount cancels the in-flight work without writing UI state.
releaseLogin(false)
}
}, [isOpen])

useEffect(() => {
if (!isOpen) return
let cancelled = false
Expand Down Expand Up @@ -184,7 +224,8 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
// so the wizard opens on the PAT tab instead of the browser tab.
const hasBrowserLogin = activeOption?.descriptor.capabilities?.browser_login !== false
const showDropSystem = activeOption?.provider === 'workbuddy'
const showCallbackPaste = activeOption?.provider === 'trae' || activeOption?.provider === 'devin' || activeOption?.provider === 'codex'
const isOrcaRouter = activeOption?.provider === 'orcarouter' || activeOption?.provider === 'orcarouter-oauth'
const showCallbackPaste = activeOption?.provider === 'trae' || activeOption?.provider === 'devin' || activeOption?.provider === 'codex' || isOrcaRouter
const busy = phase === 'busy' || phase === 'polling'
const settingsLocked = Boolean(createdId.current) || busy
const isDone = phase === 'done'
Expand Down Expand Up @@ -273,34 +314,97 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
return id
}

// While the OrcaRouter PKCE entry is on screen, the start button hands off to
// the provider's PKCE login session: the wizard opens, the provider resolves
// its own /auth URL, and the operator pastes the callback when the browser
// cannot reach this process. The other wizard tabs stay unchanged.
async function runBrowser() {
if (activeOption?.provider === 'orcarouter-oauth') return runOAuthLogin()
setMessage('')
setAuthUrl('')
// Invalidate any earlier attempt before a new one starts, so a late response
// from a previous login cannot overwrite this one.
browserGeneration.current += 1
const generation = browserGeneration.current
try {
setPhase('busy')
const id = await ensureAccount()
if (generation !== browserGeneration.current) return
setMessage(t('wizardStartingSession'))
setPhase('polling')
const output = await startDeviceLogin(id)
if (generation !== browserGeneration.current) return
if (output.authUrl) {
setAuthUrl(output.authUrl)
window.open(output.authUrl, '_blank', 'noopener,noreferrer')
}
setMessage(t('wizardWaitingBrowser'))
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
await new Promise((resolve) => { pollTimer.current = window.setTimeout(resolve, POLL_INTERVAL) })
if (generation !== browserGeneration.current) return
const status = await fetchLoginStatus(id)
if (generation !== browserGeneration.current) return
const login = status.login || {}
if (login.message) setMessage(login.message)
if (login.status === 'ok') break
if (login.status === 'error') throw new Error(login.message || 'login failed')
if (attempt === POLL_ATTEMPTS - 1) throw new Error(t('wizardLoginTimeout'))
}
if (generation !== browserGeneration.current) return
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
window.setTimeout(finishAndClose, 900)
} catch (error) {
if (generation !== browserGeneration.current) return
setPhase('idle')
setMessage(error instanceof Error ? error.message : String(error))
}
}

// runOAuthLogin drives an OrcaRouter PKCE attempt through startDeviceLogin +
// fetchLoginStatus, which the console already routes to the in-process login
// provider. Run it inside its own generation so a superseded attempt cannot
// touch credentials or UI state.
async function runOAuthLogin() {
setMessage('')
setAuthUrl('')
browserGeneration.current += 1
const generation = browserGeneration.current
try {
setPhase('busy')
const id = await ensureAccount()
if (generation !== browserGeneration.current) return
setMessage(t('wizardStartingSession'))
const output = await startDeviceLogin(id)
if (generation !== browserGeneration.current) return
if (output.authUrl) {
setAuthUrl(output.authUrl)
window.open(output.authUrl, '_blank', 'noopener,noreferrer')
}
setPhase('polling')
setMessage(t('wizardWaitingBrowser'))
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
await new Promise((resolve) => { pollTimer.current = window.setTimeout(resolve, POLL_INTERVAL) })
if (generation !== browserGeneration.current) return
const status = await fetchLoginStatus(id)
if (generation !== browserGeneration.current) return
const login = status.login || {}
if (login.message) setMessage(login.message)
if (login.status === 'ok') break
if (login.status === 'error') throw new Error(login.message || 'login failed')
if (attempt === POLL_ATTEMPTS - 1) throw new Error(t('wizardLoginTimeout'))
}
if (generation !== browserGeneration.current) return
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
window.setTimeout(finishAndClose, 900)
} catch (error) {
if (generation !== browserGeneration.current) return
// A denial, state mismatch, expiry, or a rejected exchange clears the
// provider's pending attempt, so return to idle instead of pretending to
// still poll. The callback textarea stays available for a fresh attempt.
setPhase('idle')
setMessage(error instanceof Error ? error.message : String(error))
}
Expand All @@ -312,16 +416,21 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setMessage(t('wizardCallbackPh'))
return
}
browserGeneration.current += 1
const generation = browserGeneration.current
try {
setPhase('busy')
const id = await ensureAccount()
if (generation !== browserGeneration.current) return
stopPolling()
await completeLoginCallback(id, pasted)
if (generation !== browserGeneration.current) return
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
window.setTimeout(finishAndClose, 900)
} catch (error) {
if (generation !== browserGeneration.current) return
setPhase('polling')
setMessage(error instanceof Error ? error.message : String(error))
}
Expand Down Expand Up @@ -425,20 +534,22 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
].filter(Boolean) as Array<{ value: TabKey; label: string; icon: React.ReactNode }>

const tabLead = tab === 'browser'
? t('wizardBrowserLead')
? (isOrcaRouter ? t('wizardBrowserLeadOrcaRouter') : t('wizardBrowserLead'))
: tab === 'pat'
? t(activeOption?.provider === 'command'
? 'wizardPatLeadCommand'
: activeOption?.region === 'cn' && activeOption?.provider === 'qoder'
? 'wizardPatLeadCN'
: 'wizardPatLead')
: activeOption?.provider === 'orcarouter' || activeOption?.provider === 'orcarouter-oauth'
? 'wizardPatLeadOrcaRouter'
: activeOption?.region === 'cn' && activeOption?.provider === 'qoder'
? 'wizardPatLeadCN'
: 'wizardPatLead')
: t('wizardImportLead')

return (
<Modal.Root isOpen={isOpen} onOpenChange={(next: boolean) => { if (!next) close() }}>
<Modal.Backdrop variant="blur" isDismissable={!busy}>
<Modal.Backdrop variant="blur" isDismissable={!busy} data-testid="account-modal">
<Modal.Container size="lg" scroll="inside" className="sm:max-w-3xl">
<Modal.Dialog>
<Modal.Dialog data-testid="account-modal-dialog">
<Modal.Header className="relative items-center justify-center px-12 pt-5 text-center">
<div className="min-w-0">
<Modal.Heading className="text-lg font-semibold tracking-[-0.01em]">{t('addAccountTitle')}</Modal.Heading>
Expand Down
Loading