diff --git a/.gitignore b/.gitignore index 1d8f78aa..96f423c3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 47f4cfc2..6466d522 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.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}' @@ -6,11 +10,20 @@ help: ## Show this help # ── 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 ──────────────────────────────────────────────────────── @@ -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 @@ -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 diff --git a/changelog/unreleased/orcarouter-provider.md b/changelog/unreleased/orcarouter-provider.md new file mode 100644 index 00000000..f4edd7b3 --- /dev/null +++ b/changelog/unreleased/orcarouter-provider.md @@ -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),不再需要手填模型名。 diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 6d011a14..cf6af9f2 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -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 = { @@ -51,27 +64,32 @@ type ModelsMemoryEntry = { const modelsMemoryTTL = 30_000 const modelsMemoryCache = new Map() -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(`/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(() => { @@ -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 }) @@ -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 = {} if (accountId) headers['X-Qoder-Account'] = accountId return api('/api/chat', { diff --git a/frontend/src/components/AddAccountModal.tsx b/frontend/src/components/AddAccountModal.tsx index ef72d76d..74e88423 100644 --- a/frontend/src/components/AddAccountModal.tsx +++ b/frontend/src/components/AddAccountModal.tsx @@ -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, @@ -53,6 +54,8 @@ const labelKeys: Record = { 'devin-global': 'accountTypeDevinGlobal', 'command-global': 'accountTypeCommandGlobal', 'codex-global': 'accountTypeCodexGlobal', + 'orcarouter-global': 'accountTypeOrcaRouterGlobal', + 'orcarouter-oauth-global': 'accountTypeOrcaRouterOAuthGlobal', } const hintKeys: Record = { @@ -64,6 +67,8 @@ const hintKeys: Record = { 'devin-global': 'accountTypeDevinGlobalHint', 'command-global': 'accountTypeCommandGlobalHint', 'codex-global': 'accountTypeCodexGlobalHint', + 'orcarouter-global': 'accountTypeOrcaRouterGlobalHint', + 'orcarouter-oauth-global': 'accountTypeOrcaRouterOAuthGlobalHint', } function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) { @@ -143,6 +148,9 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { const createdId = useRef('') const pollTimer = useRef(null) const fileInput = useRef(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) { @@ -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 @@ -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' @@ -273,15 +314,26 @@ 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') @@ -289,18 +341,70 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { 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)) } @@ -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)) } @@ -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 ( { if (!next) close() }}> - + - +
{t('addAccountTitle')} diff --git a/frontend/src/components/OrcaRouterMark.tsx b/frontend/src/components/OrcaRouterMark.tsx new file mode 100644 index 00000000..965c5d81 --- /dev/null +++ b/frontend/src/components/OrcaRouterMark.tsx @@ -0,0 +1,27 @@ +type Props = { + size?: number + className?: string +} + +// OrcaRouter's official classic mark, reused as a vector so the console keeps +// its built-in-asset policy (no remote image URLs). Both the API-key and the +// Auth entry render this one mark, since they are one provider behind two +// credential choices. +export function OrcaRouterMark({ size = 16, className = '' }: Props) { + return ( + + ) +} diff --git a/frontend/src/components/ProviderMark.tsx b/frontend/src/components/ProviderMark.tsx index fdea3716..c31ef965 100644 --- a/frontend/src/components/ProviderMark.tsx +++ b/frontend/src/components/ProviderMark.tsx @@ -1,6 +1,7 @@ import { CommandMark } from '@/components/CommandMark' import { CodexMark } from '@/components/CodexMark' import { DevinMark } from '@/components/DevinMark' +import { OrcaRouterMark } from '@/components/OrcaRouterMark' import { QoderMark } from '@/components/QoderMark' import { TraeMark } from '@/components/TraeMark' import { WorkBuddyMark } from '@/components/WorkBuddyMark' @@ -29,5 +30,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) { if (id === 'codex') { return } + if (id === 'orcarouter' || id === 'orcarouter-oauth') { + return + } return } diff --git a/frontend/src/components/account/AccountCard.tsx b/frontend/src/components/account/AccountCard.tsx index 2a0cbbe1..1e825e45 100644 --- a/frontend/src/components/account/AccountCard.tsx +++ b/frontend/src/components/account/AccountCard.tsx @@ -45,6 +45,7 @@ type Props = { callbackUrl?: string onCallbackChange?: (value: string) => void onSubmitCallback?: () => void + onPageHideCancel?: () => void onExport: () => void onRefresh?: () => void onDelete: () => void @@ -86,6 +87,7 @@ export function AccountCard({ callbackUrl, onCallbackChange, onSubmitCallback, + onPageHideCancel, onExport, onRefresh, onDelete, @@ -125,6 +127,10 @@ export function AccountCard({ const lastError = account.last_error || account.lastError const errorKind = account.last_error_kind || account.kind const provider = accountProviderLabel(account.provider, account.region, t) + // Providers that hand the operator a code to paste back (Trae, and the + // OrcaRouter PKCE entry) render the callback textarea in the auth panel. + const supportsCallbackPaste = (account.provider === 'trae' || account.provider === 'orcarouter-oauth') && Boolean(onSubmitCallback && onCallbackChange) + const loginHint = account.provider === 'orcarouter-oauth' ? t('orcarouterLoginHint') : t('qoderLoginHint') const checkinStatus = account.last_checkin_status const checkinLabel = checkinStatus === 'success' ? 'checkinRecordSuccess' : checkinStatus === 'already' ? 'checkinRecordAlready' : checkinStatus === 'skipped' ? 'checkinRecordSkipped' : checkinStatus === 'error' ? 'checkinRecordFailed' : 'lastCheckinNone' @@ -173,6 +179,17 @@ export function AccountCard({ return () => context.revert() }, [authPanelOpen]) + // A pagehide while the auth panel is open must release the provider's + // in-flight login lock even though the page may be restored from the + // back-forward cache without a remount. The pagehide listener lives in + // AccountsPage so it can also clear the panel's UI state synchronously. + useEffect(() => { + if (!authPanelOpen || !onPageHideCancel) return + const onPageHide = () => onPageHideCancel() + window.addEventListener('pagehide', onPageHide) + return () => window.removeEventListener('pagehide', onPageHide) + }, [authPanelOpen, onPageHideCancel]) + // Refresh keeps the existing card mounted so the layout does not jump. // The refresh button spinner (busyKind === 'refresh') is the only visual // indicator; stale quota / status stay on screen until the new payload @@ -181,6 +198,8 @@ export function AccountCard({ @@ -281,21 +300,21 @@ export function AccountCard({ {authPanelOpen && account.enabled ? ( -
+
{t('oauthDeviceFlow')}
-

{t('qoderLoginHint')}

+

{loginHint}

- + {authUrl ? : null}
- {account.provider === 'trae' && onSubmitCallback && onCallbackChange ? ( + {supportsCallbackPaste ? (

{t('wizardCallbackLead')}