From 026b079fe1615747fdd132417be757a2bc9530fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Thu, 17 Sep 2026 02:43:42 +0800 Subject: [PATCH] fix(workbuddy): keep regional catalog prices honest Source WorkBuddy credits/free from the official catalog, keep /v1 models merged, and expose /api/models?view=regional so the Providers page can show per-region prices without breaking Access/Overview. --- AGENTS.md | 2 + frontend/src/api/overview.ts | 19 +- frontend/src/api/types.ts | 4 + frontend/src/components/ModelDetailsModal.tsx | 15 +- .../components/account/AccountModelsModal.tsx | 5 + frontend/src/i18n/messages.ts | 2 + frontend/src/lib/format.ts | 14 + frontend/src/pages/AccessPage.tsx | 20 +- frontend/src/pages/ProvidersPage.tsx | 86 ++++- internal/accounts/manager.go | 20 ++ internal/accounts/providers_test.go | 28 +- internal/api/chat.go | 112 +++--- internal/api/server.go | 2 +- internal/api/workerproxy.go | 232 +++++++++---- internal/api/workerproxy_test.go | 168 +++++++++ internal/providers/interfaces.go | 2 + internal/providers/workbuddy/catalog.go | 40 +++ internal/providers/workbuddy/client.go | 89 +---- internal/providers/workbuddy/client_test.go | 326 +++++++++++++----- internal/providers/workbuddy/credential.go | 41 ++- internal/providers/workbuddy/headers.go | 13 +- internal/providers/workbuddy/mapping.go | 8 +- internal/providers/workbuddy/mapping_test.go | 10 +- ...t-CnUmynI-.js => TrafficChart-BKiRYku8.js} | 2 +- .../webui/static/assets/index-B5AoNCHt.js | 28 ++ .../webui/static/assets/index-CwU6lAfD.js | 28 -- internal/webui/static/index.html | 2 +- 27 files changed, 949 insertions(+), 369 deletions(-) rename internal/webui/static/assets/{TrafficChart-CnUmynI-.js => TrafficChart-BKiRYku8.js} (99%) create mode 100644 internal/webui/static/assets/index-B5AoNCHt.js delete mode 100644 internal/webui/static/assets/index-CwU6lAfD.js diff --git a/AGENTS.md b/AGENTS.md index 89352ce..17f7307 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Do not add new `TODO.md`, `NOTES.md`, or extra plan files. Extra design docs are - Keep architecture: auth / endpoint / executor / translate / api - Prefer direct HTTP/SSE to Qoder cloud APIs - Pin qodercli / qoderclicn hooks in `worker/src/compat.mjs`; fail loudly on mismatch. Qoder CN is `provider=qoder` + `region=cn`, not a new family +- Reasoning levels are catalog-driven: map client values through `internal/providers/reasoning.go` (`none`/`low`/`medium`/`high`/`xhigh`/`max`), clamp anything the model does not allow back to an allowed level, and treat the console value as a default only (it never locks a call or caps a higher client value) - Console UI: React + Tailwind v4 + **HeroUI only** for components - Follow `docs/DESIGN.md` (taste v1 adapted for this console) - Keep iterating Qoder login, usage, and account routing. Borrow scheduling ideas from [sub2api](https://github.com/Wei-Shaw/sub2api), not its commercial gateway @@ -37,4 +38,5 @@ Do not add new `TODO.md`, `NOTES.md`, or extra plan files. Extra design docs are - Copy sub2api billing, Redis slots, multi-tenant API keys, or session-hash-for-profit - Add a new component library, purple AI chrome, centered generic login cards, or emoji in UI copy - Start Cursor / Anthropic until the current Qoder milestone in `docs/PLAN.md` is done. Qoder CN is that milestone (`provider=qoder` + `region=cn`); do not spawn a full `qoderclicn` per request +- Invent reasoning levels a model does not declare. Catalog effort wins: keep `onlyReasoning` models locked (DeepSeek is `high`), and do not give WorkBuddy a Trae-style Max switch or send a context-window switch on chat - Change the SQL bytes of a shipped SQLite migration in `internal/accounts/migrations.go`. Tabs, spaces, and comments inside the raw string count. `gofmt` on the Go around it is fine; indenting the SQL is not. Existing databases panic on boot with `checksum mismatch` diff --git a/frontend/src/api/overview.ts b/frontend/src/api/overview.ts index 5645cec..037af02 100644 --- a/frontend/src/api/overview.ts +++ b/frontend/src/api/overview.ts @@ -56,26 +56,27 @@ type ModelsMemoryEntry = { const modelsMemoryTTL = 30_000 const modelsMemoryCache = new Map() -function modelsMemoryKey(accountId?: string) { - return accountId || '*' +function modelsMemoryKey(accountId?: string, view?: 'regional') { + return `${accountId || '*'}@${view || 'merged'}` } -export function fetchModels(accountId?: string, refresh = false) { +export function fetchModels(accountId?: string, refresh = false, view?: 'regional') { const q = new URLSearchParams() if (refresh) q.set('refresh', '1') if (accountId) q.set('account', accountId) + if (view) q.set('view', view) const query = q.toString() return api(`/api/models${query ? `?${query}` : ''}`) } -export function fetchModelsCached(accountId?: string) { - const key = modelsMemoryKey(accountId) +export function fetchModelsCached(accountId?: string, view?: 'regional') { + const key = modelsMemoryKey(accountId, view) 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).then((data) => { + const pending = fetchModels(accountId, false, view).then((data) => { modelsMemoryCache.set(key, { data, at: Date.now() }) return data }).finally(() => { @@ -88,10 +89,10 @@ export function fetchModelsCached(accountId?: string) { return pending } -export function refreshModels(accountId?: string) { - const key = modelsMemoryKey(accountId) +export function refreshModels(accountId?: string, view?: 'regional') { + const key = modelsMemoryKey(accountId, view) modelsMemoryCache.delete(key) - return fetchModels(accountId, true).then((data) => { + return fetchModels(accountId, true, view).then((data) => { modelsMemoryCache.set(key, { data, at: Date.now() }) return data }) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 30a62a4..fe50e94 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -29,7 +29,11 @@ export type ModelInfo = { provider?: string owned_by?: string native_model?: string + region?: string + regions?: string[] stale?: boolean + credits?: string + free?: boolean context_length?: number default_context_length?: number context_custom?: boolean diff --git a/frontend/src/components/ModelDetailsModal.tsx b/frontend/src/components/ModelDetailsModal.tsx index 07001ab..6782972 100644 --- a/frontend/src/components/ModelDetailsModal.tsx +++ b/frontend/src/components/ModelDetailsModal.tsx @@ -1,6 +1,8 @@ import { Chip, Modal } from '@heroui/react' import { X } from '@phosphor-icons/react' import type { ModelInfo } from '@/api/types' +import { modelCreditsText, modelIsFree } from '@/lib/format' +import { accountProviderLabel } from '@/lib/provider' type Translate = (key: string, vars?: Record) => string @@ -22,6 +24,11 @@ export function ModelDetailsModal({ model, t, onClose }: Props) { const options = model.reasoning_options || [] const windowDev = model.catalog_context_length || model.default_context_length || model.context_length const windowMax = model.catalog_context_length_max + const credits = modelCreditsText(model) + const free = modelIsFree(model) + const provider = String(model.provider || model.owned_by || 'qoder').trim().toLowerCase() + const region = String(model.region || model.regions?.[0] || '').trim().toLowerCase() + const providerLabel = accountProviderLabel(provider, region || undefined, t) return ( { if (!next) onClose() }}> @@ -29,8 +36,14 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
-
{model.display_name || model.id}
+
+
{model.display_name || model.id}
+ {free ? {t('modelFree')} : null} +
{model.id}
+
+ {providerLabel}{credits ? ` · ${credits}` : ''} +
diff --git a/frontend/src/components/account/AccountModelsModal.tsx b/frontend/src/components/account/AccountModelsModal.tsx index f9ad4f6..cb9dbdb 100644 --- a/frontend/src/components/account/AccountModelsModal.tsx +++ b/frontend/src/components/account/AccountModelsModal.tsx @@ -8,6 +8,7 @@ import { EmptyPanel } from '@/components/ui/EmptyPanel' import { SkeletonBlock } from '@/components/ui/PageSkeletons' import { PageAlert } from '@/components/ui/PageAlert' import type { AccountRow } from '@/lib/account' +import { modelCreditsText, modelIsFree } from '@/lib/format' import { accountProviderLabel } from '@/lib/provider' type Translate = (key: string, vars?: Record) => string @@ -113,12 +114,16 @@ export function AccountModelsModal({ account, t, onClose }: Props) { {models.map((model) => { const ownedBy = model.provider || model.owned_by || account?.provider || 'qoder' const routed = routedModelName(model) + const credits = modelCreditsText(model) + const free = modelIsFree(model) return (
  • {model.display_name || model.id} + {free ? {t('modelFree')} : null} + {credits ? {credits} : null} {model.stale ? {t('fallback')} : null}
    {model.id}
    diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 306b95a..bb92e1b 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -440,6 +440,7 @@ export const messages: Record = { defaultValue: 'Default', resetDefault: 'Restore default', fallback: 'fallback', + modelFree: '(free)', shownTotal: '{shown} shown / {total} total', noModelsYet: 'No models yet', noModelsMatch: 'No models match this filter.', @@ -1065,6 +1066,7 @@ export const messages: Record = { defaultValue: '默认', resetDefault: '恢复默认', fallback: '回退', + modelFree: '免费', shownTotal: '显示 {shown} / 共 {total}', noModelsYet: '暂无模型', noModelsMatch: '没有匹配的模型。', diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 812391b..8699ade 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -25,6 +25,20 @@ export function formatCountKind(value: number, kind: 'int' | 'compact' | 'percen return String(Math.round(value)) } +/** Official WorkBuddy catalog credits text, when present. */ +export function modelCreditsText(model: { credits?: string | null }) { + const credits = (model.credits || '').trim() + return credits || '' +} + +export function modelIsFree(model: { free?: boolean | null; credits?: string | null }) { + if (model.free) return true + const credits = modelCreditsText(model).toLowerCase() + if (!credits) return false + const match = credits.match(/(\d+(?:\.\d+)?)/) + return Boolean(match && Number(match[1]) === 0) +} + function trimFixed(value: number) { const digits = Math.abs(value) >= 10 ? 0 : 1 return Number(value.toFixed(digits)).toString() diff --git a/frontend/src/pages/AccessPage.tsx b/frontend/src/pages/AccessPage.tsx index eb12475..128cdf2 100644 --- a/frontend/src/pages/AccessPage.tsx +++ b/frontend/src/pages/AccessPage.tsx @@ -15,6 +15,7 @@ import { useOverview } from '@/hooks/useOverview' import { fetchAccounts, fetchModels, testChat } from '@/api/overview' import type { ModelInfo, Overview } from '@/api/types' import { absUrl } from '@/lib/url' +import { modelCreditsText, modelIsFree } from '@/lib/format' import { EmptyPanel } from '@/components/ui/EmptyPanel' import { PageAlert } from '@/components/ui/PageAlert' import { AccessPageSkeleton } from '@/components/ui/PageSkeletons' @@ -302,12 +303,19 @@ export function AccessPage() { value={selectedModel} onChange={setModel} placeholder={t('model')} - options={models.map((item) => ({ - id: item.id, - textValue: `${item.display_name || item.id} ${item.id} ${item.owned_by || item.provider || ''}`, - label: item.display_name || item.id, - hint: item.provider || item.owned_by ? `${item.id} · ${item.provider || item.owned_by}` : item.id, - }))} + options={models.map((item) => { + const credits = modelCreditsText(item) + const free = modelIsFree(item) + const title = item.display_name || item.id + const badge = free ? t('modelFree') : credits + const provider = item.provider || item.owned_by || '' + return { + id: item.id, + textValue: `${title} ${item.id} ${provider} ${credits} ${free ? 'free' : ''}`, + label: badge ? `${title} ${badge}` : title, + hint: [item.id, provider, badge].filter(Boolean).join(' · '), + } + })} /> ) : (
    diff --git a/frontend/src/pages/ProvidersPage.tsx b/frontend/src/pages/ProvidersPage.tsx index d90e43c..dffd224 100644 --- a/frontend/src/pages/ProvidersPage.tsx +++ b/frontend/src/pages/ProvidersPage.tsx @@ -3,7 +3,7 @@ import { Button, Card, Chip, Input, Table, Tooltip } from '@heroui/react' import { Cube, ArrowClockwise, ArrowCounterClockwise, FloppyDisk, MagnifyingGlass, Info } from '@phosphor-icons/react' import { useI18n } from '@/hooks/useI18n' import { useOverview } from '@/hooks/useOverview' -import { fetchModelsCached, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode } from '@/api/overview' +import { fetchModelsCached, fetchProviders, refreshModels, updateModelContext, updateProviderReasoning, updateTraeMaxMode, type ProviderDescriptor } from '@/api/overview' import type { Overview } from '@/api/types' import { ProviderMark } from '@/components/ProviderMark' import { ModelDetailsModal, formatTokens } from '@/components/ModelDetailsModal' @@ -14,9 +14,28 @@ import { ListPager, type PageSize } from '@/components/ui/ListPager' import { PageAlert } from '@/components/ui/PageAlert' import { ProvidersPageSkeleton, ProvidersTableSkeleton } from '@/components/ui/PageSkeletons' import { SearchBar } from '@/components/ui/SearchBar' +import { modelCreditsText, modelIsFree } from '@/lib/format' +import { accountProviderLabel } from '@/lib/provider' type ModelInfo = NonNullable[number] +type ProviderRegionOption = { + value: string + provider: string + region: string +} + +function providerRegionOptions(descriptors: ProviderDescriptor[]): ProviderRegionOption[] { + const options: ProviderRegionOption[] = [] + for (const descriptor of descriptors) { + for (const region of descriptor.regions || []) { + if (!region?.id) continue + options.push({ value: `${descriptor.id}:${region.id}`, provider: descriptor.id, region: region.id }) + } + } + return options +} + function modelSettingsKey(model: ModelInfo) { return model.settings_key || model.id } @@ -26,8 +45,15 @@ function modelProvider(model: ModelInfo) { return String(model.provider || model.owned_by || 'qoder').trim().toLowerCase() } +function modelRegion(model: ModelInfo) { + const region = String(model.region || '').trim().toLowerCase() + if (region) return region + const regions = model.regions || [] + return String(regions[0] || '').trim().toLowerCase() +} + function modelRowKey(model: ModelInfo) { - return `${modelProvider(model)}:${model.settings_key || model.id}:${model.native_model || model.mapped_key || ''}` + return `${modelProvider(model)}:${modelRegion(model) || 'any'}:${model.settings_key || model.id}:${model.native_model || model.mapped_key || ''}` } function routedModelName(model: ModelInfo) { @@ -178,6 +204,7 @@ export function ProvidersPage() { const { overview, loading } = useOverview() const [filter, setFilter] = useState('') const [providerFilter, setProviderFilter] = useState('') + const [providerOptions, setProviderOptions] = useState([]) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(50) const [busy, setBusy] = useState(false) @@ -190,27 +217,33 @@ export function ProvidersPage() { const [modelsLoading, setModelsLoading] = useState(true) useEffect(() => { let cancelled = false - void fetchModelsCached() + void fetchModelsCached(undefined, 'regional') .then((data) => { if (!cancelled) setModels(data.data || []) }) .catch(() => undefined) .finally(() => { if (!cancelled) setModelsLoading(false) }) + void fetchProviders() + .then((result) => { + if (!cancelled) setProviderOptions(providerRegionOptions(result.data || [])) + }) + .catch(() => undefined) return () => { cancelled = true } }, []) - const providers = useMemo(() => { - const ids = new Set() - for (const model of models) ids.add(modelProvider(model)) - return [...ids].sort() - }, [models]) const filtered = useMemo(() => { const query = filter.trim().toLowerCase() + const [filterProvider, filterRegion] = providerFilter.includes(':') + ? providerFilter.split(':', 2) + : [providerFilter, ''] return models.filter((model) => { const provider = modelProvider(model) - if (providerFilter && provider !== providerFilter) return false + const region = modelRegion(model) + if (filterProvider && provider !== filterProvider) return false + if (filterRegion && region !== filterRegion) return false if (!query) return true - return `${model.display_name || ''} ${model.id} ${model.mapped_key || ''} ${model.provider || ''} ${model.owned_by || ''}`.toLowerCase().includes(query) + const providerLabel = accountProviderLabel(provider, region || undefined, t) + return `${model.display_name || ''} ${model.id} ${model.mapped_key || ''} ${provider} ${providerLabel} ${model.owned_by || ''} ${region} ${model.credits || ''} ${model.free ? 'free' : ''}`.toLowerCase().includes(query) }) - }, [filter, models, providerFilter]) + }, [filter, models, providerFilter, t]) const filterKey = [filter, providerFilter, pageSize].join('\0') const [appliedFilterKey, setAppliedFilterKey] = useState(filterKey) @@ -286,7 +319,7 @@ export function ProvidersPage() { setMessage('') setMessageError(false) try { - const data = await refreshModels() + const data = await refreshModels(undefined, 'regional') setModels(data.data || []) } catch (error) { setMessageError(true) @@ -395,7 +428,10 @@ export function ProvidersPage() { onChange={setProviderFilter} options={[ { id: '', label: t('providerFilterAll') }, - ...providers.map((provider) => ({ id: provider, label: provider })), + ...providerOptions.map((option) => ({ + id: option.value, + label: accountProviderLabel(option.provider, option.region, t), + })), ]} />