From ce6d6967e223f0153e36f44ee4f84c85b28c9148 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:20:00 +0800 Subject: [PATCH 01/58] feat(config): add independent accounts per curated provider Problem: Users of DeepSeek official and OpenCode Go needed more than one API key per vendor, but Reasonix stored one credential per provider route. Switching keys required rewriting the same provider block, and project configs could not pin an account without duplicating secrets. Root cause: Provider identity, credentials, and generated routes were a single object. There was no user-global account layer, no stable account slug, and no migration that kept existing provider names valid. Fix: - Store [[provider_accounts]] in the user-global config (config_version 8). - Expand each account into generated routes. The first account keeps legacy names (main); later accounts use --. - Keep secrets in Reasonix home .env under unique APIKeyEnv names. Project configs may only reference generated provider names. - Desktop Settings, ModelSwitcher, CLI setup, and /provider group accounts with enable, disable, default, and retire. Selection is manual; this version has no polling or failover. - OpenCode Go still uses one key for Chat, Anthropic, and Responses. - Disable hides an account from new pickers; retire keeps generated entries so existing sessions can still resolve. Compatibility: - Older configs migrate in memory to a main account and keep names such as deepseek-flash and opencode-go. - Account metadata on ProviderEntry is omitempty; Wails empty account lists encode as []. - Previous readers ignore unknown [[provider_accounts]] tables and extra account_* fields, but they will not expand additional accounts. Verification: - go test ./internal/config ./internal/i18n - go test ./internal/boot -run 'TestAccountSwitch' - go test ./internal/cli -run 'TestRunMigratesLegacyConfigBeforeConfigOnlyCommands|TestRunAppliesUserConfigUpgradesOnStartup' - cd desktop && go test . -run 'TestAddProviderPresetAccountCreatesSecondKey|TestSettingsViewEncodesEmptyProviderAccounts|TestRejectedAccountCreateDoesNotWriteKey' - cd desktop/frontend && pnpm typecheck - go vet ./internal/config ./internal/cli ./internal/boot - cd desktop && go vet . - go run ./tools/repolint - git diff --check --- desktop/app.go | 12 +- .../__tests__/provider-access-card.test.tsx | 1 + .../frontend/src/components/ModelSwitcher.tsx | 92 +++-- .../components/ProviderAccountSettings.tsx | 134 +++++++ .../frontend/src/components/SettingsPanel.tsx | 54 +-- desktop/frontend/src/lib/bridge.ts | 10 +- .../frontend/src/lib/mockProviderAccounts.ts | 47 +++ .../src/lib/providerAccessIdentity.ts | 33 ++ .../frontend/src/lib/providerAccountTypes.ts | 5 + desktop/frontend/src/lib/types.ts | 12 +- desktop/frontend/src/locales/en.ts | 11 + desktop/frontend/src/locales/zh-TW.ts | 11 + desktop/frontend/src/locales/zh.ts | 11 + .../src/test-support/settingsTestFixtures.ts | 1 + desktop/model_catalog.go | 9 +- desktop/provider_accounts.go | 332 ++++++++++++++++ desktop/provider_accounts_test.go | 85 ++++ desktop/reasoning_display_app.go | 7 +- desktop/settings_app.go | 191 +++------ docs/CLI.md | 8 +- docs/CLI.zh-CN.md | 8 +- docs/CONFIG_PATHS.md | 8 + docs/CONFIG_PATHS.zh-CN.md | 8 + docs/GUIDE.md | 8 +- docs/GUIDE.zh-CN.md | 6 +- docs/SPEC.md | 23 +- docs/SPEC.zh-CN.md | 4 +- internal/boot/provider_account_auth_test.go | 120 ++++++ internal/cli/cli_test.go | 4 +- internal/cli/provider.go | 33 +- internal/cli/setup_accounts.go | 208 ++++++++++ internal/cli/setup_manager.go | 44 +-- internal/config/billing_upgrade_test.go | 4 +- internal/config/config.go | 216 ++-------- internal/config/edit.go | 14 +- internal/config/load.go | 29 +- internal/config/load_normalize.go | 1 + internal/config/load_user_pins.go | 41 ++ internal/config/migrate_test.go | 2 +- internal/config/pricing.go | 5 + internal/config/provider_account.go | 296 ++++++++++++++ internal/config/provider_account_expand.go | 309 +++++++++++++++ internal/config/provider_account_group.go | 255 ++++++++++++ internal/config/provider_account_migrate.go | 172 ++++++++ internal/config/provider_account_mutate.go | 216 ++++++++++ internal/config/provider_account_render.go | 167 ++++++++ internal/config/provider_account_resolve.go | 242 ++++++++++++ internal/config/provider_account_test.go | 369 ++++++++++++++++++ internal/config/provider_preset_clone.go | 43 ++ internal/config/provider_presets.go | 42 +- internal/config/render.go | 108 +---- internal/config/render_test.go | 2 +- internal/i18n/i18n.go | 7 + internal/i18n/messages_en.go | 7 + internal/i18n/messages_zh.go | 7 + internal/i18n/messages_zh_tw.go | 7 + reasonix.example.toml | 10 + 57 files changed, 3480 insertions(+), 631 deletions(-) create mode 100644 desktop/frontend/src/components/ProviderAccountSettings.tsx create mode 100644 desktop/frontend/src/lib/mockProviderAccounts.ts create mode 100644 desktop/frontend/src/lib/providerAccessIdentity.ts create mode 100644 desktop/frontend/src/lib/providerAccountTypes.ts create mode 100644 desktop/provider_accounts.go create mode 100644 desktop/provider_accounts_test.go create mode 100644 internal/boot/provider_account_auth_test.go create mode 100644 internal/cli/setup_accounts.go create mode 100644 internal/config/load_user_pins.go create mode 100644 internal/config/provider_account.go create mode 100644 internal/config/provider_account_expand.go create mode 100644 internal/config/provider_account_group.go create mode 100644 internal/config/provider_account_migrate.go create mode 100644 internal/config/provider_account_mutate.go create mode 100644 internal/config/provider_account_render.go create mode 100644 internal/config/provider_account_resolve.go create mode 100644 internal/config/provider_account_test.go create mode 100644 internal/config/provider_preset_clone.go diff --git a/desktop/app.go b/desktop/app.go index b4a41aad85..7836e7280e 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -9208,10 +9208,14 @@ func removeServerOrder(order []string, name string) []string { // ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/ // model") is what SetModel takes; Provider/Model are for display. type ModelInfo struct { - Ref string `json:"ref"` - Provider string `json:"provider"` - Model string `json:"model"` - Current bool `json:"current"` + Ref string `json:"ref"` + Provider string `json:"provider"` + Model string `json:"model"` + Current bool `json:"current"` + ProviderGroup string `json:"providerGroup,omitempty"` + AccountID string `json:"accountId,omitempty"` + AccountLabel string `json:"accountLabel,omitempty"` + AccountDefault bool `json:"accountDefault,omitempty"` } type EffortInfo struct { diff --git a/desktop/frontend/src/__tests__/provider-access-card.test.tsx b/desktop/frontend/src/__tests__/provider-access-card.test.tsx index 1debb3e6c7..5c091cc608 100644 --- a/desktop/frontend/src/__tests__/provider-access-card.test.tsx +++ b/desktop/frontend/src/__tests__/provider-access-card.test.tsx @@ -138,6 +138,7 @@ function renderCard( undefined} busy={false} fetching={false} editing={null} diff --git a/desktop/frontend/src/components/ModelSwitcher.tsx b/desktop/frontend/src/components/ModelSwitcher.tsx index 1a1fffd807..d9efcbfbf8 100644 --- a/desktop/frontend/src/components/ModelSwitcher.tsx +++ b/desktop/frontend/src/components/ModelSwitcher.tsx @@ -80,37 +80,54 @@ export function ModelSwitcher({ const keyword = query.trim().toLowerCase(); const filtered = useMemo( () => keyword - ? models.filter((m) => m.model.toLowerCase().includes(keyword) || m.provider.toLowerCase().includes(keyword)) + ? models.filter((m) => m.model.toLowerCase().includes(keyword) || m.provider.toLowerCase().includes(keyword) || (m.accountLabel ?? "").toLowerCase().includes(keyword)) : models, [models, keyword], ); - // Group by provider, with the current model's group first const groups = useMemo(() => { - const map = new Map(); - let currentProvider = ""; + const map = new Map }>(); + let currentGroup = ""; for (const m of filtered) { - if (m.current) currentProvider = m.provider; - const list = map.get(m.provider); - if (list) list.push(m); - else map.set(m.provider, [m]); + const groupID = m.providerGroup || m.provider; + const groupLabel = providerLabel(groupID, t); + const accountID = m.accountId || m.provider; + const accountLabel = m.accountLabel || m.provider; + if (m.current) currentGroup = groupID; + let group = map.get(groupID); + if (!group) { + group = { label: groupLabel, accounts: new Map() }; + map.set(groupID, group); + } + let account = group.accounts.get(accountID); + if (!account) { + account = { label: accountLabel, items: [] }; + group.accounts.set(accountID, account); + } + account.items.push(m); } return [...map.entries()] .sort(([a], [b]) => { - if (a === currentProvider) return -1; - if (b === currentProvider) return 1; - return providerLabel(a, t).localeCompare(providerLabel(b, t)); + if (a === currentGroup) return -1; + if (b === currentGroup) return 1; + return (map.get(a)?.label ?? a).localeCompare(map.get(b)?.label ?? b); }) - .map(([provider, items]) => ({ - provider, - label: providerLabel(provider, t), - items, + .map(([id, group]) => ({ + id, + label: group.label, + accounts: [...group.accounts.entries()].map(([accountId, account]) => ({ + id: accountId, + label: account.label, + items: account.items, + })), })); }, [filtered, t]); const currentProvider = useMemo(() => { const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label); - return cur ? providerLabel(cur.provider, t) : null; + if (!cur) return null; + const group = providerLabel(cur.providerGroup || cur.provider, t); + return cur.accountLabel ? `${group} · ${cur.accountLabel}` : group; }, [label, models, t]); const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label; @@ -202,22 +219,29 @@ export function ModelSwitcher({ {models.length === 0 &&
{t("status.noModels")}
} {models.length > 0 && filtered.length === 0 && query &&
{t("modelSwitcher.noMatches")}
} {groups.map((g) => ( -
+
{g.label}
- {g.items.map((m) => ( - + {g.accounts.map((account) => ( +
+ {account.label && account.label !== g.label && ( +
{account.label}
+ )} + {account.items.map((m) => ( + + ))} +
))}
))} @@ -232,6 +256,10 @@ export function normalizeModelInfo(model: ModelInfo): ModelInfo { ...model, provider: String(model.provider ?? ""), model: String(model.model ?? ""), + providerGroup: String(model.providerGroup ?? ""), + accountId: String(model.accountId ?? ""), + accountLabel: String(model.accountLabel ?? ""), + accountDefault: Boolean(model.accountDefault), }; } @@ -241,6 +269,8 @@ function providerLabel(provider: string, t: ReturnType): string { case "deepseek-flash": case "deepseek-pro": return t("settings.providerLabel.deepseek"); + case "opencode-go": + return t("settings.providerLabel.opencodeGo"); default: return provider; } diff --git a/desktop/frontend/src/components/ProviderAccountSettings.tsx b/desktop/frontend/src/components/ProviderAccountSettings.tsx new file mode 100644 index 0000000000..c8ddcf62be --- /dev/null +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { asArray } from "../lib/array"; +import { app } from "../lib/bridge"; +import { useT } from "../lib/i18n"; +import type { ProviderAccountView as AccountView } from "../lib/types"; + +export function normalizeProviderAccountView(p: AccountView): AccountView { + return { + ...p, + providerId: String(p.providerId ?? ""), + accountId: String(p.accountId ?? ""), + label: String(p.label ?? ""), + apiKeyEnv: String(p.apiKeyEnv ?? ""), + enabled: p.enabled !== false, + default: Boolean(p.default), + keySet: Boolean(p.keySet), + providerNames: asArray(p.providerNames), + }; +} + +export function accountsForProviderGroup(group: { id: string; providers: { providerId?: string }[] }, accounts: AccountView[]): AccountView[] { + const ids = new Set(group.providers.map((p) => p.providerId).filter(Boolean) as string[]); + if (group.id === "builtin:deepseek") ids.add("deepseek"); + if (group.id === "custom:opencode-go") ids.add("opencode-go"); + return accounts.filter((account) => ids.has(account.providerId) && !account.retired); +} + +export function addAccountPresetID(group: { id: string }): string { + if (group.id === "builtin:deepseek" || group.id.endsWith(":deepseek")) return "deepseek-anthropic"; + if (group.id === "custom:opencode-go") return "opencode-go-recommended"; + return ""; +} + +export function ProviderAccountManager({ + group, + accounts, + busy, + apply, +}: { + group: { id: string }; + accounts: AccountView[]; + busy: boolean; + apply: (fn: () => Promise) => Promise; +}) { + const t = useT(); + const [adding, setAdding] = useState(false); + const [label, setLabel] = useState(""); + const [key, setKey] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameLabel, setRenameLabel] = useState(""); + const presetID = addAccountPresetID(group); + if (accounts.length === 0 && !presetID) return null; + + return ( +
+
{t("settings.providerAccounts")}
+ {accounts.map((account) => ( +
+ {renaming === account.accountId ? ( + <> + setRenameLabel(e.target.value)} + aria-label={t("settings.accountLabel")} + /> + + + + ) : ( + <> + {account.label} + {account.default ? {t("settings.accountDefault")} : null} + {account.enabled ? t("settings.accountEnabled") : t("settings.accountDisabled")} + {account.keySet ? t("settings.keySet") : t("settings.noKey")} + + + + + + )} +
+ ))} + {presetID && adding && ( +
+ setLabel(e.target.value)} placeholder={t("settings.accountLabel")} /> + setKey(e.target.value)} placeholder={t("settings.accountApiKey")} /> + + +
+ )} + {presetID && !adding && ( + + )} +
+ ); +} + diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index f24cc36e53..451a33a7e3 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -66,7 +66,9 @@ import { shortcutDefinitions, type ShortcutAction, } from "../lib/keyboardShortcuts"; -import type { BotAccessView, BotAllowlistView, BotConnectionDiagnostic, BotConnectionView, BotInstallStartResult, BotRouteView, BotSettingsView, HookConfigView, HooksSettingsView, NetworkView, ProviderModelCatalogUpdate, ProviderPresetView, ProviderView, SettingsTab, SettingsView } from "../lib/types"; +import type { BotAccessView, BotAllowlistView, BotConnectionDiagnostic, BotConnectionView, BotInstallStartResult, BotRouteView, BotSettingsView, HookConfigView, HooksSettingsView, NetworkView, ProviderAccountView, ProviderModelCatalogUpdate, ProviderPresetView, ProviderView, SettingsTab, SettingsView } from "../lib/types"; +import { ProviderAccountManager, accountsForProviderGroup, normalizeProviderAccountView } from "./ProviderAccountSettings"; +import { canonicalOfficialProviderName, providerGroupID } from "../lib/providerAccessIdentity"; import { AppearanceOverview } from "./AppearanceOverview"; import { applyConfiguredBaseAppearance, setBaseAppearance } from "../lib/themePack"; import { InlineConfirmButton } from "./InlineConfirmButton"; @@ -1395,6 +1397,11 @@ export function normalizeProviderView(p: ProviderView): ProviderView { keySource: p.keySource ?? "", keySourcePath: p.keySourcePath ?? "", modelCatalogFingerprint: p.modelCatalogFingerprint ?? "", + providerId: String(p.providerId ?? ""), + accountId: String(p.accountId ?? ""), + accountLabel: String(p.accountLabel ?? ""), + accountEnabled: p.accountEnabled !== false, + accountDefault: Boolean(p.accountDefault), }; } @@ -1426,6 +1433,9 @@ function normalizeProviderPresetView(p: ProviderPresetView): ProviderPresetView configured, keySource: p.keySource ?? "", keySourcePath: p.keySourcePath ?? "", + accountGroupId: String(p.accountGroupId ?? ""), + accounts: asArray(p.accounts).map(normalizeProviderAccountView), + canAddAccount: Boolean(p.canAddAccount), }; } @@ -1454,6 +1464,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV providers: asArray(view.providers).map(normalizeProviderView), officialProviders: asArray(view.officialProviders).map(normalizeProviderView), providerPresets: asArray(view.providerPresets).map(normalizeProviderPresetView).filter((p) => p.id), + providerAccounts: asArray(view.providerAccounts).map(normalizeProviderAccountView), providerKinds: asArray(view.providerKinds), permissions: { ...permissions, @@ -5257,6 +5268,8 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { Promise) => Promise; busy: boolean; fetching: boolean; fetchResult?: ProviderFetchResult; @@ -5954,6 +5971,7 @@ export function ProviderAccessCard({ ); return (
+
@@ -6489,32 +6507,13 @@ export function providerVisionCapabilityForView( return "configurable"; } -function canonicalOfficialProviderName(name: string): string { - switch (name.trim()) { - case "deepseek-flash": - case "deepseek-pro": - return "deepseek"; - default: - return name.trim(); - } -} - function officialProviderKind(p: ProviderView): string { if (!p.builtIn) return ""; - const name = canonicalOfficialProviderName(p.name); const host = providerBaseHost(p.baseUrl); - if (name === "deepseek" && host === "api.deepseek.com") return "deepseek"; + if ((p.name === "deepseek" || p.name === "deepseek-flash" || p.name === "deepseek-pro") && host === "api.deepseek.com") return "deepseek"; return ""; } -function providerGroupID(p: ProviderView): string { - const official = officialProviderKind(p); - if (official) return `builtin:${official}`; - if (isOpenCodeGoProviderName(p.name)) return "custom:opencode-go"; - if (p.name === "opencode-zen-anthropic") return "custom:opencode-zen"; - return `custom:${p.name}`; -} - function providerGroupLabel(p: ProviderView, t?: ReturnType): string { const id = providerGroupID(p); if (id === "builtin:deepseek") return t ? t("settings.providerLabel.deepseek") : "DeepSeek"; @@ -6533,19 +6532,6 @@ function providerGroupDescription(p: ProviderView, t: ReturnType): return ""; } -function isOpenCodeGoProviderName(name: string): boolean { - switch (name.trim()) { - case "opencode-go": - case "opencode-go-anthropic": - case "opencode-go-responses": - case "opencode-go-deepseek-anthropic": - case "opencode-go-deepseek-responses": - return true; - default: - return false; - } -} - function uniqueStrings(values: string[]): string[] { const seen = new Set(); const out: string[] = []; diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index edbf93df1f..47c6419184 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -2,7 +2,7 @@ // @ts-ignore generated locally; fresh checkouts use the disabled drift check below. import type * as GeneratedApp from "../../wailsjs/go/main/App"; import type { InvocationRequest } from "./invocationDisplay"; -import { addBreadcrumb } from "./breadcrumbs"; +import { addBreadcrumb } from "./breadcrumbs"; import { mockProviderAccountBindings, type ProviderAccountBindings } from "./mockProviderAccounts"; import { maybeShare } from "./queryCoalesce"; import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge"; import { makeMockHistoryCatalogBindings, type HistoryCatalogBindings } from "./historyCatalogBridge"; @@ -192,7 +192,7 @@ interface DesktopWindowState { } // AppBindings is the hand-written React-to-Go contract. _CheckGeneratedBindings // catches generated methods missing here; update this interface and typecheck. -export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings { +export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings, ProviderAccountBindings { Platform(): Promise; MinimiseMainWindow(): Promise; ToggleMaximiseMainWindow(): Promise; @@ -1091,7 +1091,7 @@ function bridgeBreadcrumb(method: string): string { return `model ${method}`; if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetReasoningDisplayMode|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetCompactRatio|SetReasoningLanguage)/.test(method)) return `settings ${method}`; - if (/^(SaveProvider|SetProviderWebSearch|SaveProviderModelCatalogs|AddOfficialProviderAccess|UpgradeDeepSeekProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|RemoveProviderAccesses|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|FetchAllProviderModels|ConnectKey)/.test(method)) + if (/^(SaveProvider|SetProviderWebSearch|SaveProviderModelCatalogs|AddOfficialProviderAccess|UpgradeDeepSeekProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|RemoveProviderAccesses|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|FetchAllProviderModels|ConnectKey|AddProviderPresetAccount|SetProviderAccountDefault|SetProviderAccountEnabled|RetireProviderAccount|RenameProviderAccount|SetProviderAccountKey|ClearProviderAccountKey)/.test(method)) return `provider ${method}`; if (/^(CheckUpdate|ApplyUpdateRequest|OpenDownloadPage|OpenUserConfigPath|ReloadUserConfig)/.test(method)) return `update ${method}`; if (/^(AddMCPServer|InstallMCPServer|UpdateMCPServer|RemoveMCPServer|AuthorizeAndConnectMCPServer|AuthenticateMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|SetMCPServer)/.test(method)) @@ -1719,7 +1719,7 @@ function makeMockApp(): AppBindings { officialProviders: [ { name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" }, ], - providerPresets: mockProviderPresetViews(), + providerPresets: mockProviderPresetViews(), providerAccounts: [], permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] }, sandbox: { bash: browserPreviewBashSandboxMode(), network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto", effectiveShell: browserPreviewEffectiveShell("auto"), resolvedShell: browserPreviewEffectiveShell("auto"), shellReloadRequired: false, ...browserPreviewShellSupport(browserPlatformOverride()) }, network: { @@ -4630,7 +4630,7 @@ function makeMockApp(): AppBindings { preset.keySet = preset.keySet || !!key.trim(); preset.configured = !preset.requiresKey || preset.keySet; return ""; - }, + }, ...mockProviderAccountBindings(settings), async ResetProviderPresetAccess(id: string) { const preset = settings.providerPresets.find((p) => p.id === id); if (!preset) throw new Error(`unknown provider preset ${id}`); diff --git a/desktop/frontend/src/lib/mockProviderAccounts.ts b/desktop/frontend/src/lib/mockProviderAccounts.ts new file mode 100644 index 0000000000..d302703d15 --- /dev/null +++ b/desktop/frontend/src/lib/mockProviderAccounts.ts @@ -0,0 +1,47 @@ +import { asArray } from "./array"; +import type { ProviderAccountView, SettingsView } from "./types"; + +export type ProviderAccountBindings = { + AddProviderPresetAccount(presetID: string, label: string, key: string): Promise; + SetProviderAccountDefault(providerID: string, accountID: string): Promise; + SetProviderAccountEnabled(providerID: string, accountID: string, enabled: boolean): Promise; + RetireProviderAccount(providerID: string, accountID: string): Promise; + RenameProviderAccount(providerID: string, accountID: string, label: string): Promise; + SetProviderAccountKey(providerID: string, accountID: string, value: string): Promise; + ClearProviderAccountKey(providerID: string, accountID: string): Promise; +}; + +export function mockProviderAccountBindings(settings: SettingsView) { + return { + async AddProviderPresetAccount(presetID: string, label: string, key: string) { + const group = settings.providerPresets.find((p) => p.id === presetID)?.accountGroupId || presetID; + const accountId = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-") || "team"; + settings.providerAccounts = asArray(settings.providerAccounts); + settings.providerAccounts.push({ + providerId: group, accountId, label, + apiKeyEnv: `${group.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY_${accountId.toUpperCase()}`, + enabled: true, default: settings.providerAccounts.every((a) => a.providerId !== group), + keySet: Boolean(key.trim()), providerNames: [], + }); + return ""; + }, + async SetProviderAccountDefault(providerID: string, accountID: string) { + settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID ? { ...a, default: a.accountId === accountID } : a); + }, + async SetProviderAccountEnabled(providerID: string, accountID: string, enabled: boolean) { + settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, enabled } : a); + }, + async RetireProviderAccount(providerID: string, accountID: string) { + settings.providerAccounts = asArray(settings.providerAccounts).filter((a) => !(a.providerId === providerID && a.accountId === accountID)); + }, + async RenameProviderAccount(providerID: string, accountID: string, label: string) { + settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, label } : a); + }, + async SetProviderAccountKey() { return ""; }, + async ClearProviderAccountKey() {}, + }; +} + +function mapAccounts(settings: SettingsView, fn: (account: ProviderAccountView) => ProviderAccountView) { + return asArray(settings.providerAccounts).map(fn); +} diff --git a/desktop/frontend/src/lib/providerAccessIdentity.ts b/desktop/frontend/src/lib/providerAccessIdentity.ts new file mode 100644 index 0000000000..ab29911f70 --- /dev/null +++ b/desktop/frontend/src/lib/providerAccessIdentity.ts @@ -0,0 +1,33 @@ +import type { ProviderView } from "./types"; + +export function isOpenCodeGoProviderName(name: string): boolean { + const base = name.trim().split("--")[0]; + switch (base) { + case "opencode-go": + case "opencode-go-anthropic": + case "opencode-go-responses": + case "opencode-go-deepseek-anthropic": + case "opencode-go-deepseek-responses": + return true; + default: + return false; + } +} + +export function canonicalOfficialProviderName(name: string): string { + switch (name.trim()) { + case "deepseek-flash": + case "deepseek-pro": + return "deepseek"; + default: + return name.trim(); + } +} + +export function providerGroupID(p: ProviderView): string { + if (p.providerId === "deepseek") return "builtin:deepseek"; + if (p.providerId === "opencode-go" || isOpenCodeGoProviderName(p.name)) return "custom:opencode-go"; + if (p.providerId) return `family:${p.providerId}`; + if (p.name === "opencode-zen-anthropic") return "custom:opencode-zen"; + return `custom:${p.name}`; +} diff --git a/desktop/frontend/src/lib/providerAccountTypes.ts b/desktop/frontend/src/lib/providerAccountTypes.ts new file mode 100644 index 0000000000..61ef8df0a4 --- /dev/null +++ b/desktop/frontend/src/lib/providerAccountTypes.ts @@ -0,0 +1,5 @@ +export interface ProviderAccountView { + providerId: string; accountId: string; presetId?: string; label: string; apiKeyEnv: string; + enabled: boolean; default: boolean; retired?: boolean; keySet: boolean; + keySource?: string; keySourcePath?: string; providerNames: string[]; +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 951a9cf876..ec6a5ca210 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -5,8 +5,8 @@ import type { Todo } from "./tools"; import type { ContextBudgetInfo, ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes"; import type { WireApproval } from "./approvalTypes"; import type { RemoteProjectNodeFields, RemoteSessionMetaFields, RemoteTabMetaFields } from "./remoteTypes"; -import type { PinnedFileInfo } from "./pinnedContextBridge"; -export * from "./remoteTypes"; +import type { PinnedFileInfo } from "./pinnedContextBridge"; import type { ProviderAccountView } from "./providerAccountTypes"; +export * from "./remoteTypes"; export * from "./providerAccountTypes"; export type { ContextBudgetInfo, ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes"; export type { ProjectGroupsSnapshot, ProjectRuntimeTopic, ProjectTopicKey, ProjectTopicPage, ProjectTopicPageRequest, ProjectTreeChangedV2, ProjectTreeOrganizationBindings, ProjectTreeRuntimeSnapshot, ProjectTreeSnapshot, SessionCatalogBindings, SessionCatalogStatus, SessionGroup, SessionReference } from "./sessionCatalogTypes"; export type EventKind = @@ -1423,7 +1423,7 @@ export interface ModelInfo { ref: string; // "provider/model" — pass to SetModel provider: string; model: string; - current: boolean; + current: boolean; providerGroup?: string; accountId?: string; accountLabel?: string; accountDefault?: boolean; } export interface EffortInfo { @@ -1715,7 +1715,7 @@ export interface ProviderView { defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0] modelOverrides?: ProviderModelOverrideView[] | null; recommendedUpgradeAvailable?: boolean; // official legacy OpenAI entry can switch to recommended Anthropic access - modelCatalogFingerprint?: string; // opaque compare-and-apply token for background model discovery + modelCatalogFingerprint?: string; providerId?: string; accountId?: string; accountLabel?: string; accountEnabled?: boolean; accountDefault?: boolean; // catalog fingerprint is opaque compare-and-apply token } export interface ProviderModelCatalogUpdate { @@ -1749,7 +1749,7 @@ export interface ProviderPresetView { requiresKey?: boolean; configured?: boolean; keySource?: string; - keySourcePath?: string; + keySourcePath?: string; accountGroupId?: string; accounts?: ProviderAccountView[]; canAddAccount?: boolean; } export interface ProviderModelOverrideView { @@ -2191,7 +2191,7 @@ export interface SettingsView { autoPlan: string; providers: ProviderView[]; officialProviders: ProviderView[]; - providerPresets: ProviderPresetView[]; + providerPresets: ProviderPresetView[]; providerAccounts: ProviderAccountView[]; permissions: PermissionsView; sandbox: SandboxView; network: NetworkView; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 8df7c0f635..74884bf37f 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2014,6 +2014,17 @@ export const en = { "settings.deleteProvider": "Delete provider", "settings.confirmDeleteProvider": "Confirm delete provider", "settings.addProvider": "+ Add provider", + "settings.providerAccounts": "Accounts", + "settings.addAccount": "+ Add account", + "settings.accountLabel": "Account label", + "settings.accountApiKey": "API key", + "settings.accountDefault": "default", + "settings.accountEnabled": "enabled", + "settings.accountDisabled": "disabled", + "settings.accountSetDefault": "Set default", + "settings.accountEnable": "Enable", + "settings.accountDisable": "Disable", + "settings.accountRetire": "Retire", "settings.addProviderAccess": "Add access", "settings.removeProviderAccess": "Remove access", "settings.confirmRemoveProviderAccess": "Confirm remove access", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index f178f9655e..7258416ed1 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1552,6 +1552,17 @@ export const zhTW: Record = { "settings.deleteProvider": "刪除模型服務", "settings.confirmDeleteProvider": "確認刪除模型服務", "settings.addProvider": "+ 新增模型服務", + "settings.providerAccounts": "帳號", + "settings.addAccount": "+ 新增帳號", + "settings.accountLabel": "帳號名稱", + "settings.accountApiKey": "API Key", + "settings.accountDefault": "預設", + "settings.accountEnabled": "已啟用", + "settings.accountDisabled": "已停用", + "settings.accountSetDefault": "設為預設", + "settings.accountEnable": "啟用", + "settings.accountDisable": "停用", + "settings.accountRetire": "退休", "settings.addProviderAccess": "新增接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "確認移除接入", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index a2588ad135..e1b497489b 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2016,6 +2016,17 @@ export const zh: Record = { "settings.deleteProvider": "删除模型服务", "settings.confirmDeleteProvider": "确认删除模型服务", "settings.addProvider": "+ 添加模型服务", + "settings.providerAccounts": "账号", + "settings.addAccount": "+ 添加账号", + "settings.accountLabel": "账号名称", + "settings.accountApiKey": "API Key", + "settings.accountDefault": "默认", + "settings.accountEnabled": "已启用", + "settings.accountDisabled": "已停用", + "settings.accountSetDefault": "设为默认", + "settings.accountEnable": "启用", + "settings.accountDisable": "停用", + "settings.accountRetire": "退休", "settings.addProviderAccess": "添加接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "确认移除接入", diff --git a/desktop/frontend/src/test-support/settingsTestFixtures.ts b/desktop/frontend/src/test-support/settingsTestFixtures.ts index 744be10650..ae247c9f96 100644 --- a/desktop/frontend/src/test-support/settingsTestFixtures.ts +++ b/desktop/frontend/src/test-support/settingsTestFixtures.ts @@ -41,6 +41,7 @@ export function baseSettings(displayMode: "standard" | "compact" = "standard"): providers: [], officialProviders: [], providerPresets: [], + providerAccounts: [], permissions: { mode: "ask", allow: [], ask: [], deny: [] }, sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto", shellCapabilities: [{ id: "bash", variant: "system", available: true, path: "/bin/bash", source: "path" }] }, network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } }, diff --git a/desktop/model_catalog.go b/desktop/model_catalog.go index 83482914de..ac3c6a5df8 100644 --- a/desktop/model_catalog.go +++ b/desktop/model_catalog.go @@ -110,9 +110,16 @@ func (a *App) desktopModelCatalog(curModel, workspaceRoot string, ctrl control.S if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() { continue } + if !cfg.AccountEnabled(p.AccountProviderID, p.AccountID) && strings.TrimSpace(p.AccountID) != "" && !strings.HasPrefix(curModel, p.Name+"/") { + continue + } + account, _ := config.ProviderAccountForEntry(cfg, *p) for _, m := range p.ChatModelList() { ref := p.Name + "/" + m - out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel}) + out = append(out, ModelInfo{ + Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel, + ProviderGroup: account.ProviderID, AccountID: account.ID, AccountLabel: account.Label, AccountDefault: account.Default, + }) } } return mergeExtensionModelInfos(out, extensionCatalog, curModel) diff --git a/desktop/provider_accounts.go b/desktop/provider_accounts.go new file mode 100644 index 0000000000..a795126a8b --- /dev/null +++ b/desktop/provider_accounts.go @@ -0,0 +1,332 @@ +package main + +import ( + "fmt" + "strings" + + "reasonix/internal/config" +) + +type ProviderAccountView struct { + ProviderID string `json:"providerId"` + AccountID string `json:"accountId"` + PresetID string `json:"presetId,omitempty"` + Label string `json:"label"` + APIKeyEnv string `json:"apiKeyEnv"` + Enabled bool `json:"enabled"` + Default bool `json:"default"` + Retired bool `json:"retired,omitempty"` + KeySet bool `json:"keySet"` + KeySource string `json:"keySource,omitempty"` + KeySourcePath string `json:"keySourcePath,omitempty"` + ProviderNames []string `json:"providerNames"` +} + +func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView { + if resolver == nil { + resolver = config.NewCredentialResolverForRoot(root) + } + presets := config.CuratedProviderPresets() + out := make([]ProviderPresetView, 0, len(presets)) + for _, preset := range presets { + keyEnv := strings.TrimSpace(preset.KeyEnv) + names := make([]string, 0, len(preset.Entries)) + models := make([]string, 0) + modelSeen := map[string]bool{} + requiresKey := false + for _, entry := range preset.Entries { + if keyEnv == "" { + keyEnv = strings.TrimSpace(entry.APIKeyEnv) + } + if entry.RequiresAPIKey() { + requiresKey = true + } + name := strings.TrimSpace(entry.Name) + if name != "" { + names = append(names, name) + } + for _, model := range chatProviderModels(entry.ChatModelList()) { + if modelSeen[model] { + continue + } + modelSeen[model] = true + models = append(models, model) + } + } + key := config.CredentialResolution{} + if keyEnv != "" { + key = resolver.ResolveGlobalFirst(keyEnv) + } + status, statusNames, missingNames := classifyProviderPresetStatus(cfg, preset) + added := status == providerPresetStatusInstalled || status == providerPresetStatusInstalledModified || status == providerPresetStatusNameConflict + out = append(out, ProviderPresetView{ + ID: preset.ID, Label: preset.Label, Description: preset.Description, KeyEnv: keyEnv, + Recommended: preset.Recommended, BillingMode: preset.BillingMode, DisplayGroup: preset.DisplayGroup, + DisplaySection: preset.DisplaySection, DisplayTier: preset.DisplayTier, RouteKind: preset.RouteKind, + Optional: preset.Optional, DisplayOrder: preset.DisplayOrder, ProviderNames: nonNil(names), + Models: nonNil(models), Added: added, Status: status, StatusProviderNames: nonNil(statusNames), + MissingProviderNames: nonNil(missingNames), KeySet: key.Set, RequiresKey: requiresKey, + Configured: !requiresKey || key.Set, KeySource: key.Source.Label, KeySourcePath: key.Source.Path, + AccountGroupID: preset.AccountGroupID, Accounts: accountViewsForGroup(cfg, preset.AccountGroupID, root, resolver), CanAddAccount: true, + }) + } + return out +} + +func accountViewsForGroup(cfg *config.Config, groupID, root string, resolver *config.CredentialResolver) []ProviderAccountView { + groupID = strings.TrimSpace(groupID) + out := make([]ProviderAccountView, 0) + for _, view := range providerAccountViewsForRoot(cfg, root, resolver) { + if view.ProviderID == groupID { + out = append(out, view) + } + } + return out +} + +func providerAccountViewsForRoot(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderAccountView { + out := make([]ProviderAccountView, 0) + if cfg == nil { + return out + } + if resolver == nil { + resolver = config.NewCredentialResolverForRoot(root) + } + for _, account := range cfg.ProviderAccounts { + if account.Retired { + continue + } + view := ProviderAccountView{ + ProviderID: account.ProviderID, + AccountID: account.ID, + PresetID: account.PresetID, + Label: account.Label, + APIKeyEnv: account.APIKeyEnv, + Enabled: account.IsEnabled(), + Default: account.Default, + ProviderNames: []string{}, + } + if env := strings.TrimSpace(account.APIKeyEnv); env != "" { + key := resolver.ResolveGlobalFirst(env) + view.KeySet = key.Set + view.KeySource = key.Source.Label + view.KeySourcePath = key.Source.Path + } + if entries, ok := cfg.ResolveAccountProvider(account.ProviderID, account.ID); ok { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + } + view.ProviderNames = nonNil(names) + } + out = append(out, view) + } + return out +} + +func applyAccountMetadataToProviderView(view *ProviderView, p config.ProviderEntry, cfg *config.Config) { + if view == nil { + return + } + view.ProviderID = p.AccountProviderID + view.AccountID = p.AccountID + view.AccountLabel = p.AccountLabel + if account, ok := config.ProviderAccountForEntry(cfg, p); ok { + view.ProviderID = account.ProviderID + view.AccountID = account.ID + view.AccountLabel = account.Label + view.AccountEnabled = account.IsEnabled() + view.AccountDefault = account.Default + } +} + +func (a *App) AddProviderPresetAccount(presetID, label, key string) (string, error) { + preset, ok := config.CuratedProviderPreset(presetID) + if !ok { + return "", fmt.Errorf("unknown provider preset %q", presetID) + } + groupID := preset.AccountGroupID + if groupID == "" { + groupID = preset.ID + } + if err := a.ensureActiveTabRebuildAllowed("provider account"); err != nil { + return "", err + } + keyWarning := "" + var created config.ProviderAccount + rebuildWarning, err := a.applyConfigChangeWithWarning("provider account", func(c *config.Config) error { + env := "" + account, err := c.AddProviderAccount(groupID, preset.ID, label, env) + if err != nil { + return err + } + created = account + if strings.TrimSpace(key) != "" && strings.TrimSpace(account.APIKeyEnv) != "" { + keyWarning, err = a.saveProviderCredential(account.APIKeyEnv, key) + if err != nil { + return err + } + } + entries, _ := c.ResolveAccountProvider(account.ProviderID, account.ID) + addProviderAccess(c, providerEntryNames(entries)...) + return nil + }) + if err != nil { + if strings.TrimSpace(created.APIKeyEnv) != "" && strings.TrimSpace(key) != "" { + _ = config.RemoveCredential(created.APIKeyEnv) + } + return "", err + } + return appendSettingsWarning(keyWarning, rebuildWarning), nil +} + +func (a *App) SetProviderAccountDefault(providerID, accountID string) error { + return a.applyConfigChange(func(c *config.Config) error { + return c.SetProviderAccountDefault(providerID, accountID) + }) +} + +func (a *App) SetProviderAccountEnabled(providerID, accountID string, enabled bool) error { + return a.applyConfigChange(func(c *config.Config) error { + return c.SetProviderAccountEnabled(providerID, accountID, enabled) + }) +} + +func (a *App) RenameProviderAccount(providerID, accountID, label string) error { + return a.applyConfigChange(func(c *config.Config) error { + return c.RenameProviderAccount(providerID, accountID, label) + }) +} + +func (a *App) RetireProviderAccount(providerID, accountID string) error { + if refs := a.providerAccountLiveRefs(providerID, accountID); len(refs) > 0 { + return fmt.Errorf("cannot retire account %s/%s while it is referenced by %s", providerID, accountID, strings.Join(refs, ", ")) + } + return a.applyConfigChange(func(c *config.Config) error { + if err := c.RetireProviderAccount(providerID, accountID); err != nil { + return err + } + _, account, ok := accountByID(c, providerID, accountID) + if ok && strings.TrimSpace(account.APIKeyEnv) != "" && !accountKeyEnvShared(c, account) { + if err := config.RemoveCredential(account.APIKeyEnv); err != nil { + return err + } + } + return nil + }) +} + +func (a *App) SetProviderAccountKey(providerID, accountID, value string) (string, error) { + cfg, _, err := a.loadDesktopUserConfigForView() + if err != nil { + return "", err + } + _, account, ok := accountByID(cfg, providerID, accountID) + if !ok { + return "", fmt.Errorf("set account key: no account %s/%s", providerID, accountID) + } + if strings.TrimSpace(account.APIKeyEnv) == "" { + return "", fmt.Errorf("set account key: account %s/%s has no api_key_env", providerID, accountID) + } + return a.SetProviderKey(account.APIKeyEnv, value) +} + +func (a *App) ClearProviderAccountKey(providerID, accountID string) error { + cfg, _, err := a.loadDesktopUserConfigForView() + if err != nil { + return err + } + _, account, ok := accountByID(cfg, providerID, accountID) + if !ok { + return fmt.Errorf("clear account key: no account %s/%s", providerID, accountID) + } + return a.ClearProviderKey(account.APIKeyEnv) +} + +func (a *App) providerAccountLiveRefs(providerID, accountID string) []string { + cfg, _, err := a.loadDesktopUserConfigForView() + if err != nil { + return nil + } + refs := cfg.ProviderAccountConfigRefs(providerID, accountID) + entries, ok := cfg.ResolveAccountProvider(providerID, accountID) + if !ok { + return refs + } + names := map[string]bool{} + for _, e := range entries { + names[e.Name] = true + } + a.mu.RLock() + defer a.mu.RUnlock() + for _, tab := range a.tabs { + if tab == nil { + continue + } + entry, found := cfg.ResolveModel(tab.model) + if found && names[entry.Name] { + refs = append(refs, "tab:"+tab.ID) + } + } + for _, tab := range a.detachedSessions { + if tab == nil { + continue + } + entry, found := cfg.ResolveModel(tab.model) + if found && names[entry.Name] { + refs = append(refs, "background:"+tab.ID) + } + } + return refs +} + +func accountKeyEnvShared(c *config.Config, account config.ProviderAccount) bool { + if c == nil { + return false + } + env := strings.TrimSpace(account.APIKeyEnv) + if env == "" { + return false + } + for _, other := range c.ProviderAccounts { + if other.ProviderID == account.ProviderID && other.ID == account.ID { + continue + } + if !other.Retired && strings.TrimSpace(other.APIKeyEnv) == env { + return true + } + } + return false +} + +func accountByID(c *config.Config, providerID, accountID string) (int, config.ProviderAccount, bool) { + if c == nil { + return -1, config.ProviderAccount{}, false + } + for i, account := range c.ProviderAccounts { + if account.ProviderID == providerID && account.ID == accountID { + return i, account, true + } + } + return -1, config.ProviderAccount{}, false +} + +func providerEntryNames(entries []config.ProviderEntry) []string { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if name := strings.TrimSpace(e.Name); name != "" { + names = append(names, name) + } + } + return names +} + +func providerThinkingForSettings(thinking string) string { + normalized := strings.ToLower(strings.TrimSpace(thinking)) + switch normalized { + case "enabled", "disabled", "adaptive": + return normalized + default: + return "" + } +} diff --git a/desktop/provider_accounts_test.go b/desktop/provider_accounts_test.go new file mode 100644 index 0000000000..7d3f21f479 --- /dev/null +++ b/desktop/provider_accounts_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "reasonix/internal/config" +) + +func TestAddProviderPresetAccountCreatesSecondKey(t *testing.T) { + isolateDesktopUserDirs(t) + app := NewApp() + if _, err := app.AddProviderPresetAccess("opencode-go-recommended", "sk-main"); err != nil { + t.Fatalf("install main: %v", err) + } + if _, err := app.AddProviderPresetAccount("opencode-go-recommended", "团队账号", "sk-team"); err != nil { + t.Fatalf("add team account: %v", err) + } + cfg := config.LoadForEdit(config.UserConfigPath()) + var mainEnv, teamEnv string + for _, account := range cfg.ProviderAccounts { + if account.ProviderID != "opencode-go" { + continue + } + switch account.ID { + case config.MainProviderAccountID: + mainEnv = account.APIKeyEnv + case "team": + teamEnv = account.APIKeyEnv + } + } + if mainEnv == "" || teamEnv == "" || mainEnv == teamEnv { + t.Fatalf("account envs = %q %q", mainEnv, teamEnv) + } + data, err := os.ReadFile(config.UserCredentialsPath()) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, mainEnv+"=sk-main") || !strings.Contains(text, teamEnv+"=sk-team") { + t.Fatalf("credentials missing both keys:\n%s", text) + } + if _, ok := cfg.Provider("opencode-go--team"); !ok { + t.Fatalf("missing team provider, have %v", providerNames(cfg)) + } +} + +func TestSettingsViewEncodesEmptyProviderAccounts(t *testing.T) { + isolateDesktopUserDirs(t) + view := NewApp().defaultSettingsView() + if view.ProviderAccounts == nil { + t.Fatal("ProviderAccounts is nil") + } + raw, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"providerAccounts":[]`) { + t.Fatalf("providerAccounts not encoded as []: %s", raw) + } +} + +func TestRejectedAccountCreateDoesNotWriteKey(t *testing.T) { + isolateDesktopUserDirs(t) + app := NewApp() + if _, err := app.AddProviderPresetAccount("not-a-preset", "团队", "sk-should-not-save"); err == nil { + t.Fatal("expected unknown preset error") + } + if _, err := os.Stat(config.UserCredentialsPath()); err == nil { + data, _ := os.ReadFile(config.UserCredentialsPath()) + if strings.Contains(string(data), "sk-should-not-save") { + t.Fatalf("rejected create wrote key: %s", data) + } + } +} + +func providerNames(cfg *config.Config) []string { + names := make([]string, 0, len(cfg.Providers)) + for _, p := range cfg.Providers { + names = append(names, p.Name) + } + return names +} diff --git a/desktop/reasoning_display_app.go b/desktop/reasoning_display_app.go index 721975983c..b2d1ce8e50 100644 --- a/desktop/reasoning_display_app.go +++ b/desktop/reasoning_display_app.go @@ -35,9 +35,10 @@ func (a *App) defaultSettingsView() SettingsView { defaults := config.Default() return SettingsView{ Providers: []ProviderView{}, OfficialProviders: officialProviderViews(map[string]bool{}, ""), - ProviderPresets: providerPresetViewsForRootWithResolver(nil, a.activeWorkspaceRoot(), nil), - ProviderKinds: nonNil(provider.Kinds()), - Permissions: PermissionsView{Mode: "ask", Allow: []string{}, Ask: []string{}, Deny: []string{}}, + ProviderPresets: providerPresetViewsForRootWithResolver(nil, a.activeWorkspaceRoot(), nil), + ProviderAccounts: []ProviderAccountView{}, + ProviderKinds: nonNil(provider.Kinds()), + Permissions: PermissionsView{Mode: "ask", Allow: []string{}, Ask: []string{}, Deny: []string{}}, Sandbox: SandboxView{Bash: defaults.BashMode(), AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto", EffectiveShell: sandboxEffectiveShellView(sandbox.ResolveShell("", "", nil)), ResolvedShell: sandboxEffectiveShellView(sandbox.ResolveShell("", "", nil)), diff --git a/desktop/settings_app.go b/desktop/settings_app.go index fd25624411..243bd004ef 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -79,6 +79,11 @@ type ProviderView struct { // current model selection. Background discovery must compare it while holding // the config edit lock before applying a narrow catalog-only update. ModelCatalogFingerprint string `json:"modelCatalogFingerprint"` + ProviderID string `json:"providerId,omitempty"` + AccountID string `json:"accountId,omitempty"` + AccountLabel string `json:"accountLabel,omitempty"` + AccountEnabled bool `json:"accountEnabled,omitempty"` + AccountDefault bool `json:"accountDefault,omitempty"` } type ProviderModelCatalogUpdate struct { @@ -90,29 +95,32 @@ type ProviderModelCatalogUpdate struct { } type ProviderPresetView struct { - ID string `json:"id"` - Label string `json:"label"` - Description string `json:"description"` - KeyEnv string `json:"keyEnv"` - Recommended bool `json:"recommended,omitempty"` - BillingMode string `json:"billingMode,omitempty"` - DisplayGroup string `json:"displayGroup,omitempty"` - DisplaySection string `json:"displaySection,omitempty"` - DisplayTier string `json:"displayTier,omitempty"` - RouteKind string `json:"routeKind,omitempty"` - Optional bool `json:"optional,omitempty"` - DisplayOrder int `json:"displayOrder,omitempty"` - ProviderNames []string `json:"providerNames"` - Models []string `json:"models"` - Added bool `json:"added"` - Status string `json:"status"` - StatusProviderNames []string `json:"statusProviderNames"` - MissingProviderNames []string `json:"missingProviderNames,omitempty"` - KeySet bool `json:"keySet"` - RequiresKey bool `json:"requiresKey"` - Configured bool `json:"configured"` - KeySource string `json:"keySource,omitempty"` - KeySourcePath string `json:"keySourcePath,omitempty"` + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description"` + KeyEnv string `json:"keyEnv"` + Recommended bool `json:"recommended,omitempty"` + BillingMode string `json:"billingMode,omitempty"` + DisplayGroup string `json:"displayGroup,omitempty"` + DisplaySection string `json:"displaySection,omitempty"` + DisplayTier string `json:"displayTier,omitempty"` + RouteKind string `json:"routeKind,omitempty"` + Optional bool `json:"optional,omitempty"` + DisplayOrder int `json:"displayOrder,omitempty"` + ProviderNames []string `json:"providerNames"` + Models []string `json:"models"` + Added bool `json:"added"` + Status string `json:"status"` + StatusProviderNames []string `json:"statusProviderNames"` + MissingProviderNames []string `json:"missingProviderNames,omitempty"` + KeySet bool `json:"keySet"` + RequiresKey bool `json:"requiresKey"` + Configured bool `json:"configured"` + KeySource string `json:"keySource,omitempty"` + KeySourcePath string `json:"keySourcePath,omitempty"` + AccountGroupID string `json:"accountGroupId,omitempty"` + Accounts []ProviderAccountView `json:"accounts"` + CanAddAccount bool `json:"canAddAccount,omitempty"` } const ( @@ -302,33 +310,34 @@ type BotSettingsView struct { // SettingsView is the whole Settings panel payload. type SettingsView struct { - DefaultModel string `json:"defaultModel"` - PlannerModel string `json:"plannerModel"` - VisionModel string `json:"visionModel"` - SubagentModel string `json:"subagentModel"` - SubagentEffort string `json:"subagentEffort"` - AutoPlan string `json:"autoPlan"` - Providers []ProviderView `json:"providers"` - OfficialProviders []ProviderView `json:"officialProviders"` - ProviderPresets []ProviderPresetView `json:"providerPresets"` - Permissions PermissionsView `json:"permissions"` - Sandbox SandboxView `json:"sandbox"` - Network NetworkView `json:"network"` - Agent AgentView `json:"agent"` - Bot BotSettingsView `json:"bot"` - DesktopLanguage string `json:"desktopLanguage"` - DesktopCurrency string `json:"desktopCurrency"` - DesktopLayoutStyle string `json:"desktopLayoutStyle"` - DesktopTheme string `json:"desktopTheme"` - DesktopThemeStyle string `json:"desktopThemeStyle"` - DesktopTerminalTheme string `json:"desktopTerminalTheme,omitempty"` - CloseBehavior string `json:"closeBehavior"` - DisplayMode string `json:"displayMode"` - ReasoningDisplayMode string `json:"reasoningDisplayMode"` - ReasoningDisplayModeExplicit bool `json:"reasoningDisplayModeExplicit"` - StatusBarStyle string `json:"statusBarStyle"` - StatusBarItems []string `json:"statusBarItems"` - DefaultToolApprovalMode string `json:"defaultToolApprovalMode"` + DefaultModel string `json:"defaultModel"` + PlannerModel string `json:"plannerModel"` + VisionModel string `json:"visionModel"` + SubagentModel string `json:"subagentModel"` + SubagentEffort string `json:"subagentEffort"` + AutoPlan string `json:"autoPlan"` + Providers []ProviderView `json:"providers"` + OfficialProviders []ProviderView `json:"officialProviders"` + ProviderPresets []ProviderPresetView `json:"providerPresets"` + ProviderAccounts []ProviderAccountView `json:"providerAccounts"` + Permissions PermissionsView `json:"permissions"` + Sandbox SandboxView `json:"sandbox"` + Network NetworkView `json:"network"` + Agent AgentView `json:"agent"` + Bot BotSettingsView `json:"bot"` + DesktopLanguage string `json:"desktopLanguage"` + DesktopCurrency string `json:"desktopCurrency"` + DesktopLayoutStyle string `json:"desktopLayoutStyle"` + DesktopTheme string `json:"desktopTheme"` + DesktopThemeStyle string `json:"desktopThemeStyle"` + DesktopTerminalTheme string `json:"desktopTerminalTheme,omitempty"` + CloseBehavior string `json:"closeBehavior"` + DisplayMode string `json:"displayMode"` + ReasoningDisplayMode string `json:"reasoningDisplayMode"` + ReasoningDisplayModeExplicit bool `json:"reasoningDisplayModeExplicit"` + StatusBarStyle string `json:"statusBarStyle"` + StatusBarItems []string `json:"statusBarItems"` + DefaultToolApprovalMode string `json:"defaultToolApprovalMode"` CheckUpdates bool `json:"checkUpdates"` UpdateChannel string `json:"updateChannel"` @@ -685,16 +694,9 @@ func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEnt ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models), RecommendedUpgradeAvailable: config.CanUpgradeDeepSeekProviderProtocol(&p), ModelCatalogFingerprint: providerModelCatalogFingerprintForCredentials(p, credentialsRevision), - } -} - -func providerThinkingForSettings(thinking string) string { - normalized := strings.ToLower(strings.TrimSpace(thinking)) - switch normalized { - case "enabled", "disabled", "adaptive": - return normalized - default: - return "" + ProviderID: p.AccountProviderID, + AccountID: p.AccountID, + AccountLabel: p.AccountLabel, } } @@ -724,72 +726,6 @@ func officialProviderViewsForRootWithResolver(added map[string]bool, pricingLang return out } -func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView { - if resolver == nil { - resolver = config.NewCredentialResolverForRoot(root) - } - presets := config.CuratedProviderPresets() - out := make([]ProviderPresetView, 0, len(presets)) - for _, preset := range presets { - keyEnv := strings.TrimSpace(preset.KeyEnv) - names := make([]string, 0, len(preset.Entries)) - models := make([]string, 0) - modelSeen := map[string]bool{} - requiresKey := false - for _, entry := range preset.Entries { - if keyEnv == "" { - keyEnv = strings.TrimSpace(entry.APIKeyEnv) - } - if entry.RequiresAPIKey() { - requiresKey = true - } - name := strings.TrimSpace(entry.Name) - if name != "" { - names = append(names, name) - } - for _, model := range chatProviderModels(entry.ChatModelList()) { - if modelSeen[model] { - continue - } - modelSeen[model] = true - models = append(models, model) - } - } - key := config.CredentialResolution{} - if keyEnv != "" { - key = resolver.ResolveGlobalFirst(keyEnv) - } - status, statusNames, missingNames := classifyProviderPresetStatus(cfg, preset) - added := status == providerPresetStatusInstalled || status == providerPresetStatusInstalledModified || status == providerPresetStatusNameConflict - out = append(out, ProviderPresetView{ - ID: preset.ID, - Label: preset.Label, - Description: preset.Description, - KeyEnv: keyEnv, - Recommended: preset.Recommended, - BillingMode: preset.BillingMode, - DisplayGroup: preset.DisplayGroup, - DisplaySection: preset.DisplaySection, - DisplayTier: preset.DisplayTier, - RouteKind: preset.RouteKind, - Optional: preset.Optional, - DisplayOrder: preset.DisplayOrder, - ProviderNames: nonNil(names), - Models: nonNil(models), - Added: added, - Status: status, - StatusProviderNames: nonNil(statusNames), - MissingProviderNames: nonNil(missingNames), - KeySet: key.Set, - RequiresKey: requiresKey, - Configured: !requiresKey || key.Set, - KeySource: key.Source.Label, - KeySourcePath: key.Source.Path, - }) - } - return out -} - func classifyProviderPresetStatus(cfg *config.Config, preset config.ProviderPreset) (string, []string, []string) { if cfg == nil { return providerPresetStatusAvailable, nil, nil @@ -1005,6 +941,7 @@ func (a *App) Settings() SettingsView { Providers: []ProviderView{}, OfficialProviders: []ProviderView{}, ProviderPresets: []ProviderPresetView{}, + ProviderAccounts: []ProviderAccountView{}, Permissions: PermissionsView{ Mode: orDefault(cfg.Permissions.Mode, "ask"), Allow: nonNil(cfg.Permissions.Allow), @@ -1077,8 +1014,10 @@ func (a *App) Settings() SettingsView { p := &cfg.Providers[i] providerView := providerViewFromEntryForRootWithResolverAndCredentials(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver, credentialsRevision) providerView.RecommendedUpgradeAvailable = providerView.RecommendedUpgradeAvailable && config.CanUpgradeDeepSeekProviderProtocolUserConfig(p.Name) + applyAccountMetadataToProviderView(&providerView, *p, cfg) v.Providers = append(v.Providers, providerView) } + v.ProviderAccounts = providerAccountViewsForRoot(cfg, root, resolver) return v } diff --git a/docs/CLI.md b/docs/CLI.md index fd00446ee9..b27e788f6e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -66,14 +66,18 @@ reasonix setup /path/to/config.toml ``` In an interactive terminal, `reasonix setup` is a staged provider manager. It -lists configured providers and lets you: +lists provider families with account sub-items and lets you: - add OpenAI-compatible or Anthropic-compatible providers; +- add, rename, enable/disable, or retire a curated-provider account; - edit endpoints and model lists; - update API keys or test the connection and refresh models; - choose the default model; and - remove providers. +Project-level setup (`--local`) can select existing global accounts but cannot +create accounts or write credentials into the project. + Choose **Save and exit** to review and confirm the pending operations. Canceling discards them. Setup reloads the latest config while saving: unrelated desktop or CLI changes are retained, while an overlapping change is reported as a @@ -451,7 +455,7 @@ the displayed list matches the commands the TUI accepts. | --- | --- | | `/continue-checks [guidance]` | Resume the immediately preceding paused task-completion check while preserving its verified tool evidence. The command is one-shot and refuses stale cards after another user turn. | | `/model` | Search configured models and switch the active model. | -| `/provider` | Choose a provider, then choose one of its configured models. | +| `/provider` | Choose a provider family/account, then one of its models. `/provider ` still accepts a generated provider name. | | `/resume` | Search recent sessions and switch to one. | | `/status` | Show model, effort, cache, Git, background jobs, and balance details. | | `/theme [auto\|light\|dark\|style]` | View or change the CLI background mode and accent palette. | diff --git a/docs/CLI.zh-CN.md b/docs/CLI.zh-CN.md index 8a940a71f2..ea7e60c341 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -58,15 +58,17 @@ reasonix setup --local # 管理 ./reasonix.toml reasonix setup /path/to/config.toml ``` -在交互式终端中,`reasonix setup` 是一个暂存式供应商管理器。它会列出已配置的 -provider,并支持: +在交互式终端中,`reasonix setup` 是一个暂存式供应商管理器。它会按供应商族列出账号子项,并支持: - 添加 OpenAI-compatible 或 Anthropic-compatible provider; +- 添加、重命名、启用/停用或退休预设供应商账号; - 编辑 endpoint 和模型列表; - 更新 API Key,或测试连接并刷新模型; - 设置默认模型; - 删除 provider。 +项目级 setup(`--local`)只能选择已有全局账号,不能创建账号或把凭据写入项目。 + 选择“保存并退出”后会先展示并确认待执行操作;取消会丢弃本次修改。保存时 setup 会重新 加载最新配置:桌面端或其他 CLI 产生的不相关修改会被保留,改到同一项时则报告冲突, 不会直接覆盖。 @@ -386,7 +388,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键 | --- | --- | | `/continue-checks [补充要求]` | 继续紧邻上一轮、已暂停的任务收尾检查,并保留其工具证据。该操作仅可消费一次;出现新的用户消息后,旧卡片会被拒绝。 | | `/model` | 搜索已配置模型并切换当前模型。 | -| `/provider` | 选择 provider,再选择该 provider 下的模型。 | +| `/provider` | 选择供应商族/账号,再选择模型。`/provider ` 仍接受生成后的 provider 名。 | | `/resume` | 搜索最近会话并切换。 | | `/status` | 显示模型、effort、cache、Git、后台任务和余额信息。 | | `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 | diff --git a/docs/CONFIG_PATHS.md b/docs/CONFIG_PATHS.md index 9a560501c9..4f94c7ebb6 100644 --- a/docs/CONFIG_PATHS.md +++ b/docs/CONFIG_PATHS.md @@ -102,6 +102,14 @@ show_turn_usage = false # hide per-request token/cost receipts in the TUI; [desktop] provider_access = ["deepseek"] +# provider_accounts are user-global only. Project reasonix.toml cannot define them. +# [[provider_accounts]] +# provider_id = "deepseek" +# id = "main" +# label = "Main" +# api_key_env = "DEEPSEEK_API_KEY" +# default = true + [[providers]] name = "deepseek" kind = "anthropic" diff --git a/docs/CONFIG_PATHS.zh-CN.md b/docs/CONFIG_PATHS.zh-CN.md index 43e17791b4..160f8f78d2 100644 --- a/docs/CONFIG_PATHS.zh-CN.md +++ b/docs/CONFIG_PATHS.zh-CN.md @@ -83,6 +83,14 @@ show_turn_usage = false # 隐藏 TUI 每轮 token/费用回执;默认 tr [desktop] provider_access = ["deepseek"] +# provider_accounts 只允许写在用户全局配置中。 +# [[provider_accounts]] +# provider_id = "deepseek" +# id = "main" +# label = "主账号" +# api_key_env = "DEEPSEEK_API_KEY" +# default = true + [[providers]] name = "deepseek" kind = "anthropic" diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 2bd8c2baa0..677af143bb 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -39,7 +39,13 @@ built-in defaults**. Starting with **Reasonix v1.8.1**, the user config lives at [Configuration paths](./CONFIG_PATHS.md) for migration and related data paths. Fields marked user/global only are not overridden by `./reasonix.toml`. Provider entries name secrets with `api_key_env`, while the secret values live in -Reasonix's global `/.env`, shared by CLI and desktop. Project +Reasonix's global `/.env`, shared by CLI and desktop. +`[[provider_accounts]]` live only in the user-global config. One OpenCode Go +account expands Chat, Anthropic, and Responses routes that share a key. Old +provider names stay valid after migration to a `main` account. Account switching +is manual, not polling; a disabled account leaves the new-model picker while +existing sessions keep their bound provider. Different keys may split vendor-side +prefix cache. Project `.env`, home `.env`, inherited shell environment variables, legacy credentials, and the OS keyring are not provider-key runtime fallbacks; legacy credentials are only migration sources. Project `.env` still feeds workspace-scoped, diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 8e7ef72adf..0a565bf0d2 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -37,7 +37,11 @@ `~/.reasonix/config.toml`,Windows 为 `%AppData%\reasonix\config.toml`;迁移和相关数据路径见 [配置路径](./CONFIG_PATHS.zh-CN.md)。标注为“仅用户/全局”的字段(包括 agent 轮数上限)不会被 `./reasonix.toml` 覆盖。 Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在 CLI 与桌面端共用的 -Reasonix 全局 `/.env`。项目 `.env`、home `.env`、继承的 shell 环境变量、旧 credentials 和系统 keyring 都不再作为 provider key 的运行时 fallback;旧凭据只作为迁移来源读取。项目 `.env` 仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,但不会导入 provider key 或 Reasonix 控制变量。全局 `config.toml` 和 `.env` 的完整结构见 +Reasonix 全局 `/.env`。`[[provider_accounts]]` 只存在于用户全局配置。OpenCode Go +一个账号对应 Chat、Anthropic、Responses 三条协议路由并共用一把 Key。旧 provider +名在迁移为 `main` 账号后仍可用。账号切换是手动选择,不是轮询;停用账号不会出现在 +新模型选择器中,已有会话继续使用已绑定的 provider。不同 Key 可能导致服务商侧缓存 +按租户分裂。项目 `.env`、home `.env`、继承的 shell 环境变量、旧 credentials 和系统 keyring 都不再作为 provider key 的运行时 fallback;旧凭据只作为迁移来源读取。项目 `.env` 仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,但不会导入 provider key 或 Reasonix 控制变量。全局 `config.toml` 和 `.env` 的完整结构见 [配置路径](./CONFIG_PATHS.zh-CN.md)。 桌面端和 CLI 端的可见思考语言设置,见 [思考语言](./REASONING_LANGUAGE.zh-CN.md)。 diff --git a/docs/SPEC.md b/docs/SPEC.md index e97ac894f8..0c1d94608c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -97,7 +97,13 @@ type Config struct { `chat_url` retains its historical OpenAI-only behavior; other legacy entries derive the protocol path from `base_url`. An entry declares either a single `model = "..."` or a `models = ["...", "..."]` list (with an optional `default`); the list form lets - one vendor expose several models without re-declaring the endpoint/key. A + one vendor expose several models without re-declaring the endpoint/key. + Curated families can also declare multiple `[[provider_accounts]]` in the + user-global config. Each account has its own `api_key_env`, enable/default + flags, and generated `provider/model` identity such as `deepseek--team/deepseek-v4-flash`. + A bare family name such as `deepseek` resolves to that family's default account. + Account switching is manual; Reasonix does not poll, load-balance, or fail over + across keys. A **model reference** (`default_model`, the `--model` flag, the desktop switcher) resolves via `Config.ResolveModel`, which accepts a provider name (→ its default model), a bare model name, or an explicit `provider/model`. `context_window` is @@ -1007,7 +1013,15 @@ at `~/.reasonix/config.toml` on macOS/Linux and [Configuration paths](./CONFIG_PATHS.md) for migration and related data paths. Fields marked user/global only are not overridden by project `reasonix.toml`. Provider entries name secrets with `api_key_env`; saved key values live in -Reasonix's global `/.env`, shared by CLI and desktop. Project +Reasonix's global `/.env`, shared by CLI and desktop. +`[[provider_accounts]]` are user-global only: project `reasonix.toml` may +reference generated provider names but must not declare accounts or store keys. +Older configs without accounts migrate in memory to a `main` account and keep +existing provider names (`deepseek-flash`, `opencode-go`, …). New accounts use +`--` names. Disabled accounts leave the model picker; +retired accounts keep generated provider entries so old sessions can still +resolve. Different API keys may split a vendor's prefix cache by tenant. +Project `.env`, home `.env`, inherited shell environment variables, legacy credentials, and the OS keyring are not provider-key runtime fallbacks. Project `.env` still feeds workspace-scoped, non-provider `${VAR}` expansion for MCP/plugin settings @@ -1034,6 +1048,11 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en # subagent_efforts = { review = "max", security_review = "high" } # A vendor endpoint exposing several models under one base_url/key. +# [[provider_accounts]] # user-global only; one family can have main + team keys +# provider_id = "deepseek" +# id = "team" +# label = "Team" +# api_key_env = "DEEPSEEK_API_KEY_TEAM" [[providers]] name = "deepseek" kind = "anthropic" diff --git a/docs/SPEC.zh-CN.md b/docs/SPEC.zh-CN.md index fbbc25afee..ad9a6780d9 100644 --- a/docs/SPEC.zh-CN.md +++ b/docs/SPEC.zh-CN.md @@ -71,7 +71,7 @@ func New(kind string, cfg Config) (Provider, error) - `openai` kind 实现 OpenAI-compatible `/chat/completions`。 - OpenAI-compatible vendor 只是 `kind = "openai"` 的不同配置实例,通过 `base_url`、`model`、`api_key_env` 区分;新增兼容模型通常只需改配置。 -- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。设置 `request_url` 时,OpenAI-compatible、Anthropic-compatible 和 Responses provider 都会原样使用该完整请求地址;旧 `chat_url` 只保留 OpenAI 历史兼容语义。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。 +- 一个 provider 表示一个 vendor endpoint,可通过 `models` 暴露多个模型,并以 `default` 指定默认项。设置 `request_url` 时,OpenAI-compatible、Anthropic-compatible 和 Responses provider 都会原样使用该完整请求地址;旧 `chat_url` 只保留 OpenAI 历史兼容语义。预设供应商还可以在用户全局配置中声明多个 `[[provider_accounts]]`,每个账号有独立 `api_key_env`、启用/默认标记,以及如 `deepseek--team/deepseek-v4-flash` 这样的生成身份。裸家族名如 `deepseek` 解析到该族默认账号。账号切换是手动选择,不做轮询或故障切换。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名或 `provider/model`。 - `context_window` 是 provider 级默认值;`model_overrides..context_window` 可覆盖单个模型。 - `max_output_tokens` 是独立的本轮输出上限,不由客户端 reasoning 字节上限换算,也不参与 `compact_ratio`。`0` 是 Provider 自动值(官方 DeepSeek 384K / OpenCode 元数据),不再表示跳过本地检查;空间充足时官方 DeepSeek 仍省略字段,临界时裁剪。正数为用户显式控费上限。负数为明确省略;安全不足时压缩。`budget_tokens` 在官方 Anthropic 兼容层会被忽略。混合网关可用 `model_overrides..max_output_tokens` 覆盖单个模型。 - streaming tool-call delta 在 provider 内按 index 聚合,只向上层发出完整 `ToolCall`。 @@ -391,7 +391,7 @@ provider 层的核心类型包括 `Role`、`Message`、`ToolCall`、`ToolSchema` flag > ./reasonix.toml > 用户 config.toml > 内置默认值 ``` -从 v1.8.1 起,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml` 或 Windows 的 `%AppData%\reasonix\config.toml`。provider key 保存在 Reasonix home 的 `.env`;项目 `.env` 只用于 workspace 范围的非 provider 变量展开。完整路径见[配置路径](./CONFIG_PATHS.zh-CN.md)。 +从 v1.8.1 起,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml` 或 Windows 的 `%AppData%\reasonix\config.toml`。provider key 保存在 Reasonix home 的 `.env`;项目 `.env` 只用于 workspace 范围的非 provider 变量展开。`[[provider_accounts]]` 只允许写在用户全局配置中;项目 `reasonix.toml` 可以引用已展开的 provider 名,但不能定义账号或写入凭据。旧配置会在内存中迁移为 `main` 账号并保留原 provider 名。新账号使用 `--`。停用账号不再出现在新模型选择器中;退休账号保留已生成的 provider entry,以便旧会话解析。不同 API Key 可能导致服务商侧缓存按租户分裂。账号切换是手动选择,不是轮询。完整路径见[配置路径](./CONFIG_PATHS.zh-CN.md)。 ```toml default_model = "deepseek" diff --git a/internal/boot/provider_account_auth_test.go b/internal/boot/provider_account_auth_test.go new file mode 100644 index 0000000000..44b96fa419 --- /dev/null +++ b/internal/boot/provider_account_auth_test.go @@ -0,0 +1,120 @@ +package boot + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "reasonix/internal/config" + "reasonix/internal/provider" +) + +func TestAccountSwitchSendsMatchingAuthHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + + cfg := config.Default() + team, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM") + if err != nil { + t.Fatal(err) + } + streamAccount := func(account config.ProviderAccount, key string) string { + gotAuth = "" + entries, ok := cfg.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok || len(entries) == 0 { + t.Fatalf("no entries for %s", account.ID) + } + entry := entries[0] + entry.BaseURL = srv.URL + entry.Kind = "openai" + if entry.Model == "" { + entry.Model = entry.DefaultModel() + } + t.Setenv(entry.APIKeyEnv, key) + entry.ResolveAPIKeyFromProcessEnvForProbe() + p, err := NewProvider(&entry) + if err != nil { + t.Fatalf("NewProvider: %v", err) + } + ch, err := p.Stream(context.Background(), provider.Request{ + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}, + }) + if err != nil { + t.Fatalf("Stream: %v", err) + } + for chunk := range ch { + if chunk.Type == provider.ChunkError { + t.Fatalf("stream error: %v", chunk.Err) + } + } + return gotAuth + } + if auth := streamAccount(cfg.ProviderAccounts[0], "sk-main"); auth != "Bearer sk-main" { + t.Fatalf("main auth = %q", auth) + } + if auth := streamAccount(team, "sk-team"); auth != "Bearer sk-team" { + t.Fatalf("team auth = %q", auth) + } +} + +func TestAccountSwitchKeepsPromptBytesStable(t *testing.T) { + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode: %v", err) + } + bodies = append(bodies, body) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")) + })) + t.Cleanup(srv.Close) + + cfg := config.Default() + if _, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM"); err != nil { + t.Fatal(err) + } + req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}} + for _, account := range cfg.ProviderAccounts { + if account.ProviderID != "deepseek" { + continue + } + entries, ok := cfg.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok || len(entries) == 0 { + continue + } + entry := entries[0] + entry.BaseURL = srv.URL + entry.Kind = "openai" + if entry.Model == "" { + entry.Model = entry.DefaultModel() + } + t.Setenv(entry.APIKeyEnv, "sk-"+account.ID) + entry.ResolveAPIKeyFromProcessEnvForProbe() + p, err := NewProvider(&entry) + if err != nil { + t.Fatal(err) + } + ch, err := p.Stream(context.Background(), req) + if err != nil { + t.Fatal(err) + } + for range ch { + } + } + if len(bodies) < 2 { + t.Fatalf("got %d bodies", len(bodies)) + } + msg0, _ := json.Marshal(bodies[0]["messages"]) + msg1, _ := json.Marshal(bodies[1]["messages"]) + if string(msg0) != string(msg1) { + t.Fatalf("prompt bytes changed across accounts:\n%s\n%s", msg0, msg1) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b1ea7edfa7..97bde9fb53 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -615,7 +615,7 @@ command = "legacy-bin" if err != nil { t.Fatalf("read migrated user config: %v", err) } - for _, want := range []string{`config_version = 7`, `[desktop]`, `name = "legacy-cli"`} { + for _, want := range []string{`config_version = 8`, `[desktop]`, `name = "legacy-cli"`} { if !strings.Contains(string(body), want) { t.Fatalf("migrated config missing %q:\n%s", want, body) } @@ -642,7 +642,7 @@ func TestRunAppliesUserConfigUpgradesOnStartup(t *testing.T) { if err != nil { t.Fatalf("read upgraded user config: %v", err) } - if !strings.Contains(string(body), "config_version = 7") { + if !strings.Contains(string(body), "config_version = 8") { t.Fatalf("CLI startup should apply user config upgrades:\n%s", body) } } diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 3c845cf6d6..d2740f1eaa 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -33,9 +33,40 @@ func (m *chatTUI) openProviderPicker() { curProvider := strings.SplitN(m.modelRef, "/", 2)[0] var items []quickPickerItem selected := 0 + seen := map[string]bool{} + for _, account := range cfg.ProviderAccounts { + if !account.IsEnabled() { + continue + } + entries, ok := cfg.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok { + continue + } + usable := false + for i := range entries { + if entries[i].Configured() { + usable = true + } + seen[entries[i].Name] = true + } + if !usable { + continue + } + status := "" + for i := range entries { + if entries[i].Name == curProvider { + status = "active" + selected = len(items) + } + } + items = append(items, quickPickerItem{ + ID: entries[0].Name, Label: account.Label, + Description: fmt.Sprintf("%s · %d route(s)", account.ProviderID, len(entries)), Status: status, + }) + } for i := range cfg.Providers { p := &cfg.Providers[i] - if !p.Configured() { + if seen[p.Name] || !p.Configured() { continue } models := p.ChatModelList() diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go new file mode 100644 index 0000000000..11220c1093 --- /dev/null +++ b/internal/cli/setup_accounts.go @@ -0,0 +1,208 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "reasonix/internal/config" + "reasonix/internal/i18n" +) + +type setupMenuKind uint8 + +const ( + setupMenuProvider setupMenuKind = iota + setupMenuAccount + setupMenuAddAccount + setupMenuAddOpenAI + setupMenuAddAnthropic + setupMenuSave + setupMenuCancel +) + +type setupMenuAction struct { + kind setupMenuKind + provider int + providerID string + accountID string +} + +func providerManagerMenu(s *providerSetupSession) ([]menuItem, []setupMenuAction) { + items := make([]menuItem, 0, len(s.cfg.Providers)+8) + actions := make([]setupMenuAction, 0, len(s.cfg.Providers)+8) + seen := map[string]bool{} + for _, account := range s.cfg.ProviderAccounts { + if account.Retired { + continue + } + entries, _ := s.cfg.ResolveAccountProvider(account.ProviderID, account.ID) + keyStatus := i18n.M.SetupKeyMissing + if account.APIKeyEnv == "" || config.CredentialIsSet(account.APIKeyEnv) || s.pendingCredentials[account.APIKeyEnv] != "" { + keyStatus = i18n.M.SetupKeySet + } + desc := fmt.Sprintf("%s · %d · %s", account.ProviderID, len(entries), keyStatus) + if account.Default { + desc += " · " + i18n.M.SetupDefaultBadge + } + if !account.IsEnabled() { + desc += " · disabled" + } + items = append(items, menuItem{name: account.Label, desc: desc}) + actions = append(actions, setupMenuAction{kind: setupMenuAccount, providerID: account.ProviderID, accountID: account.ID}) + for _, e := range entries { + seen[e.Name] = true + } + } + for i, p := range s.cfg.Providers { + if seen[p.Name] { + continue + } + models := p.ModelList() + keyStatus := i18n.M.SetupKeyMissing + if p.APIKeyEnv == "" || config.CredentialIsSet(p.APIKeyEnv) || s.pendingCredentials[p.APIKeyEnv] != "" { + keyStatus = i18n.M.SetupKeySet + } + desc := fmt.Sprintf("%s · %d %s · %s", p.Kind, len(models), i18n.M.SetupModelsUnit, keyStatus) + if s.cfg.DefaultModel == p.Name || config.ModelRefsProvider(s.cfg.DefaultModel, p.Name) { + desc += " · " + i18n.M.SetupDefaultBadge + } + items = append(items, menuItem{name: p.Name, desc: desc}) + actions = append(actions, setupMenuAction{kind: setupMenuProvider, provider: i}) + } + if !s.projectScoped { + items = append(items, menuItem{name: i18n.M.SetupAddAccount, desc: i18n.M.SetupAddAccountDesc}) + actions = append(actions, setupMenuAction{kind: setupMenuAddAccount}) + } + items = append(items, + menuItem{name: i18n.M.SetupAddOpenAI, desc: i18n.M.CustomProviderDesc}, + menuItem{name: i18n.M.SetupAddAnthropic, desc: i18n.M.AnthropicProviderDesc}, + menuItem{name: i18n.M.SetupSaveExit, desc: i18n.M.SetupSaveExitDesc}, + menuItem{name: i18n.M.SetupCancel, desc: i18n.M.SetupCancelDesc}, + ) + actions = append(actions, + setupMenuAction{kind: setupMenuAddOpenAI}, + setupMenuAction{kind: setupMenuAddAnthropic}, + setupMenuAction{kind: setupMenuSave}, + setupMenuAction{kind: setupMenuCancel}, + ) + return items, actions +} + +func manageProviderAccount(s *providerSetupSession, providerID, accountID string) { + _, account, ok := lookupSessionAccount(s, providerID, accountID) + if !ok { + return + } + idx, err := selectOne(fmt.Sprintf("%s / %s", account.ProviderID, account.Label), []menuItem{ + {name: i18n.M.SetupUpdateKey}, + {name: i18n.M.SetupSetDefault}, + {name: i18n.M.SetupAccountRename}, + {name: i18n.M.SetupAccountToggle}, + {name: i18n.M.SetupAccountRetire}, + {name: i18n.M.SetupBack}, + }) + if err != nil || idx == 5 { + return + } + switch idx { + case 0: + updateAccountKey(s, account) + case 1: + _ = s.cfg.SetProviderAccountDefault(account.ProviderID, account.ID) + case 2: + renameSessionAccount(s, account) + case 3: + _ = s.cfg.SetProviderAccountEnabled(account.ProviderID, account.ID, !account.IsEnabled()) + case 4: + if err := s.cfg.RetireProviderAccount(account.ProviderID, account.ID); err != nil { + fmt.Fprintln(os.Stderr, err) + } + } +} + +func addProviderAccountToSession(s *providerSetupSession) bool { + if s.projectScoped { + fmt.Fprintln(os.Stderr, i18n.M.SetupProjectNoAccounts) + return false + } + presets := []config.ProviderPreset{} + for _, preset := range config.CuratedProviderPresets() { + if preset.ID == "opencode-go-recommended" || preset.AccountGroupID == "deepseek" && preset.ID == "deepseek-responses" { + presets = append(presets, preset) + } + } + if len(presets) == 0 { + return false + } + items := make([]menuItem, 0, len(presets)) + for _, preset := range presets { + items = append(items, menuItem{name: preset.Label, desc: preset.AccountGroupID}) + } + idx, err := selectOne(i18n.M.SetupAddAccount, items) + if err != nil || idx < 0 || idx >= len(presets) { + return false + } + preset := presets[idx] + label := strings.TrimSpace(askLine(i18n.M.SetupAccountLabel, "Team")) + if label == "" { + return false + } + key := strings.TrimSpace(askLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, preset.KeyEnv), "")) + account, err := s.cfg.AddProviderAccount(preset.AccountGroupID, preset.ID, label, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return false + } + if key != "" { + if err := s.setCredential(account.APIKeyEnv, key); err != nil { + fmt.Fprintln(os.Stderr, err) + return false + } + } + entries, _ := s.cfg.ResolveAccountProvider(account.ProviderID, account.ID) + s.addProviderAccess(entries) + s.promoteDefaultToNewProviders(entries) + return true +} + +func updateAccountKey(s *providerSetupSession, account config.ProviderAccount) { + if strings.TrimSpace(account.APIKeyEnv) == "" { + return + } + value := strings.TrimSpace(askLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, account.APIKeyEnv), "")) + if value == "" { + return + } + if err := s.setCredential(account.APIKeyEnv, value); err != nil { + fmt.Fprintln(os.Stderr, err) + } +} + +func renameSessionAccount(s *providerSetupSession, account config.ProviderAccount) { + label := strings.TrimSpace(askLine(i18n.M.SetupAccountLabel, account.Label)) + if label == "" { + return + } + _ = s.cfg.RenameProviderAccount(account.ProviderID, account.ID, label) +} + +func lookupSessionAccount(s *providerSetupSession, providerID, accountID string) (int, config.ProviderAccount, bool) { + for i, account := range s.cfg.ProviderAccounts { + if account.ProviderID == providerID && account.ID == accountID { + return i, account, true + } + } + return -1, config.ProviderAccount{}, false +} + +func askLine(label, def string) string { + fmt.Printf("%s [%s]: ", label, def) + var line string + _, _ = fmt.Scanln(&line) + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index dd01180019..5b5e97033f 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -477,58 +477,46 @@ func runProviderSetupManager(s *providerSetupSession, configPath, envPath string fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.RepairedAPIKeyEnvFmt, repair.provider, repair.old, repair.new))) } for { - items := providerManagerItems(s) + items, actions := providerManagerMenu(s) idx, err := selectOne(i18n.M.SetupManagerTitle, items) if err != nil { fmt.Fprintln(os.Stderr, "\n"+i18n.M.SetupCancelled) return 1 } - providerCount := len(cfg.Providers) - switch idx { - case providerCount: + if idx < 0 || idx >= len(actions) { + continue + } + switch actions[idx].kind { + case setupMenuAddOpenAI: if !addProviderToSession(s, false) { continue } - case providerCount + 1: + case setupMenuAddAnthropic: if !addProviderToSession(s, true) { continue } - case providerCount + 2: + case setupMenuAddAccount: + _ = addProviderAccountToSession(s) + case setupMenuSave: rc := saveProviderSetupSession(s, configPath, envPath) if rc == setupManagerContinue { continue } return rc - case providerCount + 3: + case setupMenuCancel: fmt.Println(i18n.M.SetupCancelled) return 1 + case setupMenuAccount: + manageProviderAccount(s, actions[idx].providerID, actions[idx].accountID) default: - manageProvider(s, idx) + manageProvider(s, actions[idx].provider) } } } func providerManagerItems(s *providerSetupSession) []menuItem { - cfg := s.cfg - items := make([]menuItem, 0, len(cfg.Providers)+4) - for _, p := range cfg.Providers { - models := p.ModelList() - keyStatus := i18n.M.SetupKeyMissing - if p.APIKeyEnv == "" || config.CredentialIsSet(p.APIKeyEnv) || s.pendingCredentials[p.APIKeyEnv] != "" { - keyStatus = i18n.M.SetupKeySet - } - desc := fmt.Sprintf("%s · %d %s · %s", p.Kind, len(models), i18n.M.SetupModelsUnit, keyStatus) - if cfg.DefaultModel == p.Name || config.ModelRefsProvider(cfg.DefaultModel, p.Name) { - desc += " · " + i18n.M.SetupDefaultBadge - } - items = append(items, menuItem{name: p.Name, desc: desc}) - } - return append(items, - menuItem{name: i18n.M.SetupAddOpenAI, desc: i18n.M.CustomProviderDesc}, - menuItem{name: i18n.M.SetupAddAnthropic, desc: i18n.M.AnthropicProviderDesc}, - menuItem{name: i18n.M.SetupSaveExit, desc: i18n.M.SetupSaveExitDesc}, - menuItem{name: i18n.M.SetupCancel, desc: i18n.M.SetupCancelDesc}, - ) + items, _ := providerManagerMenu(s) + return items } func addProviderToSession(s *providerSetupSession, anthropic bool) bool { diff --git a/internal/config/billing_upgrade_test.go b/internal/config/billing_upgrade_test.go index c8e4200740..6d7e2d3805 100644 --- a/internal/config/billing_upgrade_test.go +++ b/internal/config/billing_upgrade_test.go @@ -40,8 +40,8 @@ price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" } t.Fatal(err) } text := string(raw) - if !strings.Contains(text, "config_version = 7") { - t.Fatalf("missing v7:\n%s", text) + if !strings.Contains(text, "config_version = 8") { + t.Fatalf("missing v8:\n%s", text) } if !strings.Contains(text, "display_currency") && !strings.Contains(text, `currency = "CNY"`) { t.Fatalf("display currency not migrated:\n%s", text) diff --git a/internal/config/config.go b/internal/config/config.go index 95670d9d05..7b985aada6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ type Config struct { Notifications NotificationsConfig `toml:"notifications"` Agent AgentConfig `toml:"agent"` Providers []ProviderEntry `toml:"providers"` + ProviderAccounts []ProviderAccount `toml:"provider_accounts"` Tools ToolsConfig `toml:"tools"` Permissions PermissionsConfig `toml:"permissions"` Sandbox SandboxConfig `toml:"sandbox"` @@ -1364,21 +1365,25 @@ type AgentConfig struct { // token budget; the harness compacts older history as a turn's prompt approaches // it (see agent compaction). 0 disables compaction for the instance. type ProviderEntry struct { - Name string `toml:"name"` - Kind string `toml:"kind"` - BaseURL string `toml:"base_url"` - ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics - RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI - Model string `toml:"model"` // a single model (back-compat) - Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) - ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup - Default string `toml:"default"` // default model when Models is set (else Models[0]) - APIKeyEnv string `toml:"api_key_env"` - PresetID string `toml:"preset_id"` // curated preset identity; UI-only metadata, not sent to model providers. - PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations. - Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env. - ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways. - AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key. + Name string `toml:"name"` + Kind string `toml:"kind"` + BaseURL string `toml:"base_url"` + ChatURL string `toml:"chat_url"` // legacy OpenAI chat endpoint override; retained with its historical semantics + RequestURL string `toml:"request_url"` // exact provider request URL written by current settings UI + Model string `toml:"model"` // a single model (back-compat) + Models []string `toml:"models"` // a vendor's model list (one base_url/key, many models) + ModelsURL string `toml:"models_url"` // auto-fetch models from this URL on startup + Default string `toml:"default"` // default model when Models is set (else Models[0]) + APIKeyEnv string `toml:"api_key_env"` + PresetID string `toml:"preset_id"` // curated preset identity; UI-only metadata, not sent to model providers. + PresetVersion int `toml:"preset_version"` // curated preset schema version for future migrations. + AccountProviderID string `toml:"account_provider_id,omitempty"` + AccountID string `toml:"account_id,omitempty"` + AccountRouteID string `toml:"account_route_id,omitempty"` + AccountLabel string `toml:"account_label,omitempty"` + Headers map[string]string `toml:"headers"` // optional extra HTTP headers for compatible gateways; secrets should stay in api_key_env. + ExtraBody map[string]any `toml:"extra_body"` // optional extra top-level JSON request body fields for OpenAI-compatible gateways. + AuthHeader bool `toml:"auth_header"` // for Anthropic-compatible gateways that expect Authorization: Bearer instead of x-api-key. // ResponsesMode selects the Responses API context strategy. Empty preserves // vendor detection; DeepSeek is stateless while compatible endpoints may use // stateful previous_response_id continuation. @@ -1857,7 +1862,7 @@ const LanguagePolicy = `Reply in the same language the user is using in their mo // Default returns the built-in default configuration. func Default() *Config { return &Config{ - ConfigVersion: 7, + ConfigVersion: 8, DefaultModel: "deepseek-flash", CredentialsStore: CredentialsStoreAuto, UI: UIConfig{Theme: "auto", ShowTurnUsage: true}, @@ -1944,183 +1949,10 @@ func Default() *Config { // main config into an unparseable state that leaves the app with no usable // models (#4615, #4708). func (c *Config) WriteFile(path string) error { - return atomicWriteToConfigFile(path, RenderTOMLForScope(c, renderScopeForPath(path)), configFilePerm(path)) -} - -// Provider returns the named provider entry. -func (c *Config) Provider(name string) (*ProviderEntry, bool) { - for i := range c.Providers { - if c.Providers[i].Name == name { - return &c.Providers[i], true - } + if renderScopeForPath(path) != RenderScopeProject { + c.prepareUserPersist() } - return nil, false -} - -// ResolveModel resolves a model reference to a provider entry whose Model is the -// selected model string (a copy, so the config's lists stay intact). It accepts: -// - "provider/model" — that exact model under that provider; -// - a provider name — the provider's default model; -// - a bare model name — the (first) provider that lists it. -// -// The returned entry is ready to build a provider from (NewProvider reads .Model), -// so a single "vendor with many models" entry yields one instance per model -// without duplicating base_url/api_key_env. Single-`model` entries still resolve -// by provider name, keeping older configs working unchanged. -func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) { - if ref == "" { - return nil, false - } - if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 { - if access["deepseek"] && !canCanonicalizeLegacyDeepSeekProviders(c) { - delete(access, "deepseek") - } - ref = retargetDesktopOfficialRef(ref, access) - } - // "provider/model" - if prov, model, ok := strings.Cut(ref, "/"); ok { - if e, found := c.Provider(prov); found && e.HasModel(model) { - cp := *e - cp.Model = model - cp.applyModelPrice() - cp.applyModelOverride() - return &cp, true - } - } - // a provider name → its default model - if e, found := c.Provider(ref); found { - cp := *e - cp.Model = e.DefaultModel() - cp.applyModelPrice() - cp.applyModelOverride() - return &cp, true - } - // a bare model name → the provider that lists it - for i := range c.Providers { - if c.Providers[i].HasModel(ref) { - cp := c.Providers[i] - cp.Model = ref - cp.applyModelPrice() - cp.applyModelOverride() - return &cp, true - } - } - return nil, false -} - -// ResolveModelWithFallback resolves a model reference to the canonical -// "provider/model" form used by the desktop runtime. If ref is stale or empty, -// it tries the user's configured default_model before falling back to the first -// configured provider — so preference isn't overwritten by iteration order. -func (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) { - ref = strings.TrimSpace(ref) - if ref != "" { - if e, found := c.ResolveModel(ref); found { - return e.Name + "/" + e.Model, false, true - } - } - // Before falling back to the first configured provider (which may not be the - // user's preferred choice), try the configured default_model. Skip when ref - // already WAS the DefaultModel (it already failed above, so retrying won't - // help) or when the default provider has no API key configured. - if ref != c.DefaultModel && c.DefaultModel != "" { - if e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() { - return e.Name + "/" + e.Model, true, true - } - } - for i := range c.Providers { - p := &c.Providers[i] - // Skip providers with no models or no API key: falling back onto a keyless - // provider just boots the tab onto something that fails on first use. Mirrors - // the Configured() gate the provider-removal/selection paths already apply. - if len(p.ModelList()) == 0 || !p.Configured() { - continue - } - return p.Name + "/" + p.DefaultModel(), true, true - } - return "", false, false -} - -// ResolveNewSessionChatModel selects the model for a newly-created chat -// session. Configured candidates win; if every chat candidate is keyless, the -// valid default (or first chat model) is preserved so callers can surface their -// existing missing-key recovery UI. An unknown default is also preserved for -// the CLI's actionable configuration error. Provider order is otherwise stable. -func (c *Config) ResolveNewSessionChatModel() (resolvedRef string, fallback bool, ok bool) { - return c.resolveNewSessionChatModel(nil, true) -} - -func (c *Config) resolveNewSessionChatModel(providerAllowed func(string) bool, preserveUnknownDefault bool) (resolvedRef string, fallback bool, ok bool) { - if c == nil { - return "", false, false - } - if providerAllowed == nil { - providerAllowed = func(string) bool { return true } - } - - def := strings.TrimSpace(c.DefaultModel) - keylessDefault := "" - if def != "" { - if entry, found := c.ResolveModel(def); found { - if providerAllowed(entry.Name) && IsLikelyChatModel(entry.Model) { - if entry.Configured() { - return def, false, true - } - keylessDefault = def - } - } else if preserveUnknownDefault { - // CLI/boot callers need the stale value intact so their existing - // unknown-model error can name it and explain the providers that - // replaced it. Desktop uses its recovery UI and does not preserve it. - return def, false, true - } - } - - keylessFallback := "" - for i := range c.Providers { - p := &c.Providers[i] - if !providerAllowed(p.Name) { - continue - } - chatModels := p.ChatModelList() - if len(chatModels) == 0 { - continue - } - model := chatModels[0] - for _, candidate := range chatModels { - if candidate == p.DefaultModel() { - model = candidate - break - } - } - resolved := p.Name + "/" + model - if p.Configured() { - return resolved, true, true - } - if keylessFallback == "" { - keylessFallback = resolved - } - } - if keylessDefault != "" { - return keylessDefault, false, true - } - if keylessFallback != "" { - return keylessFallback, true, true - } - return "", false, false -} - -// ResolveDesktopNewSessionModel selects the model for a newly-created desktop -// session. It shares the chat-model fallback policy with other frontends while -// limiting candidates to providers exposed by the desktop access catalog. -func (c *Config) ResolveDesktopNewSessionModel() (resolvedRef string, fallback bool, ok bool) { - if c == nil { - return "", false, false - } - access := desktopProviderAccessMap(c.Desktop.ProviderAccess) - return c.resolveNewSessionChatModel(func(name string) bool { - return c.Desktop.ProviderAccess == nil || access[strings.TrimSpace(name)] - }, false) + return atomicWriteToConfigFile(path, RenderTOMLForScope(c, renderScopeForPath(path)), configFilePerm(path)) } // APIKey resolves the entry's API key from its api_key_env. diff --git a/internal/config/edit.go b/internal/config/edit.go index 2f781765a7..50a7f8ec85 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -202,17 +202,6 @@ func ProviderEntriesConfigEqual(a, b ProviderEntry) bool { return reflect.DeepEqual(ProviderEntryConfigSnapshot(a), ProviderEntryConfigSnapshot(b)) } -// SetProviderEffort updates a provider's provider-specific thinking effort knob. -func (c *Config) SetProviderEffort(name, effort string) error { - for i := range c.Providers { - if c.Providers[i].Name == name { - c.Providers[i].Effort = normalizeStoredEffort(effort) - return nil - } - } - return fmt.Errorf("set provider effort: no provider %q", name) -} - // SetLanguage pins the CLI UI/model language; empty/auto clears the override so runtime detection falls back to REASONIX_LANG / locale. func (c *Config) SetLanguage(lang string) error { switch strings.ToLower(strings.TrimSpace(lang)) { @@ -1575,6 +1564,9 @@ func (c *Config) SaveTo(path string) error { return fmt.Errorf("save config loaded from %q: %w", path, c.editLoadErr) } scope := renderScopeForPath(path) + if scope != RenderScopeProject { + c.prepareUserPersist() + } if scope == RenderScopeUser { if err := currentUserConfigEditLockError(); err != nil { return fmt.Errorf("save user config: %w", err) diff --git a/internal/config/load.go b/internal/config/load.go index 2818301867..3d48d3c3b2 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -145,13 +145,7 @@ func loadForRoot(root string, opts loadForRootOptions) (*Config, error) { cfg.systemPromptFileSource = promptFileSourceUser } userDefaultModel := cfg.DefaultModel - globalCLI := cfg.CLI - globalSecrets := cfg.Secrets - globalRemote := cfg.Remote.Clone() - globalDesktopLanguage := cfg.Desktop.Language - globalPricingCurrency := cfg.Desktop.Currency - globalBillingDisplayCurrency := cfg.Billing.DisplayCurrency - globalTelemetry, globalLegacyAnchorSafetyGate := cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate + pins := captureUserGlobalPins(cfg) tomlSources = append(tomlSources, projectTOML) projectMeta, err := mergeTOML(cfg, projectTOML) @@ -168,25 +162,7 @@ func loadForRoot(root string, opts loadForRootOptions) (*Config, error) { } else if projectMeta.IsDefined("agent", "system_prompt_file") { cfg.systemPromptFileSource = promptFileSourceProject } - // The native CLI update channel controls the one user-installed binary. - // A repository-local reasonix.toml must never switch that global choice. - cfg.CLI = globalCLI - // Secret protection is a user-global security control: a cloned repo's - // reasonix.toml must not be able to flip on the workflow-breaking env/path - // protections. - cfg.Secrets = globalSecrets - // Remote SSH hosts are equally user-global: a cloned repo's reasonix.toml - // must not be able to inject hosts, jump chains, or port forwards that - // steer where Reasonix opens connections. - cfg.Remote = globalRemote - // Desktop language and pricing currency are user-level regional preferences. - // A repository must not be able to alter how the user's spend is shown. - cfg.Desktop.Language = globalDesktopLanguage - cfg.Desktop.Currency = globalPricingCurrency - cfg.Billing.DisplayCurrency = globalBillingDisplayCurrency - // CLI telemetry is an explicit user-global privacy choice. Project config - // cannot opt a user in or out, including when the global value is absent. - cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate = globalTelemetry, globalLegacyAnchorSafetyGate + applyUserGlobalPins(cfg, pins, projectTOML, err == nil && projectMeta.IsDefined("provider_accounts")) // TOML decoding replaces [[plugins]] wholesale, so cfg.Plugins now holds // only the last file's. Re-merge by name across all sources (later wins) so a // project reasonix.toml doesn't drop the global config's MCP servers. @@ -820,6 +796,7 @@ func normalizeConfigForEdit(cfg *Config) bool { applyDeepSeekOfficialDefaultPricing(cfg) backfillDeepSeekOfficialPrices(cfg) normalizeEffortConfig(cfg) + ensureProviderAccounts(cfg) return changed } diff --git a/internal/config/load_normalize.go b/internal/config/load_normalize.go index d6b0b0ffbe..4266d70784 100644 --- a/internal/config/load_normalize.go +++ b/internal/config/load_normalize.go @@ -26,5 +26,6 @@ func normalizeLoadedConfig(cfg *Config) error { backfillDeepSeekOfficialPrices(cfg) normalizeEffortConfig(cfg) backfillDeepSeekPro(cfg) + ensureProviderAccounts(cfg) return nil } diff --git a/internal/config/load_user_pins.go b/internal/config/load_user_pins.go new file mode 100644 index 0000000000..5212e19e49 --- /dev/null +++ b/internal/config/load_user_pins.go @@ -0,0 +1,41 @@ +package config + +type userGlobalConfigPins struct { + CLI CLIConfig + Secrets SecretsConfig + Remote RemoteConfig + DesktopLanguage string + PricingCurrency string + BillingCurrency string + Telemetry TelemetryConfig + LegacyAnchor bool + ProviderAccounts []ProviderAccount +} + +func captureUserGlobalPins(cfg *Config) userGlobalConfigPins { + return userGlobalConfigPins{ + CLI: cfg.CLI, + Secrets: cfg.Secrets, + Remote: cfg.Remote.Clone(), + DesktopLanguage: cfg.Desktop.Language, + PricingCurrency: cfg.Desktop.Currency, + BillingCurrency: cfg.Billing.DisplayCurrency, + Telemetry: cfg.Telemetry, + LegacyAnchor: cfg.Agent.LegacyAnchorSafetyGate, + ProviderAccounts: cloneProviderAccounts(cfg.ProviderAccounts), + } +} + +func applyUserGlobalPins(cfg *Config, pins userGlobalConfigPins, projectPath string, projectDeclaredAccounts bool) { + cfg.CLI = pins.CLI + cfg.Secrets = pins.Secrets + cfg.Remote = pins.Remote + cfg.Desktop.Language = pins.DesktopLanguage + cfg.Desktop.Currency = pins.PricingCurrency + cfg.Billing.DisplayCurrency = pins.BillingCurrency + cfg.Telemetry, cfg.Agent.LegacyAnchorSafetyGate = pins.Telemetry, pins.LegacyAnchor + if projectDeclaredAccounts { + cfg.addLoadWarning("project config " + projectPath + " declared provider_accounts; provider accounts are user-global and were ignored") + } + cfg.ProviderAccounts = pins.ProviderAccounts +} diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 7261b89b51..a689fdd2a2 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -495,7 +495,7 @@ command = "legacy-bin" t.Fatalf("read migrated config: %v", err) } text := string(got) - for _, want := range []string{`config_version = 7`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} { + for _, want := range []string{`config_version = 8`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} { if !strings.Contains(text, want) { t.Fatalf("migrated TOML missing %q:\n%s", want, text) } diff --git a/internal/config/pricing.go b/internal/config/pricing.go index 49a8f626d0..a6d7513b54 100644 --- a/internal/config/pricing.go +++ b/internal/config/pricing.go @@ -250,6 +250,7 @@ const ( retiredAutoPlanConfigVersion = 5 billingSplitConfigVersion = 6 deepSeekScheduledPricingConfigVersion = 7 + providerAccountsUpgradeConfigVersion = providerAccountsConfigVersion ) // ApplyUserConfigUpgradesOnStartup applies one-time startup migrations. It @@ -310,6 +311,10 @@ func ApplyUserConfigUpgradesOnStartup(path string) (bool, error) { // remain user-owned on later startups instead of being reconsidered. changed = true } + if header.ConfigVersion < providerAccountsUpgradeConfigVersion { + ensureProviderAccounts(cfg) + changed = true + } if !changed { return false, nil } diff --git a/internal/config/provider_account.go b/internal/config/provider_account.go new file mode 100644 index 0000000000..244d4cd6d1 --- /dev/null +++ b/internal/config/provider_account.go @@ -0,0 +1,296 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" + "unicode" +) + +const ( + MainProviderAccountID = "main" + maxProviderAccountIDLen = 32 + legacyAccountIDPrefix = "legacy-" + providerAccountsConfigVersion = 8 +) + +var providerAccountIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) + +// ProviderAccount is one independently credentialed account for a curated +// provider family. Secrets stay in Reasonix home .env under APIKeyEnv. +type ProviderAccount struct { + ProviderID string `toml:"provider_id"` + PresetID string `toml:"preset_id,omitempty"` + ID string `toml:"id"` + Label string `toml:"label"` + APIKeyEnv string `toml:"api_key_env"` + Enabled *bool `toml:"enabled,omitempty"` + Default bool `toml:"default,omitempty"` + Retired bool `toml:"retired,omitempty"` +} + +func (a ProviderAccount) IsEnabled() bool { + return !a.Retired && (a.Enabled == nil || *a.Enabled) +} + +func (a ProviderAccount) key() providerAccountKey { + return providerAccountKey{ProviderID: a.ProviderID, ID: a.ID} +} + +type providerAccountKey struct { + ProviderID string + ID string +} + +var providerAccountLabelAliases = map[string]string{ + "main": MainProviderAccountID, + "default": MainProviderAccountID, + "primary": MainProviderAccountID, + "主账号": MainProviderAccountID, + "主帐户": MainProviderAccountID, + "默认": MainProviderAccountID, + "默认账号": MainProviderAccountID, + "backup": "backup", + "spare": "backup", + "备用": "backup", + "备用账号": "backup", + "team": "team", + "团队": "team", + "团队账号": "team", + "personal": "personal", + "个人": "personal", + "个人账号": "personal", +} + +func IsProviderAccountID(id string) bool { + return providerAccountIDPattern.MatchString(strings.TrimSpace(id)) +} + +func SuggestProviderAccountID(providerID, label string) string { + label = strings.TrimSpace(label) + if alias, ok := providerAccountLabelAliases[label]; ok { + return alias + } + if alias, ok := providerAccountLabelAliases[strings.ToLower(label)]; ok { + return alias + } + if slug := slugifyProviderAccountID(label); slug != "" { + return slug + } + sum := sha256.Sum256([]byte(strings.TrimSpace(providerID) + "\x00" + label)) + return "a" + hex.EncodeToString(sum[:4])[:7] +} + +func slugifyProviderAccountID(label string) string { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(strings.TrimSpace(label)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '_' || r == '-': + if b.Len() > 0 && !lastDash { + b.WriteByte(byte(r)) + lastDash = true + } + case unicode.IsSpace(r): + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + slug := strings.Trim(b.String(), "-_") + if len(slug) > maxProviderAccountIDLen { + slug = slug[:maxProviderAccountIDLen] + slug = strings.Trim(slug, "-_") + } + if !IsProviderAccountID(slug) { + return "" + } + return slug +} + +func uniqueProviderAccountID(providerID, suggested string, used map[providerAccountKey]bool) string { + suggested = strings.TrimSpace(suggested) + if !IsProviderAccountID(suggested) { + suggested = SuggestProviderAccountID(providerID, suggested) + } + if !used[providerAccountKey{ProviderID: providerID, ID: suggested}] { + return suggested + } + for i := 2; i < 100; i++ { + candidate := fmt.Sprintf("%s-%d", suggested, i) + if len(candidate) > maxProviderAccountIDLen { + candidate = suggested + if len(candidate) > maxProviderAccountIDLen-3 { + candidate = candidate[:maxProviderAccountIDLen-3] + } + candidate = fmt.Sprintf("%s-%d", strings.Trim(candidate, "-_"), i) + } + if IsProviderAccountID(candidate) && !used[providerAccountKey{ProviderID: providerID, ID: candidate}] { + return candidate + } + } + sum := sha256.Sum256([]byte(providerID + "\x00" + suggested)) + return "a" + hex.EncodeToString(sum[:4])[:7] +} + +func SuggestAccountAPIKeyEnv(baseEnv, accountID string, used map[string]bool) string { + baseEnv = strings.TrimSpace(baseEnv) + accountID = strings.TrimSpace(accountID) + if accountID == "" || accountID == MainProviderAccountID { + if baseEnv != "" && IsValidCredentialKey(baseEnv) && !used[baseEnv] { + return baseEnv + } + } + suffix := strings.ToUpper(strings.ReplaceAll(accountID, "-", "_")) + if suffix == "" { + suffix = "ACCOUNT" + } + candidate := baseEnv + if candidate == "" { + candidate = "PROVIDER_API_KEY" + } + if !strings.HasSuffix(candidate, "_"+suffix) { + candidate += "_" + suffix + } + if IsValidCredentialKey(candidate) && !used[candidate] { + return candidate + } + sum := sha256.Sum256([]byte(candidate)) + hashed := candidate + "_" + strings.ToUpper(hex.EncodeToString(sum[:3])) + if IsValidCredentialKey(hashed) && !used[hashed] { + return hashed + } + return candidate +} + +func uniqueProviderName(base string, taken map[string]bool) string { + base = strings.TrimSpace(base) + if base == "" { + base = "provider" + } + if !taken[base] { + return base + } + sum := sha256.Sum256([]byte(base)) + short := hex.EncodeToString(sum[:3]) + candidate := base + "-" + short + if !taken[candidate] { + return candidate + } + for i := 2; i < 100; i++ { + candidate = fmt.Sprintf("%s-%s-%d", base, short, i) + if !taken[candidate] { + return candidate + } + } + return base + "-" + hex.EncodeToString(sum[:8]) +} + +func validateProviderAccount(a ProviderAccount) error { + if strings.TrimSpace(a.ProviderID) == "" { + return fmt.Errorf("provider account: provider_id is required") + } + if !IsProviderAccountID(a.ID) { + return fmt.Errorf("provider account %s/%s: id must match %s", a.ProviderID, a.ID, providerAccountIDPattern.String()) + } + if strings.TrimSpace(a.Label) == "" { + return fmt.Errorf("provider account %s/%s: label is required", a.ProviderID, a.ID) + } + if env := strings.TrimSpace(a.APIKeyEnv); env != "" && !IsValidCredentialKey(env) { + return fmt.Errorf("provider account %s/%s: api_key_env %q is not a valid environment variable name", a.ProviderID, a.ID, env) + } + return nil +} + +func cloneProviderAccount(a ProviderAccount) ProviderAccount { + if a.Enabled != nil { + value := *a.Enabled + a.Enabled = &value + } + return a +} + +func cloneProviderAccounts(in []ProviderAccount) []ProviderAccount { + if in == nil { + return nil + } + out := make([]ProviderAccount, 0, len(in)) + for _, a := range in { + out = append(out, cloneProviderAccount(a)) + } + return out +} + +func stampAccountMetadata(e *ProviderEntry, account ProviderAccount, routeID string) { + if e == nil { + return + } + e.AccountProviderID = account.ProviderID + e.AccountID = account.ID + e.AccountRouteID = strings.TrimSpace(routeID) + e.AccountLabel = account.Label +} + +func (c *Config) lookupProviderAccount(providerID, accountID string) (int, ProviderAccount, bool) { + if c == nil { + return -1, ProviderAccount{}, false + } + providerID = strings.TrimSpace(providerID) + accountID = strings.TrimSpace(accountID) + for i, a := range c.ProviderAccounts { + if a.ProviderID == providerID && a.ID == accountID { + return i, a, true + } + } + return -1, ProviderAccount{}, false +} + +func (c *Config) providerAccountUsedIDs() map[providerAccountKey]bool { + used := map[providerAccountKey]bool{} + if c == nil { + return used + } + for _, a := range c.ProviderAccounts { + used[a.key()] = true + } + return used +} + +func (c *Config) usedAPIKeyEnvs() map[string]bool { + used := map[string]bool{} + if c == nil { + return used + } + for _, a := range c.ProviderAccounts { + if env := strings.TrimSpace(a.APIKeyEnv); env != "" { + used[env] = true + } + } + return used +} + +func (c *Config) userOwnedProvider(name string) bool { + if c == nil { + return false + } + if len(c.providerSources) == 0 { + return true + } + return c.providerSources[providerMergeKey(ProviderEntry{Name: name})] != providerSourceProject +} + +func (c *Config) markUserProvider(name string) { + if c == nil || strings.TrimSpace(name) == "" { + return + } + if c.providerSources == nil { + return + } + c.providerSources[strings.TrimSpace(name)] = providerSourceUser +} diff --git a/internal/config/provider_account_expand.go b/internal/config/provider_account_expand.go new file mode 100644 index 0000000000..ba9eedb9a4 --- /dev/null +++ b/internal/config/provider_account_expand.go @@ -0,0 +1,309 @@ +package config + +import ( + "fmt" + "strings" +) + +func ExpandProviderAccount(c *Config, account ProviderAccount) ([]ProviderEntry, error) { + if err := validateProviderAccount(account); err != nil { + return nil, err + } + templates := accountRouteTemplates(account.ProviderID) + if len(templates) == 0 { + return nil, fmt.Errorf("unknown provider family %q", account.ProviderID) + } + taken := map[string]bool{} + if c != nil { + for _, p := range c.Providers { + if p.AccountProviderID == account.ProviderID && p.AccountID == account.ID { + continue + } + if name := strings.TrimSpace(p.Name); name != "" { + taken[name] = true + } + } + } + out := make([]ProviderEntry, 0, len(templates)) + for _, tmpl := range templates { + if tmpl.MainOnly && account.ID != MainProviderAccountID { + continue + } + if tmpl.ExtraOnly && account.ID == MainProviderAccountID { + continue + } + if strings.TrimSpace(tmpl.Entry.Name) == "" && strings.TrimSpace(tmpl.Entry.Kind) == "" { + continue + } + if tmpl.Optional && !accountWantsOptionalRoute(c, account, tmpl) { + continue + } + existing := findAccountRouteEntry(c, account, tmpl.RouteID) + name := tmpl.BaseName + if account.ID != MainProviderAccountID || tmpl.ExtraOnly { + name = tmpl.BaseName + "--" + account.ID + } + if existing != nil { + name = existing.Name + } else { + name = uniqueProviderName(name, taken) + } + taken[name] = true + var entry ProviderEntry + if existing != nil { + entry = cloneProviderEntry(*existing) + if strings.TrimSpace(entry.APIKeyEnv) == "" { + entry.APIKeyEnv = account.APIKeyEnv + } + } else { + entry = cloneProviderEntry(tmpl.Entry) + entry.Name = name + entry.APIKeyEnv = account.APIKeyEnv + if preset := strings.TrimSpace(account.PresetID); preset != "" { + entry.PresetID = preset + entry.PresetVersion = ProviderPresetVersion + } + } + stampAccountMetadata(&entry, account, tmpl.RouteID) + out = append(out, entry) + } + return out, nil +} + +func ReconcileProviderAccounts(c *Config) (changed bool, warnings []string, err error) { + if c == nil { + return false, nil, nil + } + warnings = append(warnings, normalizeProviderAccountList(c)...) + if inferred := inferProviderAccounts(c); inferred { + changed = true + } + if attached := attachOrphanCuratedProviders(c); attached { + changed = true + } + for _, account := range c.ProviderAccounts { + if account.Retired { + continue + } + entries, expandErr := ExpandProviderAccount(c, account) + if expandErr != nil { + warnings = append(warnings, expandErr.Error()) + continue + } + seeded := countAccountRoutes(c, account) > 0 + for _, generated := range entries { + if !c.userOwnedProvider(generated.Name) && providerIndexByName(c, generated.Name) >= 0 { + continue + } + idx := indexAccountRouteEntry(c, account, generated.AccountRouteID) + if idx < 0 { + idx = providerIndexByName(c, generated.Name) + } + if idx >= 0 { + merged := preserveUserProviderFields(c.Providers[idx], generated) + if !ProviderEntriesConfigEqual(c.Providers[idx], merged) { + c.Providers[idx] = merged + changed = true + } + continue + } + if seeded { + continue + } + if err := c.UpsertProvider(generated); err != nil { + return changed, warnings, err + } + c.markUserProvider(generated.Name) + changed = true + } + } + return changed, warnings, nil +} + +func ProviderAccountForEntry(c *Config, e ProviderEntry) (ProviderAccount, bool) { + providerID, accountID, ok := ProviderAccountIdentity(e) + if !ok { + if group, route, _, found := curatedProviderIdentity(e); found { + providerID, accountID = group, MainProviderAccountID + _ = route + ok = true + } + } + if !ok { + return ProviderAccount{}, false + } + if c != nil { + if _, account, found := c.lookupProviderAccount(providerID, accountID); found { + return cloneProviderAccount(account), true + } + } + label := strings.TrimSpace(e.AccountLabel) + if label == "" { + label = defaultAccountLabel(accountID) + } + return ProviderAccount{ + ProviderID: providerID, + ID: accountID, + Label: label, + APIKeyEnv: e.APIKeyEnv, + }, true +} + +func ProviderAccountIdentity(e ProviderEntry) (providerID, accountID string, ok bool) { + providerID = strings.TrimSpace(e.AccountProviderID) + accountID = strings.TrimSpace(e.AccountID) + if providerID == "" || accountID == "" { + return "", "", false + } + return providerID, accountID, true +} + +func preserveUserProviderFields(existing, generated ProviderEntry) ProviderEntry { + out := cloneProviderEntry(existing) + if strings.TrimSpace(out.AccountProviderID) == "" { + out.AccountProviderID = generated.AccountProviderID + } + if strings.TrimSpace(out.AccountID) == "" { + out.AccountID = generated.AccountID + } + if strings.TrimSpace(out.AccountRouteID) == "" { + out.AccountRouteID = generated.AccountRouteID + } + if strings.TrimSpace(out.AccountLabel) == "" || (out.AccountProviderID == generated.AccountProviderID && out.AccountID == generated.AccountID) { + out.AccountLabel = generated.AccountLabel + } + if strings.TrimSpace(out.APIKeyEnv) == "" { + out.APIKeyEnv = generated.APIKeyEnv + } + return out +} + +func accountWantsOptionalRoute(c *Config, account ProviderAccount, tmpl accountRouteTemplate) bool { + if findAccountRouteEntry(c, account, tmpl.RouteID) != nil { + return true + } + presetID := strings.TrimSpace(account.PresetID) + if presetID == "" || c == nil { + return false + } + preset, ok := CuratedProviderPreset(presetID) + if !ok { + return false + } + for _, e := range preset.Entries { + if strings.TrimSpace(e.Name) == tmpl.BaseName || strings.TrimSpace(e.Name) == tmpl.RouteID { + return true + } + } + return false +} + +func findAccountRouteEntry(c *Config, account ProviderAccount, routeID string) *ProviderEntry { + idx := indexAccountRouteEntry(c, account, routeID) + if idx < 0 { + return nil + } + return &c.Providers[idx] +} + +func indexAccountRouteEntry(c *Config, account ProviderAccount, routeID string) int { + if c == nil { + return -1 + } + routeID = strings.TrimSpace(routeID) + for i := range c.Providers { + p := &c.Providers[i] + if p.AccountProviderID == account.ProviderID && p.AccountID == account.ID && strings.TrimSpace(p.AccountRouteID) == routeID { + return i + } + } + if account.ID == MainProviderAccountID { + for i := range c.Providers { + p := &c.Providers[i] + if strings.TrimSpace(p.AccountProviderID) != "" { + continue + } + if group, route, _, ok := curatedProviderIdentity(*p); ok && group == account.ProviderID && route == routeID { + return i + } + } + } + return -1 +} + +func countAccountRoutes(c *Config, account ProviderAccount) int { + if c == nil { + return 0 + } + n := 0 + for i := range c.Providers { + p := c.Providers[i] + if p.AccountProviderID == account.ProviderID && p.AccountID == account.ID { + n++ + continue + } + if strings.TrimSpace(p.AccountProviderID) != "" { + continue + } + if group, _, _, ok := curatedProviderIdentity(p); ok && group == account.ProviderID && account.ID == MainProviderAccountID { + n++ + } + } + return n +} + +func providerIndexByName(c *Config, name string) int { + if c == nil { + return -1 + } + name = strings.TrimSpace(name) + for i := range c.Providers { + if c.Providers[i].Name == name { + return i + } + } + return -1 +} + +func normalizeProviderAccountList(c *Config) []string { + if c == nil { + return nil + } + var warnings []string + seen := map[providerAccountKey]int{} + defaults := map[string]int{} + out := c.ProviderAccounts[:0] + for _, account := range c.ProviderAccounts { + account.ProviderID = strings.TrimSpace(account.ProviderID) + account.ID = strings.TrimSpace(account.ID) + account.Label = strings.TrimSpace(account.Label) + account.APIKeyEnv = strings.TrimSpace(account.APIKeyEnv) + account.PresetID = strings.TrimSpace(account.PresetID) + if err := validateProviderAccount(account); err != nil { + warnings = append(warnings, err.Error()) + continue + } + key := account.key() + if prev, dup := seen[key]; dup { + warnings = append(warnings, fmt.Sprintf("duplicate provider account %s/%s; keeping the first declaration", account.ProviderID, account.ID)) + _ = prev + continue + } + if account.Default { + if prev, exists := defaults[account.ProviderID]; exists { + warnings = append(warnings, fmt.Sprintf("multiple default accounts for %s; keeping %s", account.ProviderID, c.ProviderAccounts[prev].ID)) + account.Default = false + } else { + defaults[account.ProviderID] = len(out) + } + } + seen[key] = len(out) + out = append(out, account) + } + if len(out) == 0 { + c.ProviderAccounts = nil + } else { + c.ProviderAccounts = out + } + return warnings +} diff --git a/internal/config/provider_account_group.go b/internal/config/provider_account_group.go new file mode 100644 index 0000000000..72f2f254ca --- /dev/null +++ b/internal/config/provider_account_group.go @@ -0,0 +1,255 @@ +package config + +import "strings" + +type accountRouteTemplate struct { + RouteID string + BaseName string + Optional bool + MainOnly bool + ExtraOnly bool + Entry ProviderEntry +} + +func accountGroupIDForPresetID(id string) string { + id = strings.ToLower(strings.TrimSpace(id)) + if mapped, ok := presetAccountGroupIDs[id]; ok { + return mapped + } + if id == "" { + return "" + } + return id +} + +func (p ProviderPreset) resolvedAccountGroupID() string { + if id := strings.TrimSpace(p.AccountGroupID); id != "" { + return id + } + return accountGroupIDForPresetID(p.ID) +} + +var presetAccountGroupIDs = map[string]string{ + "deepseek-anthropic": "deepseek", + "deepseek-responses": "deepseek", + "opencode-go": "opencode-go", + "opencode-go-recommended": "opencode-go", + "opencode-go-anthropic": "opencode-go", + "opencode-go-responses": "opencode-go", + "opencode-go-deepseek-anthropic": "opencode-go", + "opencode-go-deepseek-responses": "opencode-go", + "longcat-openai": "longcat", + "longcat-anthropic": "longcat", + "minimax-cn-api": "minimax-cn", + "minimax-cn-anthropic": "minimax-cn", + "minimax-global-api": "minimax-global", + "minimax-global-anthropic": "minimax-global", + "glm-coding-plan-cn": "glm-coding-plan-cn", + "glm-coding-plan-cn-anthropic": "glm-coding-plan-cn", + "zai-coding-plan-global": "zai-coding-plan-global", + "zai-coding-plan-global-anthropic": "zai-coding-plan-global", + "mimo-api": "mimo-api", + "mimo-anthropic": "mimo-api", + "mimo-token-plan-cn": "mimo-token-plan-cn", + "mimo-token-plan-cn-anthropic": "mimo-token-plan-cn", + "mimo-token-plan-sgp": "mimo-token-plan-sgp", + "mimo-token-plan-sgp-anthropic": "mimo-token-plan-sgp", + "mimo-token-plan-ams": "mimo-token-plan-ams", + "mimo-token-plan-ams-anthropic": "mimo-token-plan-ams", + "qwen-coding-plan-cn": "qwen-coding-plan-cn", + "qwen-coding-plan-cn-anthropic": "qwen-coding-plan-cn", + "qwen-coding-plan-global": "qwen-coding-plan-global", + "qwen-coding-plan-global-anthropic": "qwen-coding-plan-global", + "stepfun": "stepfun", + "stepfun-anthropic": "stepfun", + "stepfun-responses": "stepfun", + "stepfun-api": "stepfun-api", + "stepfun-api-anthropic": "stepfun-api", + "scnet": "scnet", + "scnet-anthropic": "scnet", +} + +func knownProviderIdentity(name string) (groupID, routeID, baseName string, ok bool) { + switch strings.TrimSpace(name) { + case "deepseek-flash", "deepseek-pro", "deepseek", "deepseek-anthropic", "deepseek-responses": + base := strings.TrimSpace(name) + if base == "deepseek-anthropic" { + base = "deepseek" + } + return "deepseek", base, strings.TrimSpace(name), true + } + return "", "", "", false +} + +func curatedProviderIdentity(e ProviderEntry) (groupID, routeID, baseName string, ok bool) { + if group, route, ok := ProviderAccountIdentity(e); ok { + base := strings.TrimSpace(e.Name) + if route != "" { + base = route + } + return group, route, base, true + } + if id := strings.TrimSpace(e.PresetID); id != "" { + if group := accountGroupIDForPresetID(id); group != "" { + route := strings.TrimSpace(e.Name) + if preset, found := CuratedProviderPreset(id); found && len(preset.Entries) == 1 { + route = strings.TrimSpace(preset.Entries[0].Name) + } + if group == "deepseek" && (route == "deepseek-anthropic" || route == "") { + route = "deepseek" + } + return group, route, strings.TrimSpace(e.Name), true + } + } + if group, route, base, ok := knownProviderIdentity(e.Name); ok { + return group, route, base, true + } + for _, preset := range curatedProviderPresets { + group := accountGroupIDForPresetID(preset.ID) + for _, ent := range preset.Entries { + if ent.Name == e.Name { + route := ent.Name + if group == "deepseek" && route == "deepseek-anthropic" { + route = "deepseek" + } + return group, route, ent.Name, true + } + } + } + if isOfficialDeepSeekProviderEntry(e) { + route := strings.TrimSpace(e.Name) + if route == "deepseek-anthropic" { + route = "deepseek" + } + return "deepseek", route, e.Name, true + } + return "", "", "", false +} + +func isOfficialDeepSeekProviderEntry(e ProviderEntry) bool { + if officialProviderKindFromName(e.Name) == "deepseek" { + return true + } + base := strings.TrimRight(strings.TrimSpace(e.BaseURL), "/") + return strings.EqualFold(base, strings.TrimRight(deepSeekAnthropicBaseURL, "/")) || + strings.EqualFold(base, "https://api.deepseek.com") || + strings.EqualFold(base, "https://api.deepseek.com/v1") +} + +func officialProviderKindFromName(name string) string { + switch strings.TrimSpace(name) { + case "deepseek", "deepseek-flash", "deepseek-pro", "deepseek-anthropic", "deepseek-responses": + return "deepseek" + default: + return "" + } +} + +func accountRouteTemplates(groupID string) []accountRouteTemplate { + groupID = strings.TrimSpace(groupID) + if groupID == "" { + return nil + } + if groupID == "deepseek" { + return deepSeekAccountRouteTemplates() + } + var out []accountRouteTemplate + seen := map[string]bool{} + addPreset := func(preset ProviderPreset) { + optional := preset.Optional + for _, e := range preset.Entries { + name := strings.TrimSpace(e.Name) + if name == "" || seen[name] { + continue + } + seen[name] = true + entry := cloneProviderEntry(e) + out = append(out, accountRouteTemplate{ + RouteID: name, + BaseName: name, + Optional: optional, + Entry: entry, + }) + } + } + if groupID == "opencode-go" { + if preset, ok := CuratedProviderPreset("opencode-go-recommended"); ok { + addPreset(preset) + } + } + for _, preset := range curatedProviderPresets { + if accountGroupIDForPresetID(preset.ID) != groupID { + continue + } + if preset.ID == "opencode-go-recommended" { + continue + } + addPreset(preset) + } + return out +} + +func deepSeekAccountRouteTemplates() []accountRouteTemplate { + var flash, pro ProviderEntry + for _, p := range Default().Providers { + switch p.Name { + case "deepseek-flash": + flash = cloneProviderEntry(p) + case "deepseek-pro": + pro = cloneProviderEntry(p) + } + } + combined := ProviderEntry{} + if preset, ok := CuratedProviderPreset("deepseek-anthropic"); ok && len(preset.Entries) > 0 { + combined = cloneProviderEntry(preset.Entries[0]) + combined.Name = "deepseek" + } + responses := ProviderEntry{} + if preset, ok := CuratedProviderPreset("deepseek-responses"); ok && len(preset.Entries) > 0 { + responses = cloneProviderEntry(preset.Entries[0]) + } + return []accountRouteTemplate{ + {RouteID: "deepseek-flash", BaseName: "deepseek-flash", MainOnly: true, Entry: flash}, + {RouteID: "deepseek-pro", BaseName: "deepseek-pro", MainOnly: true, Entry: pro}, + {RouteID: "deepseek", BaseName: "deepseek", ExtraOnly: true, Entry: combined}, + {RouteID: "deepseek-responses", BaseName: "deepseek-responses", Optional: true, Entry: responses}, + } +} + +func baseAPIKeyEnvForGroup(groupID string) string { + groupID = strings.TrimSpace(groupID) + if groupID == "deepseek" { + return "DEEPSEEK_API_KEY" + } + for _, preset := range curatedProviderPresets { + if accountGroupIDForPresetID(preset.ID) == groupID { + if env := strings.TrimSpace(preset.KeyEnv); env != "" { + return env + } + for _, e := range preset.Entries { + if env := strings.TrimSpace(e.APIKeyEnv); env != "" { + return env + } + } + } + } + return "" +} + +func defaultAccountLabel(accountID string) string { + switch accountID { + case MainProviderAccountID: + return "Main" + case "backup": + return "Backup" + case "team": + return "Team" + case "personal": + return "Personal" + default: + if strings.HasPrefix(accountID, legacyAccountIDPrefix) { + return "Legacy" + } + return accountID + } +} diff --git a/internal/config/provider_account_migrate.go b/internal/config/provider_account_migrate.go new file mode 100644 index 0000000000..e2a7025d7a --- /dev/null +++ b/internal/config/provider_account_migrate.go @@ -0,0 +1,172 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +func ensureProviderAccounts(c *Config) { + if c == nil { + return + } + _, warnings, err := ReconcileProviderAccounts(c) + if err != nil { + c.addLoadWarning(err.Error()) + } + for _, warning := range warnings { + c.addLoadWarning(warning) + } +} + +func (c *Config) prepareUserPersist() { + if c == nil { + return + } + ensureProviderAccounts(c) +} + +func inferProviderAccounts(c *Config) bool { + if c == nil || len(c.ProviderAccounts) > 0 { + return false + } + type group struct { + family string + keyEnv string + index []int + } + order := make([]group, 0, 4) + indexOf := map[string]int{} + for i := range c.Providers { + p := c.Providers[i] + if !c.userOwnedProvider(p.Name) { + continue + } + family, _, _, ok := curatedProviderIdentity(p) + if !ok { + continue + } + key := family + "\x00" + strings.TrimSpace(p.APIKeyEnv) + if idx, exists := indexOf[key]; exists { + order[idx].index = append(order[idx].index, i) + continue + } + indexOf[key] = len(order) + order = append(order, group{family: family, keyEnv: strings.TrimSpace(p.APIKeyEnv), index: []int{i}}) + } + if len(order) == 0 { + return false + } + usedIDs := c.providerAccountUsedIDs() + firstFamily := map[string]bool{} + changed := false + for _, g := range order { + id := MainProviderAccountID + label := defaultAccountLabel(id) + if firstFamily[g.family] { + id = legacyAccountID(g.keyEnv, g.family, usedIDs) + label = defaultAccountLabel(id) + } + firstFamily[g.family] = true + presetID := inferAccountPresetID(c, g.index) + account := ProviderAccount{ + ProviderID: g.family, + PresetID: presetID, + ID: id, + Label: label, + APIKeyEnv: g.keyEnv, + Default: id == MainProviderAccountID, + } + if account.APIKeyEnv == "" { + account.APIKeyEnv = baseAPIKeyEnvForGroup(g.family) + } + c.ProviderAccounts = append(c.ProviderAccounts, account) + usedIDs[account.key()] = true + for _, idx := range g.index { + routeID := strings.TrimSpace(c.Providers[idx].Name) + if _, route, _, ok := curatedProviderIdentity(c.Providers[idx]); ok && route != "" { + routeID = route + } + stampAccountMetadata(&c.Providers[idx], account, routeID) + } + changed = true + } + return changed +} + +func attachOrphanCuratedProviders(c *Config) bool { + if c == nil { + return false + } + changed := false + usedIDs := c.providerAccountUsedIDs() + for i := range c.Providers { + p := &c.Providers[i] + if strings.TrimSpace(p.AccountProviderID) != "" && strings.TrimSpace(p.AccountID) != "" { + continue + } + if !c.userOwnedProvider(p.Name) { + continue + } + family, routeID, _, ok := curatedProviderIdentity(*p) + if !ok { + continue + } + keyEnv := strings.TrimSpace(p.APIKeyEnv) + idx := -1 + for j, account := range c.ProviderAccounts { + if account.ProviderID == family && !account.Retired && (keyEnv == "" || strings.TrimSpace(account.APIKeyEnv) == keyEnv) { + idx = j + break + } + } + if idx < 0 { + id := MainProviderAccountID + if c.hasProviderFamilyAccount(family) { + id = uniqueProviderAccountID(family, SuggestProviderAccountID(family, keyEnv), usedIDs) + } + account := ProviderAccount{ + ProviderID: family, + PresetID: strings.TrimSpace(p.PresetID), + ID: id, + Label: defaultAccountLabel(id), + APIKeyEnv: keyEnv, + Default: !c.hasProviderFamilyDefault(family), + } + if account.APIKeyEnv == "" { + account.APIKeyEnv = baseAPIKeyEnvForGroup(family) + } + if err := validateProviderAccount(account); err != nil { + continue + } + c.ProviderAccounts = append(c.ProviderAccounts, account) + usedIDs[account.key()] = true + idx = len(c.ProviderAccounts) - 1 + changed = true + } + if routeID == "" { + routeID = p.Name + } + stampAccountMetadata(p, c.ProviderAccounts[idx], routeID) + changed = true + } + return changed +} + +func inferAccountPresetID(c *Config, indexes []int) string { + if c == nil { + return "" + } + for _, idx := range indexes { + if id := strings.TrimSpace(c.Providers[idx].PresetID); id != "" { + return id + } + } + return "" +} + +func legacyAccountID(keyEnv, family string, used map[providerAccountKey]bool) string { + sum := sha256.Sum256([]byte(family + "\x00" + keyEnv)) + id := legacyAccountIDPrefix + hex.EncodeToString(sum[:3]) + return uniqueProviderAccountID(family, id, used) +} diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go new file mode 100644 index 0000000000..3be6d7d343 --- /dev/null +++ b/internal/config/provider_account_mutate.go @@ -0,0 +1,216 @@ +package config + +import ( + "fmt" + "strings" +) + +func (c *Config) AddProviderAccount(providerID, presetID, label, apiKeyEnv string) (ProviderAccount, error) { + if c == nil { + return ProviderAccount{}, fmt.Errorf("add provider account: nil config") + } + ensureProviderAccounts(c) + providerID = strings.TrimSpace(providerID) + presetID = strings.TrimSpace(presetID) + label = strings.TrimSpace(label) + apiKeyEnv = strings.TrimSpace(apiKeyEnv) + if providerID == "" && presetID != "" { + if preset, ok := CuratedProviderPreset(presetID); ok { + providerID = preset.resolvedAccountGroupID() + } else { + providerID = accountGroupIDForPresetID(presetID) + } + } + if providerID == "" { + return ProviderAccount{}, fmt.Errorf("provider account: provider_id is required") + } + if label == "" { + label = defaultAccountLabel(MainProviderAccountID) + } + usedIDs := c.providerAccountUsedIDs() + id := SuggestProviderAccountID(providerID, label) + if !c.hasProviderFamilyAccount(providerID) { + id = MainProviderAccountID + } + id = uniqueProviderAccountID(providerID, id, usedIDs) + if apiKeyEnv == "" { + apiKeyEnv = SuggestAccountAPIKeyEnv(baseAPIKeyEnvForGroup(providerID), id, c.usedAPIKeyEnvs()) + } + account := ProviderAccount{ + ProviderID: providerID, + PresetID: presetID, + ID: id, + Label: label, + APIKeyEnv: apiKeyEnv, + Default: !c.hasProviderFamilyDefault(providerID), + } + if err := validateProviderAccount(account); err != nil { + return ProviderAccount{}, err + } + c.ProviderAccounts = append(c.ProviderAccounts, account) + if _, _, err := ReconcileProviderAccounts(c); err != nil { + c.ProviderAccounts = c.ProviderAccounts[:len(c.ProviderAccounts)-1] + return ProviderAccount{}, err + } + return account, nil +} + +func (c *Config) SetProviderAccountDefault(providerID, accountID string) error { + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("set default account: no account %s/%s", providerID, accountID) + } + if account.Retired || !account.IsEnabled() { + return fmt.Errorf("set default account: %s/%s is not available", providerID, accountID) + } + for i := range c.ProviderAccounts { + if c.ProviderAccounts[i].ProviderID == account.ProviderID { + c.ProviderAccounts[i].Default = i == idx + } + } + return nil +} + +func (c *Config) SetProviderAccountEnabled(providerID, accountID string, enabled bool) error { + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("set account enabled: no account %s/%s", providerID, accountID) + } + if account.Retired { + return fmt.Errorf("set account enabled: %s/%s is retired", providerID, accountID) + } + c.ProviderAccounts[idx].Enabled = boolPointer(enabled) + if !enabled && account.Default { + c.ProviderAccounts[idx].Default = false + c.ensureFamilyDefault(account.ProviderID) + } + return nil +} + +func (c *Config) RenameProviderAccount(providerID, accountID, label string) error { + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("rename account: no account %s/%s", providerID, accountID) + } + label = strings.TrimSpace(label) + if label == "" { + return fmt.Errorf("rename account: label is required") + } + c.ProviderAccounts[idx].Label = label + for i := range c.Providers { + if c.Providers[i].AccountProviderID == account.ProviderID && c.Providers[i].AccountID == account.ID { + c.Providers[i].AccountLabel = label + } + } + return nil +} + +func (c *Config) RetireProviderAccount(providerID, accountID string) error { + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("retire account: no account %s/%s", providerID, accountID) + } + if refs := c.ProviderAccountConfigRefs(providerID, accountID); len(refs) > 0 { + return fmt.Errorf("retire account %s/%s: still referenced by %s", providerID, accountID, strings.Join(refs, ", ")) + } + c.ProviderAccounts[idx].Retired = true + c.ProviderAccounts[idx].Enabled = boolPointer(false) + c.ProviderAccounts[idx].Default = false + c.ensureFamilyDefault(account.ProviderID) + return nil +} + +func (c *Config) SetProviderAccountKeyEnv(providerID, accountID, apiKeyEnv string) error { + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("set account key: no account %s/%s", providerID, accountID) + } + apiKeyEnv = strings.TrimSpace(apiKeyEnv) + if apiKeyEnv == "" || !IsValidCredentialKey(apiKeyEnv) { + return fmt.Errorf("set account key: api_key_env %q is not a valid environment variable name", apiKeyEnv) + } + c.ProviderAccounts[idx].APIKeyEnv = apiKeyEnv + for i := range c.Providers { + if c.Providers[i].AccountProviderID == account.ProviderID && c.Providers[i].AccountID == account.ID { + c.Providers[i].APIKeyEnv = apiKeyEnv + } + } + return nil +} + +func (c *Config) ProviderAccountConfigRefs(providerID, accountID string) []string { + entries, ok := c.ResolveAccountProvider(providerID, accountID) + if !ok { + return nil + } + names := map[string]bool{} + for _, e := range entries { + names[e.Name] = true + } + var refs []string + add := func(field, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + entry, found := c.ResolveModel(value) + if !found || !names[entry.Name] { + return + } + refs = append(refs, field) + } + add("default_model", c.DefaultModel) + add("agent.planner_model", c.Agent.PlannerModel) + add("agent.vision_model", c.Agent.VisionModel) + add("agent.subagent_model", c.Agent.SubagentModel) + add("agent.guardian_model", c.Agent.GuardianModel) + add("agent.recovery_model", c.Agent.RecoveryModel) + for skill, ref := range c.Agent.SubagentModels { + add("agent.subagent_models."+skill, ref) + } + add("bot.model", c.Bot.Model) + for _, conn := range c.Bot.Connections { + add("bot.connections."+conn.ID, conn.Model) + } + return refs +} + +func (c *Config) hasProviderFamilyAccount(providerID string) bool { + for _, account := range c.ProviderAccounts { + if account.ProviderID == providerID && !account.Retired { + return true + } + } + return false +} + +func (c *Config) hasProviderFamilyDefault(providerID string) bool { + for _, account := range c.ProviderAccounts { + if account.ProviderID == providerID && account.Default && account.IsEnabled() { + return true + } + } + return false +} + +func (c *Config) ensureFamilyDefault(providerID string) { + if c.hasProviderFamilyDefault(providerID) { + return + } + for i, account := range c.ProviderAccounts { + if account.ProviderID == providerID && account.IsEnabled() { + c.ProviderAccounts[i].Default = true + return + } + } +} + +func (c *Config) SetProviderEffort(name, effort string) error { + for i := range c.Providers { + if c.Providers[i].Name == name { + c.Providers[i].Effort = normalizeStoredEffort(effort) + return nil + } + } + return fmt.Errorf("set provider effort: no provider %q", name) +} diff --git a/internal/config/provider_account_render.go b/internal/config/provider_account_render.go new file mode 100644 index 0000000000..e4a74c72cd --- /dev/null +++ b/internal/config/provider_account_render.go @@ -0,0 +1,167 @@ +package config + +import ( + "fmt" + "strings" + + "reasonix/internal/billing" +) + +func renderProviderAccounts(b *strings.Builder, c *Config, scope RenderScope) { + if c == nil || scope == RenderScopeProject || len(c.ProviderAccounts) == 0 { + return + } + b.WriteString("# Provider accounts are user-global. Project reasonix.toml may reference\n") + b.WriteString("# generated provider names but must not declare accounts or API keys.\n") + for _, a := range c.ProviderAccounts { + b.WriteString("[[provider_accounts]]\n") + fmt.Fprintf(b, "provider_id = %q\n", a.ProviderID) + if a.PresetID != "" { + fmt.Fprintf(b, "preset_id = %q\n", a.PresetID) + } + fmt.Fprintf(b, "id = %q\n", a.ID) + fmt.Fprintf(b, "label = %q\n", a.Label) + fmt.Fprintf(b, "api_key_env = %q\n", a.APIKeyEnv) + if a.Enabled != nil { + fmt.Fprintf(b, "enabled = %t\n", *a.Enabled) + } + if a.Default { + b.WriteString("default = true\n") + } + if a.Retired { + b.WriteString("retired = true\n") + } + b.WriteString("\n") + } +} + +func renderProviderEntries(b *strings.Builder, c *Config) { + if c == nil { + return + } + for _, p := range c.Providers { + renderOneProviderEntry(b, p) + } +} + +func renderOneProviderEntry(b *strings.Builder, p ProviderEntry) { + b.WriteString("[[providers]]\n") + fmt.Fprintf(b, "name = %q\n", p.Name) + fmt.Fprintf(b, "kind = %q\n", p.Kind) + fmt.Fprintf(b, "base_url = %q\n", p.BaseURL) + if p.ChatURL != "" { + fmt.Fprintf(b, "chat_url = %q # legacy OpenAI chat endpoint override\n", p.ChatURL) + } + if p.RequestURL != "" { + fmt.Fprintf(b, "request_url = %q # exact provider request URL; no path completion\n", p.RequestURL) + } + if len(p.Models) > 0 { + fmt.Fprintf(b, "models = %s\n", renderStringArray(p.Models)) + if p.Default != "" { + fmt.Fprintf(b, "default = %q\n", p.Default) + } + } else if p.Model != "" { + fmt.Fprintf(b, "model = %q\n", p.Model) + } + if p.ModelsURL != "" { + fmt.Fprintf(b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL) + } + fmt.Fprintf(b, "api_key_env = %q\n", p.APIKeyEnv) + if p.PresetID != "" { + fmt.Fprintf(b, "preset_id = %q # curated preset identity; settings UI uses it to avoid duplicate installs\n", p.PresetID) + } + if p.PresetVersion > 0 { + fmt.Fprintf(b, "preset_version = %d\n", p.PresetVersion) + } + if p.AccountProviderID != "" { + fmt.Fprintf(b, "account_provider_id = %q\n", p.AccountProviderID) + } + if p.AccountID != "" { + fmt.Fprintf(b, "account_id = %q\n", p.AccountID) + } + if p.AccountRouteID != "" { + fmt.Fprintf(b, "account_route_id = %q\n", p.AccountRouteID) + } + if p.AccountLabel != "" { + fmt.Fprintf(b, "account_label = %q\n", p.AccountLabel) + } + renderProviderEntryOptions(b, p) + b.WriteString("\n") +} + +func renderProviderEntryOptions(b *strings.Builder, p ProviderEntry) { + if len(p.Headers) > 0 { + fmt.Fprintf(b, "headers = %s # extra static request headers; keep secrets in api_key_env\n", renderStringMap(p.Headers)) + } + if len(p.ExtraBody) > 0 { + fmt.Fprintf(b, "extra_body = %s # extra top-level JSON request body fields for compatible gateways\n", renderAnyMap(p.ExtraBody)) + } + if p.AuthHeader { + b.WriteString("auth_header = true # Anthropic-compatible: send Authorization: Bearer instead of x-api-key\n") + } + if p.ResponsesMode != "" { + fmt.Fprintf(b, "responses_mode = %q # responses provider: stateless|stateful\n", p.ResponsesMode) + } + if p.ResponsesStateful != nil { + fmt.Fprintf(b, "responses_stateful = %t # legacy responses mode switch\n", *p.ResponsesStateful) + } + if p.BalanceURL != "" { + fmt.Fprintf(b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL) + } + if p.ContextWindow > 0 { + fmt.Fprintf(b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow) + } + if p.MaxOutputTokens != 0 { + fmt.Fprintf(b, "max_output_tokens = %d # per-turn total output; 0 = provider auto (official DeepSeek 384K, omit when safe); positive = cost cap; negative = force-omit; never affects compact_ratio\n", p.MaxOutputTokens) + } else { + b.WriteString("# max_output_tokens = 0 # recommended: official DeepSeek omits the field (server 384K ceiling)\n") + b.WriteString("# max_output_tokens = 32768 # optional cost cap\n") + b.WriteString("# max_output_tokens = 65536 # optional cost cap\n") + b.WriteString("# max_output_tokens = 131072 # optional cost cap\n") + } + if p.Price != nil { + fmt.Fprintf(b, "price = %s # provider-wide fallback, per 1M tokens\n", renderPricingInline(p.Price)) + } + if len(p.Prices) > 0 { + fmt.Fprintf(b, "prices = %s # per-model prices, per 1M tokens\n", renderPricingMap(p.Prices)) + } + if cur := strings.TrimSpace(p.BillingCurrency); cur != "" { + fmt.Fprintf(b, "billing_currency = %q # frozen list-price currency; independent of display_currency\n", billing.NormalizeCurrency(cur)) + } + if mode := strings.TrimSpace(p.BillingMode); mode != "" && mode != "payg" { + fmt.Fprintf(b, "billing_mode = %q # payg|subscription_equivalent\n", mode) + } + if p.Thinking != "" { + fmt.Fprintf(b, "thinking = %q\n", p.Thinking) + } + if p.Effort != "" { + fmt.Fprintf(b, "effort = %q\n", p.Effort) + } + if p.Vision { + b.WriteString("vision = true # provider accepts image input for all listed models\n") + } + if p.VisionModels != nil { + fmt.Fprintf(b, "vision_models = %s # models in this provider that accept image input\n", renderStringArray(p.VisionModels)) + } + if p.VisionDetail != "" { + fmt.Fprintf(b, "vision_detail = %q # openai image detail hint: low|high; empty = auto\n", p.VisionDetail) + } + if p.WebSearch != nil { + fmt.Fprintf(b, "web_search = %t # provider-executed web_search tool; omitted defaults on for supported official DeepSeek APIs\n", *p.WebSearch) + } + if p.ReasoningProtocol != "" { + fmt.Fprintf(b, "reasoning_protocol = %q # auto|deepseek|glm|kimi-k3|openai|none; overrides model/endpoint reasoning detection\n", p.ReasoningProtocol) + } + if len(p.SupportedEfforts) > 0 { + fmt.Fprintf(b, "supported_efforts = %s # custom /effort levels exposed by this provider; overrides the built-in Kind/BaseURL default\n", renderStringArray(p.SupportedEfforts)) + } + if p.DefaultEffort != "" { + fmt.Fprintf(b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort) + } + if len(p.ModelOverrides) > 0 { + fmt.Fprintf(b, "model_overrides = %s # per-model context/output/reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides)) + } + if p.NoProxy { + b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n") + } +} diff --git a/internal/config/provider_account_resolve.go b/internal/config/provider_account_resolve.go new file mode 100644 index 0000000000..a0b4338c60 --- /dev/null +++ b/internal/config/provider_account_resolve.go @@ -0,0 +1,242 @@ +package config + +import "strings" + +func (c *Config) DefaultAccount(providerID string) (ProviderAccount, bool) { + if c == nil { + return ProviderAccount{}, false + } + providerID = strings.TrimSpace(providerID) + var first ProviderAccount + found := false + for _, account := range c.ProviderAccounts { + if account.ProviderID != providerID || account.Retired { + continue + } + if !found { + first = account + found = true + } + if account.Default { + return cloneProviderAccount(account), true + } + } + if !found { + return ProviderAccount{}, false + } + return cloneProviderAccount(first), true +} + +func (c *Config) ResolveAccountProvider(providerID, accountID string) ([]ProviderEntry, bool) { + if c == nil { + return nil, false + } + providerID = strings.TrimSpace(providerID) + accountID = strings.TrimSpace(accountID) + if providerID == "" || accountID == "" { + return nil, false + } + out := make([]ProviderEntry, 0, 2) + for i := range c.Providers { + p := c.Providers[i] + if p.AccountProviderID == providerID && p.AccountID == accountID { + out = append(out, p) + } + } + return out, len(out) > 0 +} + +func (c *Config) AccountEnabled(providerID, accountID string) bool { + _, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return true + } + return account.IsEnabled() +} + +func (c *Config) accountSelectable(e ProviderEntry) bool { + providerID, accountID, ok := ProviderAccountIdentity(e) + if !ok { + return true + } + return c.AccountEnabled(providerID, accountID) +} + +func (c *Config) Provider(name string) (*ProviderEntry, bool) { + if c == nil { + return nil, false + } + for i := range c.Providers { + if c.Providers[i].Name == name { + return &c.Providers[i], true + } + } + return nil, false +} + +func (c *Config) ResolveModel(ref string) (*ProviderEntry, bool) { + if c == nil || ref == "" { + return nil, false + } + if access := desktopProviderAccessMap(c.Desktop.ProviderAccess); len(access) > 0 { + if access["deepseek"] && !canCanonicalizeLegacyDeepSeekProviders(c) { + delete(access, "deepseek") + } + ref = retargetDesktopOfficialRef(ref, access) + } + if prov, model, ok := strings.Cut(ref, "/"); ok { + if e, found := c.Provider(prov); found && e.HasModel(model) { + return copyResolvedEntry(e, model), true + } + if e, found := c.resolveFamilyModel(prov, model); found { + return e, true + } + } + if e, found := c.Provider(ref); found { + return copyResolvedEntry(e, e.DefaultModel()), true + } + if e, found := c.resolveFamilyModel(ref, ""); found { + return e, true + } + for i := range c.Providers { + if c.Providers[i].HasModel(ref) { + return copyResolvedEntry(&c.Providers[i], ref), true + } + } + return nil, false +} + +func (c *Config) resolveFamilyModel(family, model string) (*ProviderEntry, bool) { + family = strings.TrimSpace(family) + if family == "" { + return nil, false + } + if _, ok := c.Provider(family); ok { + return nil, false + } + account, ok := c.DefaultAccount(family) + if !ok { + return nil, false + } + entries, ok := c.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok { + return nil, false + } + model = strings.TrimSpace(model) + if model != "" { + for i := range entries { + if entries[i].HasModel(model) { + return copyResolvedEntry(&entries[i], model), true + } + } + return nil, false + } + for i := range entries { + if entries[i].Configured() { + return copyResolvedEntry(&entries[i], entries[i].DefaultModel()), true + } + } + e := entries[0] + return copyResolvedEntry(&e, e.DefaultModel()), true +} + +func copyResolvedEntry(e *ProviderEntry, model string) *ProviderEntry { + cp := *e + cp.Model = model + cp.applyModelPrice() + cp.applyModelOverride() + return &cp +} + +func (c *Config) ResolveModelWithFallback(ref string) (resolvedRef string, fallback bool, ok bool) { + ref = strings.TrimSpace(ref) + if ref != "" { + if e, found := c.ResolveModel(ref); found { + return e.Name + "/" + e.Model, false, true + } + } + if ref != c.DefaultModel && c.DefaultModel != "" { + if e, found := c.ResolveModel(c.DefaultModel); found && e.Configured() { + return e.Name + "/" + e.Model, true, true + } + } + for i := range c.Providers { + p := &c.Providers[i] + if len(p.ModelList()) == 0 || !p.Configured() || !c.accountSelectable(*p) { + continue + } + return p.Name + "/" + p.DefaultModel(), true, true + } + return "", false, false +} + +func (c *Config) ResolveNewSessionChatModel() (resolvedRef string, fallback bool, ok bool) { + return c.resolveNewSessionChatModel(nil, true) +} + +func (c *Config) resolveNewSessionChatModel(providerAllowed func(string) bool, preserveUnknownDefault bool) (resolvedRef string, fallback bool, ok bool) { + if c == nil { + return "", false, false + } + if providerAllowed == nil { + providerAllowed = func(string) bool { return true } + } + + def := strings.TrimSpace(c.DefaultModel) + keylessDefault := "" + if def != "" { + if entry, found := c.ResolveModel(def); found { + if providerAllowed(entry.Name) && IsLikelyChatModel(entry.Model) { + if entry.Configured() { + return def, false, true + } + keylessDefault = def + } + } else if preserveUnknownDefault { + return def, false, true + } + } + + keylessFallback := "" + for i := range c.Providers { + p := &c.Providers[i] + if !providerAllowed(p.Name) || !c.accountSelectable(*p) { + continue + } + chatModels := p.ChatModelList() + if len(chatModels) == 0 { + continue + } + model := chatModels[0] + for _, candidate := range chatModels { + if candidate == p.DefaultModel() { + model = candidate + break + } + } + resolved := p.Name + "/" + model + if p.Configured() { + return resolved, true, true + } + if keylessFallback == "" { + keylessFallback = resolved + } + } + if keylessDefault != "" { + return keylessDefault, false, true + } + if keylessFallback != "" { + return keylessFallback, true, true + } + return "", false, false +} + +func (c *Config) ResolveDesktopNewSessionModel() (resolvedRef string, fallback bool, ok bool) { + if c == nil { + return "", false, false + } + access := desktopProviderAccessMap(c.Desktop.ProviderAccess) + return c.resolveNewSessionChatModel(func(name string) bool { + return c.Desktop.ProviderAccess == nil || access[strings.TrimSpace(name)] + }, false) +} diff --git a/internal/config/provider_account_test.go b/internal/config/provider_account_test.go new file mode 100644 index 0000000000..187c62d210 --- /dev/null +++ b/internal/config/provider_account_test.go @@ -0,0 +1,369 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProviderAccountIDValidation(t *testing.T) { + t.Parallel() + cfg := Default() + if _, err := cfg.AddProviderAccount("", "", "主账号", "DEEPSEEK_API_KEY"); err == nil { + t.Fatal("empty provider_id must be rejected") + } + if err := validateProviderAccount(ProviderAccount{ProviderID: "deepseek", ID: "Main", Label: "Main", APIKeyEnv: "DEEPSEEK_API_KEY"}); err == nil { + t.Fatal("uppercase ID must be rejected") + } + if err := validateProviderAccount(ProviderAccount{ProviderID: "deepseek", ID: "main", Label: "Main", APIKeyEnv: "not a key"}); err == nil { + t.Fatal("invalid api_key_env must be rejected") + } + dup := Default() + if _, err := dup.AddProviderAccount("deepseek", "", "主账号", "DEEPSEEK_API_KEY"); err != nil { + t.Fatalf("add main: %v", err) + } + if err := validateProviderAccount(ProviderAccount{ProviderID: "deepseek", ID: "main", Label: "Main", APIKeyEnv: "DEEPSEEK_API_KEY"}); err != nil { + t.Fatalf("valid account rejected: %v", err) + } + warnings := normalizeProviderAccountList(&Config{ProviderAccounts: []ProviderAccount{ + {ProviderID: "deepseek", ID: "main", Label: "A", APIKeyEnv: "DEEPSEEK_API_KEY", Default: true}, + {ProviderID: "deepseek", ID: "main", Label: "B", APIKeyEnv: "DEEPSEEK_API_KEY_B"}, + }}) + if len(warnings) == 0 { + t.Fatal("duplicate IDs must warn") + } +} + +func TestProviderAccountSlugStabilityAndFamilies(t *testing.T) { + t.Parallel() + if got := SuggestProviderAccountID("deepseek", "团队账号"); got != "team" { + t.Fatalf("deepseek 团队账号 slug = %q, want team", got) + } + if got := SuggestProviderAccountID("opencode-go", "团队账号"); got != "team" { + t.Fatalf("opencode-go 团队账号 slug = %q, want team", got) + } + first := SuggestProviderAccountID("deepseek", "自定义网关") + second := SuggestProviderAccountID("deepseek", "自定义网关") + if first != second || !IsProviderAccountID(first) { + t.Fatalf("unstable slug %q vs %q", first, second) + } + if SuggestProviderAccountID("deepseek", "主账号") != MainProviderAccountID { + t.Fatal("主账号 should map to main") + } +} + +func TestCuratedPresetsHaveAccountGroupID(t *testing.T) { + t.Parallel() + for _, preset := range CuratedProviderPresets() { + if strings.TrimSpace(preset.AccountGroupID) == "" { + t.Fatalf("preset %q missing AccountGroupID", preset.ID) + } + } + deepseek, ok := CuratedProviderPreset("deepseek-responses") + if !ok || deepseek.AccountGroupID != "deepseek" { + t.Fatalf("deepseek-responses group = %q", deepseek.AccountGroupID) + } + goChat, _ := CuratedProviderPreset("opencode-go") + goAnth, _ := CuratedProviderPreset("opencode-go-anthropic") + goResp, _ := CuratedProviderPreset("opencode-go-responses") + if goChat.AccountGroupID != "opencode-go" || goAnth.AccountGroupID != "opencode-go" || goResp.AccountGroupID != "opencode-go" { + t.Fatalf("opencode-go family grouping failed: %q %q %q", goChat.AccountGroupID, goAnth.AccountGroupID, goResp.AccountGroupID) + } +} + +func TestProviderAccountTOMLOmitsSecrets(t *testing.T) { + cfg := Default() + account, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM") + if err != nil { + t.Fatal(err) + } + if account.ID != "team" { + t.Fatalf("account id = %q, want team", account.ID) + } + raw := RenderTOML(cfg) + if strings.Contains(raw, "sk-") || strings.Contains(strings.ToLower(raw), "sk-secret") { + t.Fatalf("rendered TOML leaked a secret-looking value:\n%s", raw) + } + if !strings.Contains(raw, "api_key_env = \"DEEPSEEK_API_KEY_TEAM\"") { + t.Fatalf("missing account env in TOML:\n%s", raw) + } +} + +func TestExpandOpenCodeGoAndDeepSeekAccounts(t *testing.T) { + cfg := Default() + main, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "主账号", "OPENCODE_GO_API_KEY") + if err != nil { + t.Fatalf("add opencode main: %v", err) + } + entries, err := ExpandProviderAccount(cfg, main) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, e := range entries { + names[e.Name] = true + if e.APIKeyEnv != "OPENCODE_GO_API_KEY" { + t.Fatalf("entry %q env = %q", e.Name, e.APIKeyEnv) + } + } + for _, want := range []string{"opencode-go", "opencode-go-anthropic", "opencode-go-responses"} { + if !names[want] { + t.Fatalf("missing OpenCode Go route %q in %v", want, names) + } + } + team, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "团队账号", "OPENCODE_GO_API_KEY_TEAM") + if err != nil { + t.Fatalf("add opencode team: %v", err) + } + teamEntries, err := ExpandProviderAccount(cfg, team) + if err != nil { + t.Fatal(err) + } + teamNames := map[string]string{} + for _, e := range teamEntries { + teamNames[e.AccountRouteID] = e.Name + if e.APIKeyEnv != "OPENCODE_GO_API_KEY_TEAM" { + t.Fatalf("team entry %q env = %q", e.Name, e.APIKeyEnv) + } + if e.Name == "opencode-go" || e.Name == "opencode-go-anthropic" { + t.Fatalf("team account reused main provider name %q", e.Name) + } + } + if teamNames["opencode-go"] != "opencode-go--team" { + t.Fatalf("team chat name = %q", teamNames["opencode-go"]) + } + deepTeam, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM") + if err != nil { + t.Fatal(err) + } + deepEntries, err := ExpandProviderAccount(cfg, deepTeam) + if err != nil { + t.Fatal(err) + } + if len(deepEntries) == 0 { + t.Fatal("deepseek team produced no routes") + } + foundCombined := false + for _, e := range deepEntries { + if e.Name == "deepseek--team" && e.Kind == "anthropic" { + foundCombined = true + } + if e.APIKeyEnv != "DEEPSEEK_API_KEY_TEAM" { + t.Fatalf("deepseek team env = %q", e.APIKeyEnv) + } + } + if !foundCombined { + t.Fatalf("deepseek team missing combined route: %+v", deepEntries) + } +} + +func TestReconcilePreservesUserEditsAndIsIdempotent(t *testing.T) { + cfg := Default() + ensureProviderAccounts(cfg) + idx := providerIndexByName(cfg, "deepseek-flash") + if idx < 0 { + t.Fatal("missing deepseek-flash") + } + cfg.Providers[idx].BaseURL = "https://proxy.example/anthropic" + cfg.Providers[idx].Headers = map[string]string{"X-Route": "custom"} + cfg.Providers[idx].Models = []string{"deepseek-v4-flash"} + cfg.Providers[idx].RequestURL = "https://proxy.example/anthropic/v1/messages" + changed, _, err := ReconcileProviderAccounts(cfg) + if err != nil { + t.Fatal(err) + } + got, ok := cfg.Provider("deepseek-flash") + if !ok { + t.Fatal("deepseek-flash disappeared") + } + if got.BaseURL != "https://proxy.example/anthropic" || got.RequestURL != "https://proxy.example/anthropic/v1/messages" || got.Headers["X-Route"] != "custom" { + t.Fatalf("user fields overwritten: %+v", got) + } + if len(got.Models) != 1 || got.Models[0] != "deepseek-v4-flash" { + t.Fatalf("models overwritten: %v", got.Models) + } + again, _, err := ReconcileProviderAccounts(cfg) + if err != nil { + t.Fatal(err) + } + if changed && again { + // first reconcile may stamp metadata; second must not keep changing + } + if again { + t.Fatal("reconcile is not idempotent") + } +} + +func TestLegacyProviderAccountsMigrateToMain(t *testing.T) { + dir := t.TempDir() + t.Setenv("REASONIX_HOME", dir) + path := filepath.Join(dir, "config.toml") + body := `config_version = 7 +default_model = "deepseek-flash/deepseek-v4-flash" + +[[providers]] +name = "deepseek-flash" +kind = "anthropic" +base_url = "https://api.deepseek.com/anthropic" +model = "deepseek-v4-flash" +api_key_env = "DEEPSEEK_API_KEY" + +[[providers]] +name = "deepseek-pro" +kind = "anthropic" +base_url = "https://api.deepseek.com/anthropic" +model = "deepseek-v4-pro" +api_key_env = "DEEPSEEK_API_KEY" + +[[providers]] +name = "gateway" +kind = "openai" +base_url = "http://localhost:8021/v1" +models = ["my-model"] +api_key_env = "GATEWAY_API_KEY" +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg := LoadForEditWithoutCredentials(path) + if len(cfg.ProviderAccounts) != 1 { + t.Fatalf("accounts = %+v, want one main DeepSeek account", cfg.ProviderAccounts) + } + account := cfg.ProviderAccounts[0] + if account.ProviderID != "deepseek" || account.ID != MainProviderAccountID || account.APIKeyEnv != "DEEPSEEK_API_KEY" { + t.Fatalf("migrated account = %+v", account) + } + flash, _ := cfg.ResolveModel("deepseek-flash/deepseek-v4-flash") + if flash == nil || flash.Name != "deepseek-flash" { + t.Fatalf("old ref resolved to %+v", flash) + } + if _, ok := cfg.Provider("gateway"); !ok { + t.Fatal("custom provider was dropped") + } + if err := cfg.SaveTo(path); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.Contains(text, "[[provider_accounts]]") || !strings.Contains(text, `id = "main"`) { + t.Fatalf("saved config missing provider_accounts:\n%s", text) + } + if !strings.Contains(text, "name = \"deepseek-flash\"") { + t.Fatalf("saved config renamed old provider:\n%s", text) + } + reloaded := LoadForEditWithoutCredentials(path) + if len(reloaded.ProviderAccounts) != 1 || reloaded.ProviderAccounts[0].ID != MainProviderAccountID { + t.Fatalf("reload accounts = %+v", reloaded.ProviderAccounts) + } + if _, ok := reloaded.ResolveModel("deepseek-flash/deepseek-v4-flash"); !ok { + t.Fatal("reload lost old model ref") + } +} + +func TestLegacyDifferentKeyEnvsBecomeMultipleAccounts(t *testing.T) { + cfg := &Config{Providers: []ProviderEntry{ + {Name: "deepseek-flash", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL, Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY"}, + {Name: "deepseek-pro", Kind: "anthropic", BaseURL: deepSeekAnthropicBaseURL, Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY_WORK"}, + }} + if !inferProviderAccounts(cfg) { + t.Fatal("expected migration") + } + if len(cfg.ProviderAccounts) != 2 { + t.Fatalf("accounts = %+v, want 2", cfg.ProviderAccounts) + } + if cfg.ProviderAccounts[0].ID != MainProviderAccountID { + t.Fatalf("first account id = %q", cfg.ProviderAccounts[0].ID) + } + if !strings.HasPrefix(cfg.ProviderAccounts[1].ID, legacyAccountIDPrefix) { + t.Fatalf("second account id = %q, want legacy-*", cfg.ProviderAccounts[1].ID) + } +} + +func TestProjectConfigCannotDefineProviderAccounts(t *testing.T) { + home := t.TempDir() + t.Setenv("REASONIX_HOME", home) + user := filepath.Join(home, "config.toml") + if err := os.WriteFile(user, []byte("config_version = 8\ndefault_model = \"deepseek-flash\"\n"), 0o600); err != nil { + t.Fatal(err) + } + root := t.TempDir() + project := filepath.Join(root, "reasonix.toml") + if err := os.WriteFile(project, []byte(` +[[provider_accounts]] +provider_id = "deepseek" +id = "sneaky" +label = "Project" +api_key_env = "PROJECT_DEEPSEEK_KEY" +`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadForRootWithoutCredentialsReadOnly(root) + if err != nil { + t.Fatal(err) + } + for _, account := range cfg.ProviderAccounts { + if account.ID == "sneaky" { + t.Fatal("project provider_accounts leaked into runtime config") + } + } + warnings := cfg.LoadWarnings() + found := false + for _, w := range warnings { + if strings.Contains(w, "provider_accounts") { + found = true + } + } + if !found { + t.Fatalf("missing project account warning: %v", warnings) + } +} + +func TestResolveFamilyAndExplicitAccountModel(t *testing.T) { + cfg := Default() + ensureProviderAccounts(cfg) + if _, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM"); err != nil { + t.Fatal(err) + } + for i := range cfg.Providers { + if cfg.Providers[i].APIKeyEnv == "DEEPSEEK_API_KEY" { + cfg.Providers[i].resolvedAPIKey = "sk-main" + } + if cfg.Providers[i].APIKeyEnv == "DEEPSEEK_API_KEY_TEAM" { + cfg.Providers[i].resolvedAPIKey = "sk-team" + } + } + family, ok := cfg.ResolveModel("deepseek") + if !ok { + t.Fatal("family deepseek did not resolve") + } + if family.AccountID != MainProviderAccountID && family.Name != "deepseek-flash" && family.Name != "deepseek-pro" { + t.Fatalf("family resolved to %+v", family) + } + team, ok := cfg.ResolveModel("deepseek--team/deepseek-v4-flash") + if !ok { + t.Fatal("explicit team ref did not resolve") + } + if team.AccountID != "team" || team.APIKeyEnv != "DEEPSEEK_API_KEY_TEAM" { + t.Fatalf("team ref = %+v", team) + } + if err := cfg.SetProviderAccountEnabled("deepseek", "team", false); err != nil { + t.Fatal(err) + } + if cfg.AccountEnabled("deepseek", "team") { + t.Fatal("disabled account still enabled") + } + if _, ok := cfg.ResolveModel("deepseek--team/deepseek-v4-flash"); !ok { + t.Fatal("explicit disabled-account ref must still resolve") + } + ref, _, ok := cfg.ResolveNewSessionChatModel() + if !ok { + t.Fatal("new session model missing") + } + if strings.Contains(ref, "--team") { + t.Fatalf("disabled account leaked into new session candidates: %s", ref) + } +} diff --git a/internal/config/provider_preset_clone.go b/internal/config/provider_preset_clone.go new file mode 100644 index 0000000000..af2404a864 --- /dev/null +++ b/internal/config/provider_preset_clone.go @@ -0,0 +1,43 @@ +package config + +func cloneProviderPreset(p ProviderPreset) ProviderPreset { + p.Entries = cloneProviderEntries(p.Entries) + p.AccountGroupID = p.resolvedAccountGroupID() + for i := range p.Entries { + p.Entries[i].PresetID = p.ID + p.Entries[i].PresetVersion = ProviderPresetVersion + } + return p +} + +func cloneProviderEntries(in []ProviderEntry) []ProviderEntry { + out := make([]ProviderEntry, 0, len(in)) + for _, e := range in { + out = append(out, cloneProviderEntry(e)) + } + return out +} + +func cloneProviderEntry(e ProviderEntry) ProviderEntry { + if e.WebSearch != nil { + value := *e.WebSearch + e.WebSearch = &value + } + if e.ResponsesStateful != nil { + value := *e.ResponsesStateful + e.ResponsesStateful = &value + } + if e.visionOverride != nil { + value := *e.visionOverride + e.visionOverride = &value + } + e.Models = append([]string(nil), e.Models...) + e.VisionModels = append([]string(nil), e.VisionModels...) + e.SupportedEfforts = append([]string(nil), e.SupportedEfforts...) + e.Headers = cloneStringMap(e.Headers) + e.ExtraBody = cloneAnyMap(e.ExtraBody) + e.Price = clonePricing(e.Price) + e.Prices = clonePricingMap(e.Prices) + e.ModelOverrides = cloneModelOverrideMap(e.ModelOverrides) + return e +} diff --git a/internal/config/provider_presets.go b/internal/config/provider_presets.go index 9625b07c35..f9ccffadf7 100644 --- a/internal/config/provider_presets.go +++ b/internal/config/provider_presets.go @@ -31,6 +31,7 @@ type ProviderPreset struct { RouteKind string Optional bool DisplayOrder int + AccountGroupID string } const ( @@ -1078,47 +1079,6 @@ func boolPointer(value bool) *bool { return &value } -func cloneProviderPreset(p ProviderPreset) ProviderPreset { - p.Entries = cloneProviderEntries(p.Entries) - for i := range p.Entries { - p.Entries[i].PresetID = p.ID - p.Entries[i].PresetVersion = ProviderPresetVersion - } - return p -} - -func cloneProviderEntries(in []ProviderEntry) []ProviderEntry { - out := make([]ProviderEntry, 0, len(in)) - for _, e := range in { - out = append(out, cloneProviderEntry(e)) - } - return out -} - -func cloneProviderEntry(e ProviderEntry) ProviderEntry { - if e.WebSearch != nil { - value := *e.WebSearch - e.WebSearch = &value - } - if e.ResponsesStateful != nil { - value := *e.ResponsesStateful - e.ResponsesStateful = &value - } - if e.visionOverride != nil { - value := *e.visionOverride - e.visionOverride = &value - } - e.Models = append([]string(nil), e.Models...) - e.VisionModels = append([]string(nil), e.VisionModels...) - e.SupportedEfforts = append([]string(nil), e.SupportedEfforts...) - e.Headers = cloneStringMap(e.Headers) - e.ExtraBody = cloneAnyMap(e.ExtraBody) - e.Price = clonePricing(e.Price) - e.Prices = clonePricingMap(e.Prices) - e.ModelOverrides = cloneModelOverrideMap(e.ModelOverrides) - return e -} - func clonePricingMap(in map[string]*provider.Pricing) map[string]*provider.Pricing { if len(in) == 0 { return nil diff --git a/internal/config/render.go b/internal/config/render.go index ef39797a38..15952121cc 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -296,112 +296,9 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { } b.WriteString("\n") + renderProviderAccounts(&b, c, scope) if shouldRenderProviders(c, defaults, scope) { - for _, p := range c.Providers { - b.WriteString("[[providers]]\n") - fmt.Fprintf(&b, "name = %q\n", p.Name) - fmt.Fprintf(&b, "kind = %q\n", p.Kind) - fmt.Fprintf(&b, "base_url = %q\n", p.BaseURL) - if p.ChatURL != "" { - fmt.Fprintf(&b, "chat_url = %q # legacy OpenAI chat endpoint override\n", p.ChatURL) - } - if p.RequestURL != "" { - fmt.Fprintf(&b, "request_url = %q # exact provider request URL; no path completion\n", p.RequestURL) - } - if len(p.Models) > 0 { - fmt.Fprintf(&b, "models = %s\n", renderStringArray(p.Models)) - if p.Default != "" { - fmt.Fprintf(&b, "default = %q\n", p.Default) - } - } else if p.Model != "" { - fmt.Fprintf(&b, "model = %q\n", p.Model) - } - if p.ModelsURL != "" { - fmt.Fprintf(&b, "models_url = %q # auto-fetch models from this URL on startup\n", p.ModelsURL) - } - fmt.Fprintf(&b, "api_key_env = %q\n", p.APIKeyEnv) - if p.PresetID != "" { - fmt.Fprintf(&b, "preset_id = %q # curated preset identity; settings UI uses it to avoid duplicate installs\n", p.PresetID) - } - if p.PresetVersion > 0 { - fmt.Fprintf(&b, "preset_version = %d\n", p.PresetVersion) - } - if len(p.Headers) > 0 { - fmt.Fprintf(&b, "headers = %s # extra static request headers; keep secrets in api_key_env\n", renderStringMap(p.Headers)) - } - if len(p.ExtraBody) > 0 { - fmt.Fprintf(&b, "extra_body = %s # extra top-level JSON request body fields for compatible gateways\n", renderAnyMap(p.ExtraBody)) - } - if p.AuthHeader { - b.WriteString("auth_header = true # Anthropic-compatible: send Authorization: Bearer instead of x-api-key\n") - } - if p.ResponsesMode != "" { - fmt.Fprintf(&b, "responses_mode = %q # responses provider: stateless|stateful\n", p.ResponsesMode) - } - if p.ResponsesStateful != nil { - fmt.Fprintf(&b, "responses_stateful = %t # legacy responses mode switch\n", *p.ResponsesStateful) - } - if p.BalanceURL != "" { - fmt.Fprintf(&b, "balance_url = %q # optional; wallet-balance endpoint shown in the status bar\n", p.BalanceURL) - } - if p.ContextWindow > 0 { - fmt.Fprintf(&b, "context_window = %d # tokens; compaction triggers near this limit\n", p.ContextWindow) - } - if p.MaxOutputTokens != 0 { - fmt.Fprintf(&b, "max_output_tokens = %d # per-turn total output; 0 = provider auto (official DeepSeek 384K, omit when safe); positive = cost cap; negative = force-omit; never affects compact_ratio\n", p.MaxOutputTokens) - } else { - b.WriteString("# max_output_tokens = 0 # recommended: official DeepSeek omits the field (server 384K ceiling)\n") - b.WriteString("# max_output_tokens = 32768 # optional cost cap\n") - b.WriteString("# max_output_tokens = 65536 # optional cost cap\n") - b.WriteString("# max_output_tokens = 131072 # optional cost cap\n") - } - if p.Price != nil { - fmt.Fprintf(&b, "price = %s # provider-wide fallback, per 1M tokens\n", renderPricingInline(p.Price)) - } - if len(p.Prices) > 0 { - fmt.Fprintf(&b, "prices = %s # per-model prices, per 1M tokens\n", renderPricingMap(p.Prices)) - } - if cur := strings.TrimSpace(p.BillingCurrency); cur != "" { - fmt.Fprintf(&b, "billing_currency = %q # frozen list-price currency; independent of display_currency\n", billing.NormalizeCurrency(cur)) - } - if mode := strings.TrimSpace(p.BillingMode); mode != "" && mode != "payg" { - fmt.Fprintf(&b, "billing_mode = %q # payg|subscription_equivalent\n", mode) - } - if p.Thinking != "" { - fmt.Fprintf(&b, "thinking = %q\n", p.Thinking) - } - if p.Effort != "" { - fmt.Fprintf(&b, "effort = %q\n", p.Effort) - } - if p.Vision { - b.WriteString("vision = true # provider accepts image input for all listed models\n") - } - if p.VisionModels != nil { - fmt.Fprintf(&b, "vision_models = %s # models in this provider that accept image input\n", renderStringArray(p.VisionModels)) - } - if p.VisionDetail != "" { - fmt.Fprintf(&b, "vision_detail = %q # openai image detail hint: low|high; empty = auto\n", p.VisionDetail) - } - if p.WebSearch != nil { - fmt.Fprintf(&b, "web_search = %t # provider-executed web_search tool; omitted defaults on for supported official DeepSeek APIs\n", *p.WebSearch) - } - if p.ReasoningProtocol != "" { - fmt.Fprintf(&b, "reasoning_protocol = %q # auto|deepseek|glm|kimi-k3|openai|none; overrides model/endpoint reasoning detection\n", p.ReasoningProtocol) - } - if len(p.SupportedEfforts) > 0 { - fmt.Fprintf(&b, "supported_efforts = %s # custom /effort levels exposed by this provider; overrides the built-in Kind/BaseURL default\n", renderStringArray(p.SupportedEfforts)) - } - if p.DefaultEffort != "" { - fmt.Fprintf(&b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort) - } - if len(p.ModelOverrides) > 0 { - fmt.Fprintf(&b, "model_overrides = %s # per-model context/output/reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides)) - } - if p.NoProxy { - b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n") - } - b.WriteString("\n") - } + renderProviderEntries(&b, c) } b.WriteString("[tools]\n") @@ -1355,6 +1252,7 @@ func projectScopedConfigForRender(c *Config) *Config { cp.Providers = append(cp.Providers, p) } cp.Providers = append(cp.Providers, c.shadowedProjectProviders...) + cp.ProviderAccounts = nil return &cp } diff --git a/internal/config/render_test.go b/internal/config/render_test.go index d86e235408..32c25a4b83 100644 --- a/internal/config/render_test.go +++ b/internal/config/render_test.go @@ -791,7 +791,7 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) { c.Agent.RecoveryTemperature = 0.2 user := RenderTOMLForScope(c, RenderScopeUser) - for _, want := range []string{"config_version = 7", "[desktop]", `currency = "CNY"`, "[billing]", `display_currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} { + for _, want := range []string{"config_version = 8", "[desktop]", `currency = "CNY"`, "[billing]", `display_currency = "CNY"`, `theme = "dark"`, `terminal_theme = "auto"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} { if !strings.Contains(user, want) { t.Fatalf("user render missing %q:\n%s", want, user) } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 45487dbd08..f00828219c 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -436,6 +436,13 @@ type Messages struct { SetupManagerTitle string SetupAddOpenAI string SetupAddAnthropic string + SetupAddAccount string + SetupAddAccountDesc string + SetupAccountLabel string + SetupAccountRename string + SetupAccountToggle string + SetupAccountRetire string + SetupProjectNoAccounts string SetupProviderExistsFmt string SetupSaveExit string SetupSaveExitDesc string diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index b17b093bf6..387db8a254 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -399,6 +399,13 @@ var English = Messages{ NotOverwritingFmt: "%s already exists; not overwriting", SetupManagerTitle: "Provider configuration", SetupAddOpenAI: "Add OpenAI-compatible provider", + SetupAddAccount: "Add account", + SetupAddAccountDesc: "Add another API key for a curated provider family", + SetupAccountLabel: "Account label", + SetupAccountRename: "Rename account", + SetupAccountToggle: "Enable/disable account", + SetupAccountRetire: "Retire account", + SetupProjectNoAccounts: "Project setup can only use existing global accounts", SetupAddAnthropic: "Add Anthropic-compatible provider", SetupProviderExistsFmt: "Provider %q already exists. Manage the existing provider to edit its models or settings.", SetupSaveExit: "Save and exit", diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 3793d166a1..6b7ab85f3a 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -400,6 +400,13 @@ var Chinese = Messages{ NotOverwritingFmt: "%s 已存在,不覆盖", SetupManagerTitle: "供应商配置", SetupAddOpenAI: "添加 OpenAI 兼容供应商", + SetupAddAccount: "添加账号", + SetupAddAccountDesc: "为已有供应商族添加另一把 API Key", + SetupAccountLabel: "账号名称", + SetupAccountRename: "重命名账号", + SetupAccountToggle: "启用/停用账号", + SetupAccountRetire: "退休账号", + SetupProjectNoAccounts: "项目级 setup 只能选择已有全局账号", SetupAddAnthropic: "添加 Anthropic 兼容供应商", SetupProviderExistsFmt: "供应商 %q 已存在。请进入现有供应商管理来编辑模型或设置。", SetupSaveExit: "保存并退出", diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index 502129a57b..030b3146ef 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -373,6 +373,13 @@ var ChineseTraditional = Messages{ NotOverwritingFmt: "%s 已存在,不覆蓋", SetupManagerTitle: "供應商設定", SetupAddOpenAI: "新增 OpenAI 相容供應商", + SetupAddAccount: "新增帳號", + SetupAddAccountDesc: "為既有供應商族新增另一把 API Key", + SetupAccountLabel: "帳號名稱", + SetupAccountRename: "重新命名帳號", + SetupAccountToggle: "啟用/停用帳號", + SetupAccountRetire: "退休帳號", + SetupProjectNoAccounts: "專案級 setup 只能選擇既有全域帳號", SetupAddAnthropic: "新增 Anthropic 相容供應商", SetupProviderExistsFmt: "供應商 %q 已存在。請進入現有供應商管理來編輯模型或設定。", SetupSaveExit: "儲存並離開", diff --git a/reasonix.example.toml b/reasonix.example.toml index c3843e3b69..cb23cc42f9 100644 --- a/reasonix.example.toml +++ b/reasonix.example.toml @@ -53,6 +53,16 @@ compact_ratio = 0.80 # sole auto trigger; presets 0.70 (active) / 0.80 (r # output_style = "explanatory" # persona/tone folded into the prompt: explanatory | learning | concise, # or a custom .reasonix/output-styles/.md ; empty = default +# Provider accounts are user-global. Project reasonix.toml may reference +# generated provider names but must not declare accounts or API keys. +# OpenCode Go one account expands Chat, Anthropic, and Responses routes. +# Account switching is manual, not polling. Different keys may split vendor cache. +# [[provider_accounts]] +# provider_id = "deepseek" +# id = "team" +# label = "Team" +# api_key_env = "DEEPSEEK_API_KEY_TEAM" +# # A provider is a vendor endpoint (one base_url + key) that offers one or more # models. Use `models = [...]` to expose several under a single entry — switching # models reuses the same connection; no need to re-declare base_url/api_key. From 0ea1ddcffecc4a141d696b72c151f12a3996740e Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:26:28 +0800 Subject: [PATCH 02/58] fix(frontend): remove provider account settings EOF whitespace Problem: diff-check rejects the settings component for an extra blank line at EOF.\nFix: normalize the file to a single trailing newline.\nVerification: git diff --check. --- desktop/frontend/src/components/ProviderAccountSettings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/frontend/src/components/ProviderAccountSettings.tsx b/desktop/frontend/src/components/ProviderAccountSettings.tsx index c8ddcf62be..02f0ed198e 100644 --- a/desktop/frontend/src/components/ProviderAccountSettings.tsx +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -131,4 +131,3 @@ export function ProviderAccountManager({
); } - From f9faeef6e68881fb167d62be4bd3e0c24e365f0f Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:27:52 +0800 Subject: [PATCH 03/58] test(boot): guard full provider request prefix across accounts Problem: the account cache regression test compared only user messages through a direct provider call.\nFix: exercise stable system prompt and tool schema fields, then compare every prompt-shaping request field while allowing authentication to differ.\nVerification: go test ./internal/boot -run TestAccountSwitch -count=1. --- internal/boot/provider_account_auth_test.go | 30 +++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/boot/provider_account_auth_test.go b/internal/boot/provider_account_auth_test.go index 44b96fa419..07c508d2a6 100644 --- a/internal/boot/provider_account_auth_test.go +++ b/internal/boot/provider_account_auth_test.go @@ -81,7 +81,15 @@ func TestAccountSwitchKeepsPromptBytesStable(t *testing.T) { if _, err := cfg.AddProviderAccount("deepseek", "", "团队账号", "DEEPSEEK_API_KEY_TEAM"); err != nil { t.Fatal(err) } - req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}} + req := provider.Request{ + Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "stable system prompt"}, + {Role: provider.RoleUser, Content: "hi"}, + }, + Tools: []provider.ToolSchema{{ + Name: "stable_tool", Description: "stable tool", Parameters: json.RawMessage(`{"type":"object","properties":{"value":{"type":"string"}}}`), + }}, + } for _, account := range cfg.ProviderAccounts { if account.ProviderID != "deepseek" { continue @@ -112,9 +120,21 @@ func TestAccountSwitchKeepsPromptBytesStable(t *testing.T) { if len(bodies) < 2 { t.Fatalf("got %d bodies", len(bodies)) } - msg0, _ := json.Marshal(bodies[0]["messages"]) - msg1, _ := json.Marshal(bodies[1]["messages"]) - if string(msg0) != string(msg1) { - t.Fatalf("prompt bytes changed across accounts:\n%s\n%s", msg0, msg1) + for _, field := range []string{"messages", "tools"} { + v0, _ := json.Marshal(bodies[0][field]) + v1, _ := json.Marshal(bodies[1][field]) + if string(v0) != string(v1) { + t.Fatalf("provider-visible %s changed across accounts:\n%s\n%s", field, v0, v1) + } + } + // Account labels, IDs, timestamps, and route paths must never leak into the + // provider-visible prompt surface. Authentication is intentionally the only + // per-account request difference. + for _, field := range []string{"messages", "tools", "model", "temperature", "max_tokens", "stream"} { + v0, _ := json.Marshal(bodies[0][field]) + v1, _ := json.Marshal(bodies[1][field]) + if string(v0) != string(v1) { + t.Fatalf("stable request field %s changed across accounts: %s vs %s", field, v0, v1) + } } } From a4aff1989beb9ae17c1bc15df35257a55a45ab10 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:39:16 +0800 Subject: [PATCH 04/58] fix(config): persist account route state and default selection Problem: account route deletion was reconstructed on reload, disabled accounts remained eligible for new sessions, and custom endpoints sharing an official URL were inferred as curated families. Root cause: reconciliation treated missing routes as evidence to seed the full template and identity detection trusted endpoint URLs. Default account mutations only changed metadata. Fix: add normalized disabled route state with restore APIs, preserve retired provider entries, synchronize family default models, exclude disabled accounts from selection, scope curated identity to explicit metadata/names, and use a deterministic non-cryptographic identity hash for generated names. Verification: go test ./internal/config -count=1 --- internal/config/provider_account.go | 59 ++++++--- internal/config/provider_account_expand.go | 43 ++++++- internal/config/provider_account_group.go | 17 --- internal/config/provider_account_migrate.go | 28 ++-- internal/config/provider_account_mutate.go | 136 ++++++++++++++++++++ internal/config/provider_account_render.go | 3 + internal/config/provider_account_resolve.go | 4 +- internal/config/provider_account_test.go | 87 +++++++++++++ 8 files changed, 327 insertions(+), 50 deletions(-) diff --git a/internal/config/provider_account.go b/internal/config/provider_account.go index 244d4cd6d1..312aeacf16 100644 --- a/internal/config/provider_account.go +++ b/internal/config/provider_account.go @@ -1,9 +1,8 @@ package config import ( - "crypto/sha256" - "encoding/hex" "fmt" + "hash/fnv" "regexp" "strings" "unicode" @@ -21,14 +20,15 @@ var providerAccountIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) // ProviderAccount is one independently credentialed account for a curated // provider family. Secrets stay in Reasonix home .env under APIKeyEnv. type ProviderAccount struct { - ProviderID string `toml:"provider_id"` - PresetID string `toml:"preset_id,omitempty"` - ID string `toml:"id"` - Label string `toml:"label"` - APIKeyEnv string `toml:"api_key_env"` - Enabled *bool `toml:"enabled,omitempty"` - Default bool `toml:"default,omitempty"` - Retired bool `toml:"retired,omitempty"` + ProviderID string `toml:"provider_id"` + PresetID string `toml:"preset_id,omitempty"` + ID string `toml:"id"` + Label string `toml:"label"` + APIKeyEnv string `toml:"api_key_env"` + Enabled *bool `toml:"enabled,omitempty"` + Default bool `toml:"default,omitempty"` + Retired bool `toml:"retired,omitempty"` + DisabledRoutes []string `toml:"disabled_routes,omitempty"` } func (a ProviderAccount) IsEnabled() bool { @@ -79,8 +79,7 @@ func SuggestProviderAccountID(providerID, label string) string { if slug := slugifyProviderAccountID(label); slug != "" { return slug } - sum := sha256.Sum256([]byte(strings.TrimSpace(providerID) + "\x00" + label)) - return "a" + hex.EncodeToString(sum[:4])[:7] + return "a" + providerIdentityHash(strings.TrimSpace(providerID) + "\x00" + label)[:7] } func slugifyProviderAccountID(label string) string { @@ -135,8 +134,20 @@ func uniqueProviderAccountID(providerID, suggested string, used map[providerAcco return candidate } } - sum := sha256.Sum256([]byte(providerID + "\x00" + suggested)) - return "a" + hex.EncodeToString(sum[:4])[:7] + base := "a" + providerIdentityHash(providerID + "\x00" + suggested)[:7] + if !used[providerAccountKey{ProviderID: providerID, ID: base}] { + return base + } + for i := 2; i < 100; i++ { + candidate := fmt.Sprintf("%s-%d", strings.TrimSuffix(base, "-"), i) + if len(candidate) > maxProviderAccountIDLen { + candidate = candidate[:maxProviderAccountIDLen] + } + if IsProviderAccountID(candidate) && !used[providerAccountKey{ProviderID: providerID, ID: candidate}] { + return candidate + } + } + return base } func SuggestAccountAPIKeyEnv(baseEnv, accountID string, used map[string]bool) string { @@ -161,8 +172,7 @@ func SuggestAccountAPIKeyEnv(baseEnv, accountID string, used map[string]bool) st if IsValidCredentialKey(candidate) && !used[candidate] { return candidate } - sum := sha256.Sum256([]byte(candidate)) - hashed := candidate + "_" + strings.ToUpper(hex.EncodeToString(sum[:3])) + hashed := candidate + "_" + strings.ToUpper(providerIdentityHash(candidate)[:6]) if IsValidCredentialKey(hashed) && !used[hashed] { return hashed } @@ -177,8 +187,7 @@ func uniqueProviderName(base string, taken map[string]bool) string { if !taken[base] { return base } - sum := sha256.Sum256([]byte(base)) - short := hex.EncodeToString(sum[:3]) + short := providerIdentityHash(base)[:6] candidate := base + "-" + short if !taken[candidate] { return candidate @@ -189,7 +198,16 @@ func uniqueProviderName(base string, taken map[string]bool) string { return candidate } } - return base + "-" + hex.EncodeToString(sum[:8]) + return base + "-" + providerIdentityHash(base) +} + +// providerIdentityHash creates a deterministic, non-cryptographic identity +// suffix for generated account/provider names. It must never be used for +// credential hashing or security decisions. +func providerIdentityHash(value string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(value)) + return fmt.Sprintf("%08x", h.Sum32()) } func validateProviderAccount(a ProviderAccount) error { @@ -213,6 +231,9 @@ func cloneProviderAccount(a ProviderAccount) ProviderAccount { value := *a.Enabled a.Enabled = &value } + if a.DisabledRoutes != nil { + a.DisabledRoutes = append([]string(nil), a.DisabledRoutes...) + } return a } diff --git a/internal/config/provider_account_expand.go b/internal/config/provider_account_expand.go index ba9eedb9a4..0eecc77a4e 100644 --- a/internal/config/provider_account_expand.go +++ b/internal/config/provider_account_expand.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "sort" "strings" ) @@ -26,6 +27,9 @@ func ExpandProviderAccount(c *Config, account ProviderAccount) ([]ProviderEntry, } out := make([]ProviderEntry, 0, len(templates)) for _, tmpl := range templates { + if providerAccountRouteDisabled(account, tmpl.RouteID) { + continue + } if tmpl.MainOnly && account.ID != MainProviderAccountID { continue } @@ -90,7 +94,6 @@ func ReconcileProviderAccounts(c *Config) (changed bool, warnings []string, err warnings = append(warnings, expandErr.Error()) continue } - seeded := countAccountRoutes(c, account) > 0 for _, generated := range entries { if !c.userOwnedProvider(generated.Name) && providerIndexByName(c, generated.Name) >= 0 { continue @@ -107,9 +110,6 @@ func ReconcileProviderAccounts(c *Config) (changed bool, warnings []string, err } continue } - if seeded { - continue - } if err := c.UpsertProvider(generated); err != nil { return changed, warnings, err } @@ -279,6 +279,7 @@ func normalizeProviderAccountList(c *Config) []string { account.Label = strings.TrimSpace(account.Label) account.APIKeyEnv = strings.TrimSpace(account.APIKeyEnv) account.PresetID = strings.TrimSpace(account.PresetID) + account.DisabledRoutes = normalizeProviderAccountRoutes(account.DisabledRoutes) if err := validateProviderAccount(account); err != nil { warnings = append(warnings, err.Error()) continue @@ -307,3 +308,37 @@ func normalizeProviderAccountList(c *Config) []string { } return warnings } + +func normalizeProviderAccountRoutes(routes []string) []string { + if len(routes) == 0 { + return nil + } + seen := make(map[string]struct{}, len(routes)) + out := make([]string, 0, len(routes)) + for _, route := range routes { + route = strings.TrimSpace(route) + if route == "" { + continue + } + if _, ok := seen[route]; ok { + continue + } + seen[route] = struct{}{} + out = append(out, route) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} + +func providerAccountRouteDisabled(account ProviderAccount, routeID string) bool { + routeID = strings.TrimSpace(routeID) + for _, disabled := range account.DisabledRoutes { + if strings.TrimSpace(disabled) == routeID { + return true + } + } + return false +} diff --git a/internal/config/provider_account_group.go b/internal/config/provider_account_group.go index 72f2f254ca..9d93a6e24f 100644 --- a/internal/config/provider_account_group.go +++ b/internal/config/provider_account_group.go @@ -116,26 +116,9 @@ func curatedProviderIdentity(e ProviderEntry) (groupID, routeID, baseName string } } } - if isOfficialDeepSeekProviderEntry(e) { - route := strings.TrimSpace(e.Name) - if route == "deepseek-anthropic" { - route = "deepseek" - } - return "deepseek", route, e.Name, true - } return "", "", "", false } -func isOfficialDeepSeekProviderEntry(e ProviderEntry) bool { - if officialProviderKindFromName(e.Name) == "deepseek" { - return true - } - base := strings.TrimRight(strings.TrimSpace(e.BaseURL), "/") - return strings.EqualFold(base, strings.TrimRight(deepSeekAnthropicBaseURL, "/")) || - strings.EqualFold(base, "https://api.deepseek.com") || - strings.EqualFold(base, "https://api.deepseek.com/v1") -} - func officialProviderKindFromName(name string) string { switch strings.TrimSpace(name) { case "deepseek", "deepseek-flash", "deepseek-pro", "deepseek-anthropic", "deepseek-responses": diff --git a/internal/config/provider_account_migrate.go b/internal/config/provider_account_migrate.go index e2a7025d7a..a80f286538 100644 --- a/internal/config/provider_account_migrate.go +++ b/internal/config/provider_account_migrate.go @@ -1,10 +1,6 @@ package config -import ( - "crypto/sha256" - "encoding/hex" - "strings" -) +import "strings" func ensureProviderAccounts(c *Config) { if c == nil { @@ -77,6 +73,24 @@ func inferProviderAccounts(c *Config) bool { APIKeyEnv: g.keyEnv, Default: id == MainProviderAccountID, } + // A legacy config may intentionally contain only a subset of a curated + // family's routes. Mark absent routes disabled so reconciliation does not + // resurrect providers the user never configured; RestoreProviderAccount + // can explicitly opt back into the complete route bundle. + routePresent := map[string]bool{} + for _, idx := range g.index { + if _, route, _, ok := curatedProviderIdentity(c.Providers[idx]); ok && route != "" { + routePresent[route] = true + } else { + routePresent[strings.TrimSpace(c.Providers[idx].Name)] = true + } + } + for _, tmpl := range accountRouteTemplates(g.family) { + if !routePresent[tmpl.RouteID] { + account.DisabledRoutes = append(account.DisabledRoutes, tmpl.RouteID) + } + } + account.DisabledRoutes = normalizeProviderAccountRoutes(account.DisabledRoutes) if account.APIKeyEnv == "" { account.APIKeyEnv = baseAPIKeyEnvForGroup(g.family) } @@ -142,7 +156,6 @@ func attachOrphanCuratedProviders(c *Config) bool { c.ProviderAccounts = append(c.ProviderAccounts, account) usedIDs[account.key()] = true idx = len(c.ProviderAccounts) - 1 - changed = true } if routeID == "" { routeID = p.Name @@ -166,7 +179,6 @@ func inferAccountPresetID(c *Config, indexes []int) string { } func legacyAccountID(keyEnv, family string, used map[providerAccountKey]bool) string { - sum := sha256.Sum256([]byte(family + "\x00" + keyEnv)) - id := legacyAccountIDPrefix + hex.EncodeToString(sum[:3]) + id := legacyAccountIDPrefix + providerIdentityHash(family + "\x00" + keyEnv)[:6] return uniqueProviderAccountID(family, id, used) } diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index 3be6d7d343..7513fa039c 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -24,6 +24,9 @@ func (c *Config) AddProviderAccount(providerID, presetID, label, apiKeyEnv strin if providerID == "" { return ProviderAccount{}, fmt.Errorf("provider account: provider_id is required") } + if len(accountRouteTemplates(providerID)) == 0 { + return ProviderAccount{}, fmt.Errorf("unknown provider family %q", providerID) + } if label == "" { label = defaultAccountLabel(MainProviderAccountID) } @@ -68,6 +71,7 @@ func (c *Config) SetProviderAccountDefault(providerID, accountID string) error { c.ProviderAccounts[i].Default = i == idx } } + c.syncFamilyDefaultModel(account.ProviderID, account.ID) return nil } @@ -83,6 +87,90 @@ func (c *Config) SetProviderAccountEnabled(providerID, accountID string, enabled if !enabled && account.Default { c.ProviderAccounts[idx].Default = false c.ensureFamilyDefault(account.ProviderID) + if replacement, ok := c.DefaultAccount(account.ProviderID); ok { + c.syncFamilyDefaultModel(account.ProviderID, replacement.ID) + } + } + return nil +} + +// SetProviderAccountRouteEnabled controls whether a generated route is +// available for new model selection. Disabled routes remain materialized in +// Providers so existing sessions and explicit references can still resolve. +func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID string, enabled bool) error { + if c == nil { + return fmt.Errorf("set account route: nil config") + } + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("set account route: no account %s/%s", providerID, accountID) + } + if account.Retired { + return fmt.Errorf("set account route: %s/%s is retired", providerID, accountID) + } + routeID = strings.TrimSpace(routeID) + if routeID == "" { + return fmt.Errorf("set account route: route_id is required") + } + known := false + for _, tmpl := range accountRouteTemplates(account.ProviderID) { + if tmpl.RouteID == routeID { + known = true + break + } + } + if !known { + return fmt.Errorf("set account route: unknown route %q for provider %s", routeID, providerID) + } + disabled := normalizeProviderAccountRoutes(account.DisabledRoutes) + filtered := disabled[:0] + for _, route := range disabled { + if route != routeID { + filtered = append(filtered, route) + } + } + if !enabled { + filtered = append(filtered, routeID) + } + c.ProviderAccounts[idx].DisabledRoutes = normalizeProviderAccountRoutes(filtered) + if _, _, err := ReconcileProviderAccounts(c); err != nil { + return err + } + return nil +} + +// RestoreProviderAccount re-enables an account, clears disabled routes and +// recreates any missing generated provider entries. Existing provider names +// and user customizations are preserved by reconciliation. +func (c *Config) RestoreProviderAccount(providerID, accountID string) error { + if c == nil { + return fmt.Errorf("restore account: nil config") + } + idx, account, ok := c.lookupProviderAccount(providerID, accountID) + if !ok { + return fmt.Errorf("restore account: no account %s/%s", providerID, accountID) + } + c.ProviderAccounts[idx].Retired = false + c.ProviderAccounts[idx].Enabled = boolPointer(true) + c.ProviderAccounts[idx].DisabledRoutes = nil + if !c.hasProviderFamilyDefault(providerID) { + c.ProviderAccounts[idx].Default = true + } + entries, err := ExpandProviderAccount(c, c.ProviderAccounts[idx]) + if err != nil { + c.ProviderAccounts[idx] = account + return err + } + for _, generated := range entries { + if err := c.UpsertProvider(generated); err != nil { + c.ProviderAccounts[idx] = account + return err + } + c.markUserProvider(generated.Name) + } + if _, _, err := ReconcileProviderAccounts(c); err != nil { + c.ProviderAccounts[idx] = account + return err } return nil } @@ -205,6 +293,54 @@ func (c *Config) ensureFamilyDefault(providerID string) { } } +func (c *Config) syncFamilyDefaultModel(providerID, accountID string) { + if c == nil { + return + } + current := strings.TrimSpace(c.DefaultModel) + if current == "" { + return + } + entry, ok := c.ResolveModel(current) + if !ok { + return + } + family, _, ok := ProviderAccountIdentity(*entry) + if !ok || family != providerID { + return + } + _, target, ok := c.lookupProviderAccount(providerID, accountID) + if !ok || !target.IsEnabled() { + return + } + entries, ok := c.ResolveAccountProvider(providerID, accountID) + if !ok { + return + } + model := entry.Model + for _, candidate := range entries { + if candidate.HasModel(model) && c.accountSelectable(candidate) { + c.DefaultModel = candidate.Name + "/" + model + return + } + } + for _, candidate := range entries { + if !c.accountSelectable(candidate) { + continue + } + models := candidate.ChatModelList() + if len(models) == 0 { + continue + } + selected := candidate.DefaultModel() + if !candidate.HasModel(selected) { + selected = models[0] + } + c.DefaultModel = candidate.Name + "/" + selected + return + } +} + func (c *Config) SetProviderEffort(name, effort string) error { for i := range c.Providers { if c.Providers[i].Name == name { diff --git a/internal/config/provider_account_render.go b/internal/config/provider_account_render.go index e4a74c72cd..e5b00d7fb3 100644 --- a/internal/config/provider_account_render.go +++ b/internal/config/provider_account_render.go @@ -31,6 +31,9 @@ func renderProviderAccounts(b *strings.Builder, c *Config, scope RenderScope) { if a.Retired { b.WriteString("retired = true\n") } + if routes := normalizeProviderAccountRoutes(a.DisabledRoutes); len(routes) > 0 { + fmt.Fprintf(b, "disabled_routes = %s\n", renderStringArray(routes)) + } b.WriteString("\n") } } diff --git a/internal/config/provider_account_resolve.go b/internal/config/provider_account_resolve.go index a0b4338c60..ce773a1cf1 100644 --- a/internal/config/provider_account_resolve.go +++ b/internal/config/provider_account_resolve.go @@ -10,7 +10,7 @@ func (c *Config) DefaultAccount(providerID string) (ProviderAccount, bool) { var first ProviderAccount found := false for _, account := range c.ProviderAccounts { - if account.ProviderID != providerID || account.Retired { + if account.ProviderID != providerID || !account.IsEnabled() { continue } if !found { @@ -186,7 +186,7 @@ func (c *Config) resolveNewSessionChatModel(providerAllowed func(string) bool, p keylessDefault := "" if def != "" { if entry, found := c.ResolveModel(def); found { - if providerAllowed(entry.Name) && IsLikelyChatModel(entry.Model) { + if providerAllowed(entry.Name) && c.accountSelectable(*entry) && IsLikelyChatModel(entry.Model) { if entry.Configured() { return def, false, true } diff --git a/internal/config/provider_account_test.go b/internal/config/provider_account_test.go index 187c62d210..91dc326cb5 100644 --- a/internal/config/provider_account_test.go +++ b/internal/config/provider_account_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" ) @@ -90,6 +91,21 @@ func TestProviderAccountTOMLOmitsSecrets(t *testing.T) { } } +func TestProviderAccountDisabledRoutesRenderNormalized(t *testing.T) { + cfg := &Config{ProviderAccounts: []ProviderAccount{{ + ProviderID: "opencode-go", ID: "team", Label: "Team", APIKeyEnv: "TEAM_KEY", + DisabledRoutes: []string{" opencode-go-responses ", "opencode-go-responses", "opencode-go-anthropic"}, + }}} + normalizeProviderAccountList(cfg) + if got, want := cfg.ProviderAccounts[0].DisabledRoutes, []string{"opencode-go-anthropic", "opencode-go-responses"}; !reflect.DeepEqual(got, want) { + t.Fatalf("disabled routes = %v, want %v", got, want) + } + raw := RenderTOML(cfg) + if !strings.Contains(raw, `disabled_routes = ["opencode-go-anthropic", "opencode-go-responses"]`) { + t.Fatalf("render missing normalized disabled routes:\n%s", raw) + } +} + func TestExpandOpenCodeGoAndDeepSeekAccounts(t *testing.T) { cfg := Default() main, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "主账号", "OPENCODE_GO_API_KEY") @@ -367,3 +383,74 @@ func TestResolveFamilyAndExplicitAccountModel(t *testing.T) { t.Fatalf("disabled account leaked into new session candidates: %s", ref) } } + +func TestCuratedIdentityDoesNotInferCustomEndpointByURL(t *testing.T) { + cfg := &Config{Providers: []ProviderEntry{{ + Name: "my-gateway", + Kind: "openai", + BaseURL: "https://api.deepseek.com", + Model: "custom-model", + APIKeyEnv: "CUSTOM_KEY", + }}} + if inferProviderAccounts(cfg) { + t.Fatal("custom endpoint was inferred as a curated account") + } + if len(cfg.ProviderAccounts) != 0 { + t.Fatalf("provider accounts = %+v, want none", cfg.ProviderAccounts) + } + if _, _, _, ok := curatedProviderIdentity(cfg.Providers[0]); ok { + t.Fatal("custom provider was classified as a curated family") + } +} + +func TestProviderAccountDisabledRoutesSurviveReconcileAndRestore(t *testing.T) { + cfg := Default() + if _, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Main", "OPENCODE_MAIN_KEY"); err != nil { + t.Fatal(err) + } + account, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Team", "OPENCODE_TEAM_KEY") + if err != nil { + t.Fatal(err) + } + if err := cfg.SetProviderAccountRouteEnabled(account.ProviderID, account.ID, "opencode-go-responses", false); err != nil { + t.Fatal(err) + } + if _, ok := cfg.Provider("opencode-go-responses--team"); !ok { + t.Fatal("disabled route provider entry disappeared") + } + _, stored, ok := cfg.lookupProviderAccount(account.ProviderID, account.ID) + if !ok || len(stored.DisabledRoutes) != 1 || stored.DisabledRoutes[0] != "opencode-go-responses" { + t.Fatalf("disabled routes = %v accounts=%+v", stored.DisabledRoutes, cfg.ProviderAccounts) + } + if changed, _, err := ReconcileProviderAccounts(cfg); err != nil || changed { + t.Fatalf("reconcile changed=%v err=%v", changed, err) + } + if _, ok := cfg.ResolveModel("opencode-go--team/glm-5.2"); !ok { + t.Fatal("explicit account model should remain resolvable") + } + if err := cfg.RestoreProviderAccount(account.ProviderID, account.ID); err != nil { + t.Fatal(err) + } + _, stored, _ = cfg.lookupProviderAccount(account.ProviderID, account.ID) + if len(stored.DisabledRoutes) != 0 { + t.Fatalf("restore left disabled routes: %v", stored.DisabledRoutes) + } +} + +func TestSetDefaultAccountUpdatesFamilyDefaultModel(t *testing.T) { + cfg := Default() + ensureProviderAccounts(cfg) + if _, err := cfg.AddProviderAccount("deepseek", "", "Team", "DEEPSEEK_TEAM_KEY"); err != nil { + t.Fatal(err) + } + if err := cfg.SetProviderAccountDefault("deepseek", "team"); err != nil { + t.Fatal(err) + } + entry, ok := cfg.ResolveModel(cfg.DefaultModel) + if !ok || entry.AccountID != "team" { + t.Fatalf("default model = %q, resolved entry = %+v", cfg.DefaultModel, entry) + } + if got, _, ok := cfg.ResolveNewSessionChatModel(); !ok || !strings.Contains(got, "--team/") { + t.Fatalf("new session model = %q, want team account", got) + } +} From 8ce64efaf47f0bf421d1c5dbec6c55769510e544 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:41:35 +0800 Subject: [PATCH 05/58] fix(cli): persist provider account setup operations Problem: CLI setup mutated provider accounts only in memory, and account creation was limited to two curated groups.\nRoot cause: setup replay tracked provider entries but had no account operation snapshots; account menu also used hard-coded preset IDs.\nFix: record/replay account add, rename, default, enable, retire, route and restore mutations; include account changes in summaries; select one deterministic recommended preset per curated account group; keep credential prompts out of generic logging paths.\nVerification: go test -mod=mod ./internal/cli -count=1 --- internal/cli/setup_accounts.go | 154 +++++++++++--- internal/cli/setup_manager.go | 313 +++++++++++++++++++++++++++-- internal/cli/setup_manager_test.go | 128 ++++++++++++ 3 files changed, 555 insertions(+), 40 deletions(-) diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go index 11220c1093..563a7eaab4 100644 --- a/internal/cli/setup_accounts.go +++ b/internal/cli/setup_accounts.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "os" + "sort" "strings" "reasonix/internal/config" @@ -33,9 +34,6 @@ func providerManagerMenu(s *providerSetupSession) ([]menuItem, []setupMenuAction actions := make([]setupMenuAction, 0, len(s.cfg.Providers)+8) seen := map[string]bool{} for _, account := range s.cfg.ProviderAccounts { - if account.Retired { - continue - } entries, _ := s.cfg.ResolveAccountProvider(account.ProviderID, account.ID) keyStatus := i18n.M.SetupKeyMissing if account.APIKeyEnv == "" || config.CredentialIsSet(account.APIKeyEnv) || s.pendingCredentials[account.APIKeyEnv] != "" { @@ -48,6 +46,9 @@ func providerManagerMenu(s *providerSetupSession) ([]menuItem, []setupMenuAction if !account.IsEnabled() { desc += " · disabled" } + if account.Retired { + desc += " · retired" + } items = append(items, menuItem{name: account.Label, desc: desc}) actions = append(actions, setupMenuAction{kind: setupMenuAccount, providerID: account.ProviderID, accountID: account.ID}) for _, e := range entries { @@ -94,30 +95,83 @@ func manageProviderAccount(s *providerSetupSession, providerID, accountID string if !ok { return } - idx, err := selectOne(fmt.Sprintf("%s / %s", account.ProviderID, account.Label), []menuItem{ - {name: i18n.M.SetupUpdateKey}, - {name: i18n.M.SetupSetDefault}, - {name: i18n.M.SetupAccountRename}, - {name: i18n.M.SetupAccountToggle}, - {name: i18n.M.SetupAccountRetire}, - {name: i18n.M.SetupBack}, - }) - if err != nil || idx == 5 { + items := []menuItem{{name: i18n.M.SetupUpdateKey}} + var routeIDs []string + if !account.Retired { + items = append(items, + menuItem{name: i18n.M.SetupSetDefault}, + menuItem{name: i18n.M.SetupAccountRename}, + menuItem{name: i18n.M.SetupAccountToggle}, + menuItem{name: i18n.M.SetupAccountRetire}, + ) + seenRoutes := map[string]bool{} + for _, entry := range s.cfg.Providers { + if entry.AccountProviderID != account.ProviderID || entry.AccountID != account.ID { + continue + } + routeID := strings.TrimSpace(entry.AccountRouteID) + if routeID == "" || seenRoutes[routeID] { + continue + } + seenRoutes[routeID] = true + routeIDs = append(routeIDs, routeID) + disabled := containsString(account.DisabledRoutes, routeID) + action := "Enable route" + if !disabled { + action = "Disable route" + } + items = append(items, menuItem{name: fmt.Sprintf("%s: %s", action, routeID)}) + } + sort.Strings(routeIDs) + } else { + items = append(items, menuItem{name: "Restore account"}) + } + items = append(items, menuItem{name: i18n.M.SetupBack}) + idx, err := selectOne(fmt.Sprintf("%s / %s", account.ProviderID, account.Label), items) + if err != nil || idx == len(items)-1 { return } switch idx { case 0: updateAccountKey(s, account) case 1: - _ = s.cfg.SetProviderAccountDefault(account.ProviderID, account.ID) + if account.Retired { + if err := s.restoreProviderAccount(account.ProviderID, account.ID); err != nil { + fmt.Fprintln(os.Stderr, err) + } + return + } + if err := s.mutateProviderAccount(account.ProviderID, account.ID, func() error { + return s.cfg.SetProviderAccountDefault(account.ProviderID, account.ID) + }); err != nil { + fmt.Fprintln(os.Stderr, err) + } case 2: renameSessionAccount(s, account) case 3: - _ = s.cfg.SetProviderAccountEnabled(account.ProviderID, account.ID, !account.IsEnabled()) + if err := s.mutateProviderAccount(account.ProviderID, account.ID, func() error { + return s.cfg.SetProviderAccountEnabled(account.ProviderID, account.ID, !account.IsEnabled()) + }); err != nil { + fmt.Fprintln(os.Stderr, err) + } case 4: - if err := s.cfg.RetireProviderAccount(account.ProviderID, account.ID); err != nil { + if err := s.mutateProviderAccount(account.ProviderID, account.ID, func() error { + return s.cfg.RetireProviderAccount(account.ProviderID, account.ID) + }); err != nil { fmt.Fprintln(os.Stderr, err) } + default: + // Account actions occupy slots 0..4. Route toggles follow them. + if account.Retired { + return + } + if routeIndex := idx - 5; routeIndex >= 0 && routeIndex < len(routeIDs) { + routeID := routeIDs[routeIndex] + disabled := containsString(account.DisabledRoutes, routeID) + if err := s.setProviderAccountRouteEnabled(account.ProviderID, account.ID, routeID, disabled); err != nil { + fmt.Fprintln(os.Stderr, err) + } + } } } @@ -126,12 +180,7 @@ func addProviderAccountToSession(s *providerSetupSession) bool { fmt.Fprintln(os.Stderr, i18n.M.SetupProjectNoAccounts) return false } - presets := []config.ProviderPreset{} - for _, preset := range config.CuratedProviderPresets() { - if preset.ID == "opencode-go-recommended" || preset.AccountGroupID == "deepseek" && preset.ID == "deepseek-responses" { - presets = append(presets, preset) - } - } + presets := curatedAccountSetupPresets() if len(presets) == 0 { return false } @@ -148,12 +197,13 @@ func addProviderAccountToSession(s *providerSetupSession) bool { if label == "" { return false } - key := strings.TrimSpace(askLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, preset.KeyEnv), "")) + key := strings.TrimSpace(askCredentialLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, preset.KeyEnv))) account, err := s.cfg.AddProviderAccount(preset.AccountGroupID, preset.ID, label, "") if err != nil { fmt.Fprintln(os.Stderr, err) return false } + s.recordProviderAccountMutation(account.ProviderID, account.ID, nil, providerSetupAccountPtr(account), "", nil, nil) if key != "" { if err := s.setCredential(account.APIKeyEnv, key); err != nil { fmt.Fprintln(os.Stderr, err) @@ -166,11 +216,48 @@ func addProviderAccountToSession(s *providerSetupSession) bool { return true } +// curatedAccountSetupPresets returns one deterministic preset per curated +// provider account group. Recommended presets win; ties use display order and +// then ID so adding a new curated route cannot make setup nondeterministic. +func curatedAccountSetupPresets() []config.ProviderPreset { + byGroup := map[string]config.ProviderPreset{} + for _, preset := range config.CuratedProviderPresets() { + group := strings.TrimSpace(preset.AccountGroupID) + if group == "" { + continue + } + current, ok := byGroup[group] + if !ok || accountSetupPresetLess(preset, current) { + byGroup[group] = preset + } + } + groups := make([]string, 0, len(byGroup)) + for group := range byGroup { + groups = append(groups, group) + } + sort.Strings(groups) + out := make([]config.ProviderPreset, 0, len(groups)) + for _, group := range groups { + out = append(out, byGroup[group]) + } + return out +} + +func accountSetupPresetLess(a, b config.ProviderPreset) bool { + if a.Recommended != b.Recommended { + return a.Recommended + } + if a.DisplayOrder != b.DisplayOrder { + return a.DisplayOrder < b.DisplayOrder + } + return a.ID < b.ID +} + func updateAccountKey(s *providerSetupSession, account config.ProviderAccount) { if strings.TrimSpace(account.APIKeyEnv) == "" { return } - value := strings.TrimSpace(askLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, account.APIKeyEnv), "")) + value := strings.TrimSpace(askCredentialLine(fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, account.APIKeyEnv))) if value == "" { return } @@ -184,7 +271,11 @@ func renameSessionAccount(s *providerSetupSession, account config.ProviderAccoun if label == "" { return } - _ = s.cfg.RenameProviderAccount(account.ProviderID, account.ID, label) + if err := s.mutateProviderAccount(account.ProviderID, account.ID, func() error { + return s.cfg.RenameProviderAccount(account.ProviderID, account.ID, label) + }); err != nil { + fmt.Fprintln(os.Stderr, err) + } } func lookupSessionAccount(s *providerSetupSession, providerID, accountID string) (int, config.ProviderAccount, bool) { @@ -197,7 +288,10 @@ func lookupSessionAccount(s *providerSetupSession, providerID, accountID string) } func askLine(label, def string) string { - fmt.Printf("%s [%s]: ", label, def) + // Keep the default value in control flow only. Printing arbitrary defaults + // from this generic helper can accidentally expose a secret if a caller is + // added later with a credential as its fallback. + fmt.Printf("%s: ", label) var line string _, _ = fmt.Scanln(&line) line = strings.TrimSpace(line) @@ -206,3 +300,13 @@ func askLine(label, def string) string { } return line } + +func askCredentialLine(label string) string { + // Never echo a credential value or pass it through the generic prompt's + // default argument. This keeps API key input out of logging sinks while + // still showing the environment variable name to the user. + fmt.Printf("%s: ", label) + var line string + _, _ = fmt.Scanln(&line) + return strings.TrimSpace(line) +} diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index 5b5e97033f..4aa2fa5eee 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "reflect" "sort" "strings" "time" @@ -27,6 +28,7 @@ type providerSetupSession struct { projectScoped bool declaredProviders []string operations []providerSetupOperation + originalAccounts map[string]config.ProviderAccount } const setupManagerContinue = 2 @@ -39,19 +41,30 @@ const ( setupOpLanguage setupOpMaterializeAccess setupOpAccessMembership + setupOpProviderAccount ) type providerSetupOperation struct { - kind providerSetupOperationKind - providerName string - beforeProvider *config.ProviderEntry - afterProvider *config.ProviderEntry - beforeString string - afterString string - accessName string - projectScoped bool - beforeBool bool - afterBool bool + kind providerSetupOperationKind + providerName string + beforeProvider *config.ProviderEntry + afterProvider *config.ProviderEntry + beforeString string + afterString string + accessName string + projectScoped bool + beforeBool bool + afterBool bool + providerID string + accountID string + beforeAccount *config.ProviderAccount + afterAccount *config.ProviderAccount + routeID string + beforeRoute *bool + afterRoute *bool + beforeAccounts []config.ProviderAccount + afterAccounts []config.ProviderAccount + accountDefaultModelChanged bool } type providerSetupConflictError struct { @@ -94,13 +107,199 @@ func newProviderSetupSession(cfg *config.Config) *providerSetupSession { originalDefault: cfg.DefaultModel, pendingCredentials: map[string]string{}, removed: map[string]bool{}, + originalAccounts: make(map[string]config.ProviderAccount, len(cfg.ProviderAccounts)), } for _, p := range cfg.Providers { s.originalProviders[p.Name] = p } + for _, account := range cfg.ProviderAccounts { + s.originalAccounts[providerSetupAccountKey(account.ProviderID, account.ID)] = cloneProviderSetupAccount(account) + } return s } +func providerSetupAccountKey(providerID, accountID string) string { + return strings.TrimSpace(providerID) + "\x00" + strings.TrimSpace(accountID) +} + +func cloneProviderSetupAccount(account config.ProviderAccount) config.ProviderAccount { + if account.Enabled != nil { + enabled := *account.Enabled + account.Enabled = &enabled + } + account.DisabledRoutes = append([]string(nil), account.DisabledRoutes...) + return account +} + +func providerSetupAccountPtr(account config.ProviderAccount) *config.ProviderAccount { + copy := cloneProviderSetupAccount(account) + return © +} + +func (s *providerSetupSession) recordProviderAccountMutation(providerID, accountID string, before, after *config.ProviderAccount, routeID string, beforeRoute, afterRoute *bool) { + var beforeAccounts, afterAccounts []config.ProviderAccount + if before != nil { + beforeAccounts = []config.ProviderAccount{cloneProviderSetupAccount(*before)} + } + if after != nil { + afterAccounts = []config.ProviderAccount{cloneProviderSetupAccount(*after)} + } + s.operations = append(s.operations, providerSetupOperation{ + kind: setupOpProviderAccount, + providerID: strings.TrimSpace(providerID), + accountID: strings.TrimSpace(accountID), + beforeAccount: before, + afterAccount: after, + routeID: strings.TrimSpace(routeID), + beforeRoute: beforeRoute, + afterRoute: afterRoute, + beforeAccounts: beforeAccounts, + afterAccounts: afterAccounts, + }) +} + +func (s *providerSetupSession) recordProviderAccountFamilyMutation(providerID, accountID string, before, after []config.ProviderAccount, routeID string) { + var targetBefore, targetAfter *config.ProviderAccount + for i := range before { + if before[i].ProviderID == providerID && before[i].ID == accountID { + value := cloneProviderSetupAccount(before[i]) + targetBefore = &value + } + } + for i := range after { + if after[i].ProviderID == providerID && after[i].ID == accountID { + value := cloneProviderSetupAccount(after[i]) + targetAfter = &value + } + } + s.recordProviderAccountMutation(providerID, accountID, targetBefore, targetAfter, routeID, nil, nil) + if len(s.operations) == 0 { + return + } + op := &s.operations[len(s.operations)-1] + op.beforeAccounts = cloneProviderSetupAccounts(before) + op.afterAccounts = cloneProviderSetupAccounts(after) +} + +func cloneProviderSetupAccounts(accounts []config.ProviderAccount) []config.ProviderAccount { + if accounts == nil { + return nil + } + out := make([]config.ProviderAccount, len(accounts)) + for i, account := range accounts { + out[i] = cloneProviderSetupAccount(account) + } + return out +} + +func (s *providerSetupSession) accountFamilySnapshots(providerID string) []config.ProviderAccount { + if s == nil || s.cfg == nil { + return nil + } + var out []config.ProviderAccount + for _, account := range s.cfg.ProviderAccounts { + if account.ProviderID == strings.TrimSpace(providerID) { + out = append(out, cloneProviderSetupAccount(account)) + } + } + return out +} + +func (s *providerSetupSession) mutateProviderAccount(providerID, accountID string, mutate func() error) error { + familyBefore := s.accountFamilySnapshots(providerID) + defaultBefore := s.cfg.DefaultModel + before, ok := s.snapshotAccount(providerID, accountID) + if !ok { + return fmt.Errorf("provider account %s/%s not found", providerID, accountID) + } + if err := mutate(); err != nil { + return err + } + after, ok := s.snapshotAccount(providerID, accountID) + if !ok { + return fmt.Errorf("provider account %s/%s disappeared after mutation", providerID, accountID) + } + if !reflect.DeepEqual(before, after) { + s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), "") + if len(s.operations) > 0 { + op := &s.operations[len(s.operations)-1] + op.accountDefaultModelChanged = defaultBefore != s.cfg.DefaultModel + op.beforeString = defaultBefore + op.afterString = s.cfg.DefaultModel + } + } + return nil +} + +func (s *providerSetupSession) setProviderAccountRouteEnabled(providerID, accountID, routeID string, enabled bool) error { + familyBefore := s.accountFamilySnapshots(providerID) + defaultBefore := s.cfg.DefaultModel + before, ok := s.snapshotAccount(providerID, accountID) + if !ok { + return fmt.Errorf("provider account %s/%s not found", providerID, accountID) + } + if err := s.cfg.SetProviderAccountRouteEnabled(providerID, accountID, routeID, enabled); err != nil { + return err + } + after, _ := s.snapshotAccount(providerID, accountID) + if !reflect.DeepEqual(before, after) { + s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), routeID) + if len(s.operations) > 0 { + op := &s.operations[len(s.operations)-1] + op.accountDefaultModelChanged = defaultBefore != s.cfg.DefaultModel + op.beforeString = defaultBefore + op.afterString = s.cfg.DefaultModel + } + } + return nil +} + +func (s *providerSetupSession) restoreProviderAccount(providerID, accountID string) error { + familyBefore := s.accountFamilySnapshots(providerID) + defaultBefore := s.cfg.DefaultModel + before, ok := s.snapshotAccount(providerID, accountID) + if !ok { + return fmt.Errorf("provider account %s/%s not found", providerID, accountID) + } + if err := s.cfg.RestoreProviderAccount(providerID, accountID); err != nil { + return err + } + after, _ := s.snapshotAccount(providerID, accountID) + if !reflect.DeepEqual(before, after) { + s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), "") + if len(s.operations) > 0 { + op := &s.operations[len(s.operations)-1] + op.accountDefaultModelChanged = defaultBefore != s.cfg.DefaultModel + op.beforeString = defaultBefore + op.afterString = s.cfg.DefaultModel + } + } + return nil +} + +func (s *providerSetupSession) snapshotAccount(providerID, accountID string) (*config.ProviderAccount, bool) { + if s == nil || s.cfg == nil { + return nil, false + } + _, account, ok := s.cfgAccount(providerID, accountID) + if !ok { + return nil, false + } + return providerSetupAccountPtr(account), true +} + +func (s *providerSetupSession) cfgAccount(providerID, accountID string) (int, config.ProviderAccount, bool) { + if s == nil || s.cfg == nil { + return -1, config.ProviderAccount{}, false + } + for i, account := range s.cfg.ProviderAccounts { + if strings.TrimSpace(account.ProviderID) == strings.TrimSpace(providerID) && strings.TrimSpace(account.ID) == strings.TrimSpace(accountID) { + return i, account, true + } + } + return -1, config.ProviderAccount{}, false +} + func newProviderSetupSessionForPath(cfg *config.Config, path string) *providerSetupSession { s := newProviderSetupSession(cfg) s.projectScoped = !config.IsUserConfigPath(path) @@ -434,6 +633,25 @@ func (s *providerSetupSession) summary() []string { if len(edited) > 0 { out = append(out, fmt.Sprintf(i18n.M.SetupSummaryEditedFmt, strings.Join(edited, ", "))) } + var addedAccounts, editedAccounts []string + for _, account := range s.cfg.ProviderAccounts { + key := providerSetupAccountKey(account.ProviderID, account.ID) + old, existed := s.originalAccounts[key] + label := fmt.Sprintf("%s/%s", account.ProviderID, account.Label) + if !existed { + addedAccounts = append(addedAccounts, label) + } else if !reflect.DeepEqual(old, account) { + editedAccounts = append(editedAccounts, label) + } + } + sort.Strings(addedAccounts) + sort.Strings(editedAccounts) + if len(addedAccounts) > 0 { + out = append(out, fmt.Sprintf("accounts added: %s", strings.Join(addedAccounts, ", "))) + } + if len(editedAccounts) > 0 { + out = append(out, fmt.Sprintf("accounts changed: %s", strings.Join(editedAccounts, ", "))) + } if len(s.removed) > 0 { names := make([]string, 0, len(s.removed)) for name := range s.removed { @@ -514,11 +732,6 @@ func runProviderSetupManager(s *providerSetupSession, configPath, envPath string } } -func providerManagerItems(s *providerSetupSession) []menuItem { - items, _ := providerManagerMenu(s) - return items -} - func addProviderToSession(s *providerSetupSession, anthropic bool) bool { var result providerPromptResult var err error @@ -827,6 +1040,10 @@ func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclar } cfg.Desktop.ProviderAccess = out } + case setupOpProviderAccount: + if err := replayProviderAccountOperation(cfg, operation); err != nil { + return err + } default: return fmt.Errorf("unknown provider setup operation %d", operation.kind) } @@ -834,6 +1051,72 @@ func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclar return nil } +func replayProviderAccountOperation(cfg *config.Config, operation providerSetupOperation) error { + field := fmt.Sprintf("provider account %s/%s", operation.providerID, operation.accountID) + if strings.TrimSpace(operation.providerID) == "" || strings.TrimSpace(operation.accountID) == "" { + return fmt.Errorf("replay provider account: missing identity") + } + before := operation.beforeAccounts + after := operation.afterAccounts + // Older operation snapshots contain only the target account. Keep those + // snapshots valid while preferring full-family snapshots for mutations that + // also change which account is default. + if before == nil && operation.beforeAccount != nil { + before = []config.ProviderAccount{*operation.beforeAccount} + } + if after == nil && operation.afterAccount != nil { + after = []config.ProviderAccount{*operation.afterAccount} + } + current := make(map[string]config.ProviderAccount) + for _, account := range cfg.ProviderAccounts { + if account.ProviderID == operation.providerID { + current[providerSetupAccountKey(account.ProviderID, account.ID)] = account + } + } + if len(before) == 0 { + // New account: reject an existing identity, then append all after + // snapshots (normally one account) and reconcile generated routes. + for _, account := range after { + key := providerSetupAccountKey(account.ProviderID, account.ID) + if _, exists := current[key]; exists { + return &providerSetupConflictError{field: field} + } + cfg.ProviderAccounts = append(cfg.ProviderAccounts, cloneProviderSetupAccount(account)) + } + } else { + for _, account := range before { + key := providerSetupAccountKey(account.ProviderID, account.ID) + got, exists := current[key] + if !exists || !reflect.DeepEqual(got, account) { + return &providerSetupConflictError{field: field} + } + } + beforeKeys := make(map[string]bool, len(before)) + for _, account := range before { + beforeKeys[providerSetupAccountKey(account.ProviderID, account.ID)] = true + } + for i := len(cfg.ProviderAccounts) - 1; i >= 0; i-- { + account := cfg.ProviderAccounts[i] + if account.ProviderID == operation.providerID && beforeKeys[providerSetupAccountKey(account.ProviderID, account.ID)] { + cfg.ProviderAccounts = append(cfg.ProviderAccounts[:i], cfg.ProviderAccounts[i+1:]...) + } + } + cfg.ProviderAccounts = append(cfg.ProviderAccounts, cloneProviderSetupAccounts(after)...) + } + if _, _, reconcileErr := config.ReconcileProviderAccounts(cfg); reconcileErr != nil { + return fmt.Errorf("replay %s: %w", field, reconcileErr) + } + if operation.accountDefaultModelChanged { + if cfg.DefaultModel != operation.beforeString { + return &providerSetupConflictError{field: "default_model"} + } + if err := cfg.SetDefaultModel(operation.afterString); err != nil { + return fmt.Errorf("replay account default_model: %w", err) + } + } + return nil +} + func providerSetupAccessContains(names []string, want string) bool { want = strings.TrimSpace(want) for _, name := range names { diff --git a/internal/cli/setup_manager_test.go b/internal/cli/setup_manager_test.go index f5e4e9ffa6..eb29ce5074 100644 --- a/internal/cli/setup_manager_test.go +++ b/internal/cli/setup_manager_test.go @@ -744,3 +744,131 @@ func TestLocalSetupPersistsWorkspaceProviderAccess(t *testing.T) { t.Fatalf("local setup leaked user-global desktop preferences:\n%s", text) } } + +func TestProviderAccountSetupOperationReplayPersistsAdd(t *testing.T) { + isolateUserConfig(t) + path := config.UserConfigPath() + initial := config.Default() + if err := initial.SaveTo(path); err != nil { + t.Fatal(err) + } + working := config.LoadForEdit(path) + s := newProviderSetupSessionForPath(working, path) + account, err := working.AddProviderAccount("deepseek", "deepseek-responses", "Team", "TEAM_DEEPSEEK_KEY") + if err != nil { + t.Fatal(err) + } + s.recordProviderAccountMutation(account.ProviderID, account.ID, nil, providerSetupAccountPtr(account), "", nil, nil) + s.addProviderAccess(entriesForAccount(t, working, account)) + if _, err := commitProviderSetupSession(s, path); err != nil { + t.Fatal(err) + } + reloaded := config.LoadForEditWithoutCredentials(path) + got, ok := reloadedAccount(reloaded, account.ProviderID, account.ID) + if !ok || got.Label != "Team" { + t.Fatalf("reloaded account = %+v, want Team", got) + } + if _, ok := reloaded.ResolveAccountProvider(account.ProviderID, account.ID); !ok { + t.Fatalf("reloaded account routes missing") + } +} + +func TestProviderAccountSetupOperationReplayPersistsRenameDefaultAndRoute(t *testing.T) { + isolateUserConfig(t) + path := config.UserConfigPath() + initial := config.Default() + if _, err := initial.AddProviderAccount("deepseek", "deepseek-responses", "Main", "MAIN_KEY"); err != nil { + t.Fatal(err) + } + if err := initial.SaveTo(path); err != nil { + t.Fatal(err) + } + working := config.LoadForEdit(path) + s := newProviderSetupSessionForPath(working, path) + team, err := working.AddProviderAccount("deepseek", "deepseek-responses", "Team", "TEAM_KEY") + if err != nil { + t.Fatal(err) + } + s.recordProviderAccountMutation(team.ProviderID, team.ID, nil, providerSetupAccountPtr(team), "", nil, nil) + if err := s.mutateProviderAccount(team.ProviderID, team.ID, func() error { + return working.RenameProviderAccount(team.ProviderID, team.ID, "Renamed") + }); err != nil { + t.Fatal(err) + } + if err := s.mutateProviderAccount(team.ProviderID, team.ID, func() error { + return working.SetProviderAccountDefault(team.ProviderID, team.ID) + }); err != nil { + t.Fatal(err) + } + if err := s.setProviderAccountRouteEnabled(team.ProviderID, team.ID, "deepseek-responses", false); err != nil { + t.Fatal(err) + } + if _, err := commitProviderSetupSession(s, path); err != nil { + t.Fatal(err) + } + reloaded := config.LoadForEditWithoutCredentials(path) + got, ok := reloadedAccount(reloaded, team.ProviderID, team.ID) + if !ok || got.Label != "Renamed" || !got.Default || len(got.DisabledRoutes) != 1 || got.DisabledRoutes[0] != "deepseek-responses" { + t.Fatalf("reloaded team account = %+v", got) + } + if !strings.Contains(reloaded.DefaultModel, "--team/") { + t.Fatalf("default model did not follow account default: %q", reloaded.DefaultModel) + } + restoreSession := newProviderSetupSessionForPath(reloaded, path) + if err := restoreSession.restoreProviderAccount(team.ProviderID, team.ID); err != nil { + t.Fatal(err) + } + if _, err := commitProviderSetupSession(restoreSession, path); err != nil { + t.Fatal(err) + } + restored, ok := reloadedAccount(config.LoadForEditWithoutCredentials(path), team.ProviderID, team.ID) + if !ok || len(restored.DisabledRoutes) != 0 || restored.Retired { + t.Fatalf("restored account = %+v", restored) + } +} + +func TestCuratedAccountSetupPresetsCoverEveryGroupDeterministically(t *testing.T) { + want := map[string]config.ProviderPreset{} + for _, preset := range config.CuratedProviderPresets() { + group := preset.AccountGroupID + if group == "" { + continue + } + current, ok := want[group] + if !ok || accountSetupPresetLess(preset, current) { + want[group] = preset + } + } + got := curatedAccountSetupPresets() + if len(got) != len(want) { + t.Fatalf("setup presets = %d, want %d groups", len(got), len(want)) + } + seen := map[string]bool{} + for _, preset := range got { + if seen[preset.AccountGroupID] { + t.Fatalf("duplicate setup group %q", preset.AccountGroupID) + } + seen[preset.AccountGroupID] = true + if preset.ID != want[preset.AccountGroupID].ID { + t.Fatalf("group %q selected %q, want %q", preset.AccountGroupID, preset.ID, want[preset.AccountGroupID].ID) + } + } +} + +func entriesForAccount(t *testing.T, cfg *config.Config, account config.ProviderAccount) []config.ProviderEntry { + t.Helper() + entries, ok := cfg.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok { + t.Fatalf("account %s/%s routes missing", account.ProviderID, account.ID) + } + return entries +} + +func reloadedAccount(cfg *config.Config, providerID, accountID string) (config.ProviderAccount, bool) { + for _, account := range cfg.ProviderAccounts { + if account.ProviderID == providerID && account.ID == accountID { + return account, true + } + } + return config.ProviderAccount{}, false +} From 361bb55ef4924627ee85d24bcd4ed377e600b322 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:43:04 +0800 Subject: [PATCH 06/58] fix(config): synchronize account route access state Problem: toggling an account route only changed account metadata, leaving desktop.provider_access stale. Fix: remove access entries when routes are disabled and restore route access after account restoration or route enablement while retaining provider entries for historical references. Verification: go test ./internal/config -count=1 --- internal/cli/setup_accounts.go | 4 +- internal/config/provider_account_mutate.go | 57 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go index 563a7eaab4..268d78c4df 100644 --- a/internal/cli/setup_accounts.go +++ b/internal/cli/setup_accounts.go @@ -115,6 +115,9 @@ func manageProviderAccount(s *providerSetupSession, providerID, accountID string } seenRoutes[routeID] = true routeIDs = append(routeIDs, routeID) + } + sort.Strings(routeIDs) + for _, routeID := range routeIDs { disabled := containsString(account.DisabledRoutes, routeID) action := "Enable route" if !disabled { @@ -122,7 +125,6 @@ func manageProviderAccount(s *providerSetupSession, providerID, accountID string } items = append(items, menuItem{name: fmt.Sprintf("%s: %s", action, routeID)}) } - sort.Strings(routeIDs) } else { items = append(items, menuItem{name: "Restore account"}) } diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index 7513fa039c..fe4c547929 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -136,6 +136,11 @@ func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID s if _, _, err := ReconcileProviderAccounts(c); err != nil { return err } + if !enabled { + c.removeProviderAccountRouteAccess(account.ProviderID, account.ID, routeID) + } else { + c.restoreProviderAccountRouteAccess(account.ProviderID, account.ID, routeID) + } return nil } @@ -172,9 +177,61 @@ func (c *Config) RestoreProviderAccount(providerID, accountID string) error { c.ProviderAccounts[idx] = account return err } + if c.Desktop.ProviderAccess != nil { + for _, entry := range c.Providers { + if entry.AccountProviderID == providerID && entry.AccountID == accountID { + c.restoreProviderAccountRouteAccess(providerID, accountID, entry.AccountRouteID) + } + } + } return nil } +func (c *Config) removeProviderAccountRouteAccess(providerID, accountID, routeID string) { + if c == nil || c.Desktop.ProviderAccess == nil { + return + } + names := map[string]bool{} + for _, entry := range c.Providers { + if entry.AccountProviderID == providerID && entry.AccountID == accountID && strings.TrimSpace(entry.AccountRouteID) == routeID { + names[entry.Name] = true + } + } + if len(names) == 0 { + return + } + out := c.Desktop.ProviderAccess[:0] + for _, name := range c.Desktop.ProviderAccess { + if !names[strings.TrimSpace(name)] { + out = append(out, name) + } + } + c.Desktop.ProviderAccess = out +} + +func (c *Config) restoreProviderAccountRouteAccess(providerID, accountID, routeID string) { + if c == nil || c.Desktop.ProviderAccess == nil { + return + } + for _, entry := range c.Providers { + if entry.AccountProviderID != providerID || entry.AccountID != accountID || strings.TrimSpace(entry.AccountRouteID) != strings.TrimSpace(routeID) { + continue + } + present := false + for _, name := range c.Desktop.ProviderAccess { + if name == entry.Name { + present = true + break + } + } + if present { + continue + } + c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, entry.Name) + break + } +} + func (c *Config) RenameProviderAccount(providerID, accountID, label string) error { idx, account, ok := c.lookupProviderAccount(providerID, accountID) if !ok { From 0f27da3e8291fc11be8fa2900ab0e3ba6d9b03be Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:03 +0800 Subject: [PATCH 07/58] test(cli): include account state in setup summary Problem: setup summaries only identified changed accounts without showing default, disabled, retired, or route state.\nRoot cause: account metadata was omitted from summary rendering.\nFix: annotate account entries with effective state and disabled route IDs.\nVerification: go test -mod=mod ./internal/cli -run 'ProviderSetup|Account' -count=1 --- internal/cli/setup_manager.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index 4aa2fa5eee..2b4dc03529 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -637,7 +637,7 @@ func (s *providerSetupSession) summary() []string { for _, account := range s.cfg.ProviderAccounts { key := providerSetupAccountKey(account.ProviderID, account.ID) old, existed := s.originalAccounts[key] - label := fmt.Sprintf("%s/%s", account.ProviderID, account.Label) + label := providerSetupAccountSummary(account) if !existed { addedAccounts = append(addedAccounts, label) } else if !reflect.DeepEqual(old, account) { @@ -672,6 +672,26 @@ func (s *providerSetupSession) summary() []string { return out } +func providerSetupAccountSummary(account config.ProviderAccount) string { + label := fmt.Sprintf("%s/%s", account.ProviderID, account.Label) + var states []string + if account.Default { + states = append(states, "default") + } + if account.Retired { + states = append(states, "retired") + } else if !account.IsEnabled() { + states = append(states, "disabled") + } + if len(account.DisabledRoutes) > 0 { + states = append(states, fmt.Sprintf("routes disabled: %s", strings.Join(account.DisabledRoutes, ", "))) + } + if len(states) > 0 { + label += " (" + strings.Join(states, "; ") + ")" + } + return label +} + func providerSetupEqual(a, b config.ProviderEntry) bool { // Render-level equality is unnecessary here: the manager only changes these // fields, while advanced provider fields are preserved by editing a copy. From 9a860ba013f3c49667409efed49304cc10b67561 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:37 +0800 Subject: [PATCH 08/58] test(config): cover disabled route persistence Verification: save and reload a multi-account OpenCode Go configuration, ensuring disabled routes remain hidden from access while retained provider entries and explicit account references continue to resolve. Test: go test ./internal/config -run DisabledRoutes -count=1 --- internal/config/provider_account_test.go | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/config/provider_account_test.go b/internal/config/provider_account_test.go index 91dc326cb5..f8d0d2c095 100644 --- a/internal/config/provider_account_test.go +++ b/internal/config/provider_account_test.go @@ -106,6 +106,37 @@ func TestProviderAccountDisabledRoutesRenderNormalized(t *testing.T) { } } +func TestProviderAccountDisabledRoutesRoundTripWithoutResurrection(t *testing.T) { + home := t.TempDir() + t.Setenv("REASONIX_HOME", home) + path := filepath.Join(home, "config.toml") + cfg := Default() + if _, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Main", "OPENCODE_MAIN_KEY"); err != nil { + t.Fatal(err) + } + account, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Team", "OPENCODE_TEAM_KEY") + if err != nil { + t.Fatal(err) + } + if err := cfg.SetProviderAccountRouteEnabled(account.ProviderID, account.ID, "opencode-go-responses", false); err != nil { + t.Fatal(err) + } + if err := cfg.SaveTo(path); err != nil { + t.Fatal(err) + } + reloaded := LoadForEditWithoutCredentials(path) + _, team, ok := reloaded.lookupProviderAccount("opencode-go", "team") + if !ok || !providerAccountRouteDisabled(team, "opencode-go-responses") { + t.Fatalf("reloaded team account = %+v", team) + } + if _, ok := reloaded.Provider("opencode-go-responses--team"); !ok { + t.Fatal("reloaded config lost retained disabled route entry") + } + if _, ok := reloaded.ResolveModel("opencode-go--team/glm-5.2"); !ok { + t.Fatal("reloaded config lost explicit account model") + } +} + func TestExpandOpenCodeGoAndDeepSeekAccounts(t *testing.T) { cfg := Default() main, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "主账号", "OPENCODE_GO_API_KEY") From 070148aa7bf9d56dbfbc48a07fa16a69f26ec502 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:46:58 +0800 Subject: [PATCH 09/58] fix(cli): hide disabled account routes in provider picker Problem: CLI /provider listed retired, disabled, and route-disabled account entries as selectable providers.\nRoot cause: picker consumed all resolved account routes without filtering account state.\nFix: filter disabled routes and resolve family names through the enabled default account while retaining explicit provider compatibility.\nVerification: go test -mod=mod ./internal/cli -run 'Provider|Account' -count=1 --- internal/cli/provider.go | 45 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/cli/provider.go b/internal/cli/provider.go index d2740f1eaa..aa9294a428 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -42,6 +42,10 @@ func (m *chatTUI) openProviderPicker() { if !ok { continue } + entries = selectableAccountEntries(account, entries) + if len(entries) == 0 { + continue + } usable := false for i := range entries { if entries[i].Configured() { @@ -103,11 +107,22 @@ func (m *chatTUI) switchToProvider(name string) { var entry *config.ProviderEntry for i := range cfg.Providers { p := &cfg.Providers[i] - if p.Name == name && p.Configured() { + if p.Name == name && p.Configured() && providerEntrySelectable(cfg, *p) { entry = p break } } + if entry == nil { + if account, ok := cfg.DefaultAccount(name); ok { + for _, candidate := range selectableAccountEntries(account, mustResolveAccountProvider(cfg, account.ProviderID, account.ID)) { + if candidate.Configured() && len(candidate.ChatModelList()) > 0 { + copy := candidate + entry = © + break + } + } + } + } if entry == nil { m.notice(fmt.Sprintf(i18n.M.ProviderUnknownFmt, name)) return @@ -156,6 +171,34 @@ func (m *chatTUI) switchToProvider(name string) { } } +func mustResolveAccountProvider(cfg *config.Config, providerID, accountID string) []config.ProviderEntry { + entries, _ := cfg.ResolveAccountProvider(providerID, accountID) + return entries +} + +func selectableAccountEntries(account config.ProviderAccount, entries []config.ProviderEntry) []config.ProviderEntry { + if len(entries) == 0 { + return nil + } + disabled := make(map[string]bool, len(account.DisabledRoutes)) + for _, route := range account.DisabledRoutes { + disabled[strings.TrimSpace(route)] = true + } + out := make([]config.ProviderEntry, 0, len(entries)) + for _, entry := range entries { + if disabled[strings.TrimSpace(entry.AccountRouteID)] { + continue + } + out = append(out, entry) + } + return out +} + +func providerEntrySelectable(cfg *config.Config, entry config.ProviderEntry) bool { + account, ok := config.ProviderAccountForEntry(cfg, entry) + return !ok || account.IsEnabled() +} + func (m chatTUI) handleQuickPickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { p := m.quickPick if p == nil { From a7d3bd34d8e9eb0c426cb6b30e8a2cc9d4317f1a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:48:24 +0800 Subject: [PATCH 10/58] fix(cli): record account default model mutations Problem: setting an already-default account could still repair default_model without producing a setup operation.\nRoot cause: account replay snapshots were recorded only when account fields differed.\nFix: record account operations whenever account state or synchronized default_model changes.\nVerification: go test -mod=mod ./internal/cli -run 'ProviderSetup|Account' -count=1 --- internal/cli/setup_manager.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index 2b4dc03529..37c253b80e 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -219,7 +219,7 @@ func (s *providerSetupSession) mutateProviderAccount(providerID, accountID strin if !ok { return fmt.Errorf("provider account %s/%s disappeared after mutation", providerID, accountID) } - if !reflect.DeepEqual(before, after) { + if !reflect.DeepEqual(before, after) || defaultBefore != s.cfg.DefaultModel { s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), "") if len(s.operations) > 0 { op := &s.operations[len(s.operations)-1] @@ -242,7 +242,7 @@ func (s *providerSetupSession) setProviderAccountRouteEnabled(providerID, accoun return err } after, _ := s.snapshotAccount(providerID, accountID) - if !reflect.DeepEqual(before, after) { + if !reflect.DeepEqual(before, after) || defaultBefore != s.cfg.DefaultModel { s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), routeID) if len(s.operations) > 0 { op := &s.operations[len(s.operations)-1] @@ -265,7 +265,7 @@ func (s *providerSetupSession) restoreProviderAccount(providerID, accountID stri return err } after, _ := s.snapshotAccount(providerID, accountID) - if !reflect.DeepEqual(before, after) { + if !reflect.DeepEqual(before, after) || defaultBefore != s.cfg.DefaultModel { s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), "") if len(s.operations) > 0 { op := &s.operations[len(s.operations)-1] From f251ed8f3a34969f3d006285fabdf5d7b4ba341d Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:49:37 +0800 Subject: [PATCH 11/58] fix(config): disable retained routes when retiring account Problem: retiring an account left route state implicit, allowing future reconciliation to expose retained providers. Fix: mark every account route disabled during retirement while retaining provider entries for historical session references. Verification: go test ./internal/config -run ProviderAccount -count=1 --- internal/config/provider_account_mutate.go | 4 ++++ internal/config/provider_account_test.go | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index fe4c547929..10099eb2ec 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -261,6 +261,10 @@ func (c *Config) RetireProviderAccount(providerID, accountID string) error { c.ProviderAccounts[idx].Retired = true c.ProviderAccounts[idx].Enabled = boolPointer(false) c.ProviderAccounts[idx].Default = false + for _, tmpl := range accountRouteTemplates(account.ProviderID) { + c.ProviderAccounts[idx].DisabledRoutes = append(c.ProviderAccounts[idx].DisabledRoutes, tmpl.RouteID) + } + c.ProviderAccounts[idx].DisabledRoutes = normalizeProviderAccountRoutes(c.ProviderAccounts[idx].DisabledRoutes) c.ensureFamilyDefault(account.ProviderID) return nil } diff --git a/internal/config/provider_account_test.go b/internal/config/provider_account_test.go index f8d0d2c095..c186dcfc00 100644 --- a/internal/config/provider_account_test.go +++ b/internal/config/provider_account_test.go @@ -485,3 +485,24 @@ func TestSetDefaultAccountUpdatesFamilyDefaultModel(t *testing.T) { t.Fatalf("new session model = %q, want team account", got) } } + +func TestRetireProviderAccountDisablesAllRoutes(t *testing.T) { + cfg := Default() + if _, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Main", "OPENCODE_MAIN_KEY"); err != nil { + t.Fatal(err) + } + account, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Team", "OPENCODE_TEAM_KEY") + if err != nil { + t.Fatal(err) + } + if err := cfg.RetireProviderAccount(account.ProviderID, account.ID); err != nil { + t.Fatal(err) + } + _, retired, _ := cfg.lookupProviderAccount(account.ProviderID, account.ID) + if !retired.Retired || retired.IsEnabled() || len(retired.DisabledRoutes) == 0 { + t.Fatalf("retired account = %+v", retired) + } + if _, ok := cfg.DefaultAccount(account.ProviderID); !ok { + t.Fatal("main account should remain family default after team retirement") + } +} From d996e67a0fa522e28acee962fcb190c58e0f48d2 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:49:47 +0800 Subject: [PATCH 12/58] fix(cli): replay explicit account route state Problem: route operation metadata was recorded but replay relied only on account snapshots.\nRoot cause: route enabled state was not checked or reapplied independently.\nFix: record route before/after state and apply explicit metadata only for compact route operations, while full family snapshots preserve route changes.\nVerification: go test -mod=mod ./internal/cli -run 'TestProviderAccountSetupOperationReplay' -count=1 --- internal/cli/setup_manager.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index 37c253b80e..b773ad68dc 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -243,9 +243,13 @@ func (s *providerSetupSession) setProviderAccountRouteEnabled(providerID, accoun } after, _ := s.snapshotAccount(providerID, accountID) if !reflect.DeepEqual(before, after) || defaultBefore != s.cfg.DefaultModel { + beforeEnabled := accountRouteEnabled(*before, routeID) + afterEnabled := accountRouteEnabled(*after, routeID) s.recordProviderAccountFamilyMutation(providerID, accountID, familyBefore, s.accountFamilySnapshots(providerID), routeID) if len(s.operations) > 0 { op := &s.operations[len(s.operations)-1] + op.beforeRoute = &beforeEnabled + op.afterRoute = &afterEnabled op.accountDefaultModelChanged = defaultBefore != s.cfg.DefaultModel op.beforeString = defaultBefore op.afterString = s.cfg.DefaultModel @@ -1134,9 +1138,39 @@ func replayProviderAccountOperation(cfg *config.Config, operation providerSetupO return fmt.Errorf("replay account default_model: %w", err) } } + if operation.routeID != "" && operation.afterRoute != nil && len(operation.beforeAccounts) == 0 && len(operation.afterAccounts) == 0 { + _, account, found := cfgAccountByIdentity(cfg, operation.providerID, operation.accountID) + if !found { + return &providerSetupConflictError{field: field} + } + if operation.beforeRoute != nil && accountRouteEnabled(account, operation.routeID) != *operation.beforeRoute { + return &providerSetupConflictError{field: field + ".route"} + } + if err := cfg.SetProviderAccountRouteEnabled(operation.providerID, operation.accountID, operation.routeID, *operation.afterRoute); err != nil { + return fmt.Errorf("replay %s route: %w", field, err) + } + } return nil } +func cfgAccountByIdentity(cfg *config.Config, providerID, accountID string) (int, config.ProviderAccount, bool) { + for i, account := range cfg.ProviderAccounts { + if account.ProviderID == providerID && account.ID == accountID { + return i, account, true + } + } + return -1, config.ProviderAccount{}, false +} + +func accountRouteEnabled(account config.ProviderAccount, routeID string) bool { + for _, disabled := range account.DisabledRoutes { + if strings.TrimSpace(disabled) == strings.TrimSpace(routeID) { + return false + } + } + return true +} + func providerSetupAccessContains(names []string, want string) bool { want = strings.TrimSpace(want) for _, name := range names { From 5f1290aa1c7c5e83876f4b42ea4998f4881d79b8 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:50:13 +0800 Subject: [PATCH 13/58] fix(desktop): preserve account routes and generic account UI Problem: account-owned providers were deleted or recreated, while desktop account views supported only hard-coded provider groups.\n\nRoot cause: provider removal discarded generated routes and account setup persisted keys/config in one opaque operation; frontend used DeepSeek/OpenCode-specific IDs.\n\nFix: retain account provider entries while disabling routes, expose restore/retired state, make key cleanup reference-aware, track config commit state before credential writes, filter disabled models, and select account presets from curated account groups. Add Wails normalization, route APIs, locale strings, responsive account styles, and mock bindings.\n\nVerification: pnpm typecheck; go test -mod=mod ./desktop (focused account test). --- .../components/ProviderAccountSettings.tsx | 48 +++++--- .../frontend/src/components/SettingsPanel.tsx | 8 +- desktop/frontend/src/lib/bridge.ts | 2 +- .../frontend/src/lib/mockProviderAccounts.ts | 15 ++- .../frontend/src/lib/providerAccountTypes.ts | 2 +- desktop/frontend/src/locales/en.ts | 4 + desktop/frontend/src/locales/zh-TW.ts | 4 + desktop/frontend/src/locales/zh.ts | 4 + desktop/frontend/src/styles.css | 48 ++++++++ desktop/model_catalog.go | 10 +- desktop/provider_access_removal.go | 67 ++++++++++- desktop/provider_accounts.go | 105 ++++++++++++------ desktop/settings_app.go | 45 +++++++- 13 files changed, 297 insertions(+), 65 deletions(-) diff --git a/desktop/frontend/src/components/ProviderAccountSettings.tsx b/desktop/frontend/src/components/ProviderAccountSettings.tsx index 02f0ed198e..b1b6ca328d 100644 --- a/desktop/frontend/src/components/ProviderAccountSettings.tsx +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -15,30 +15,38 @@ export function normalizeProviderAccountView(p: AccountView): AccountView { default: Boolean(p.default), keySet: Boolean(p.keySet), providerNames: asArray(p.providerNames), + disabledRoutes: asArray(p.disabledRoutes), }; } -export function accountsForProviderGroup(group: { id: string; providers: { providerId?: string }[] }, accounts: AccountView[]): AccountView[] { +type ProviderPresetRef = { id: string; accountGroupId?: string; recommended?: boolean; displayOrder?: number }; + +export function accountsForProviderGroup(group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }, accounts: AccountView[]): AccountView[] { const ids = new Set(group.providers.map((p) => p.providerId).filter(Boolean) as string[]); - if (group.id === "builtin:deepseek") ids.add("deepseek"); - if (group.id === "custom:opencode-go") ids.add("opencode-go"); - return accounts.filter((account) => ids.has(account.providerId) && !account.retired); + if (group.providerGroup) ids.add(group.providerGroup); + return accounts.filter((account) => ids.has(account.providerId)); } -export function addAccountPresetID(group: { id: string }): string { - if (group.id === "builtin:deepseek" || group.id.endsWith(":deepseek")) return "deepseek-anthropic"; - if (group.id === "custom:opencode-go") return "opencode-go-recommended"; - return ""; +export function addAccountPresetID(group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }, presets: ProviderPresetRef[]): string { + const groupID = group.providerGroup || group.providers.map((p) => p.providerId).find(Boolean) || ""; + if (!groupID) return ""; + const candidates = presets.filter((preset) => String(preset.accountGroupId ?? "").trim() === groupID); + candidates.sort((a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)) + || Number(a.displayOrder ?? 0) - Number(b.displayOrder ?? 0) + || a.id.localeCompare(b.id)); + return candidates[0]?.id ?? ""; } export function ProviderAccountManager({ group, accounts, + providerPresets, busy, apply, }: { - group: { id: string }; + group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }; accounts: AccountView[]; + providerPresets: ProviderPresetRef[]; busy: boolean; apply: (fn: () => Promise) => Promise; }) { @@ -48,7 +56,7 @@ export function ProviderAccountManager({ const [key, setKey] = useState(""); const [renaming, setRenaming] = useState(null); const [renameLabel, setRenameLabel] = useState(""); - const presetID = addAccountPresetID(group); + const presetID = addAccountPresetID(group, providerPresets); if (accounts.length === 0 && !presetID) return null; return ( @@ -77,19 +85,26 @@ export function ProviderAccountManager({ ) : ( <> {account.label} + {account.retired ? {t("settings.accountRetired")} : null} {account.default ? {t("settings.accountDefault")} : null} {account.enabled ? t("settings.accountEnabled") : t("settings.accountDisabled")} {account.keySet ? t("settings.keySet") : t("settings.noKey")} - + ) : null} + - - @@ -129,5 +147,5 @@ export function ProviderAccountManager({ )}
- ); + ); } diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 451a33a7e3..a40c33e845 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -5269,6 +5269,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { key={group.id} group={group} accounts={accountsForProviderGroup(group, asArray(s.providerAccounts))} + providerPresets={s.providerPresets} apply={apply} busy={busy} fetching={fetchingProviders.has(group.id)} @@ -5345,6 +5346,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { export type ProviderAccessGroup = { id: string; + providerGroup: string; label: string; description: string; builtIn: boolean; @@ -5880,6 +5882,7 @@ export function AddProviderPanel({ export function ProviderAccessCard({ group, accounts = [], + providerPresets, apply, busy, fetching, @@ -5905,6 +5908,7 @@ export function ProviderAccessCard({ }: { group: ProviderAccessGroup; accounts?: ProviderAccountView[]; + providerPresets?: ProviderPresetView[]; apply: (fn: () => Promise) => Promise; busy: boolean; fetching: boolean; @@ -5971,7 +5975,7 @@ export function ProviderAccessCard({ ); return (
- +
@@ -6430,10 +6434,12 @@ export function providerAccessGroups(providers: ProviderView[], t: ReturnType; SetProviderAccountEnabled(providerID: string, accountID: string, enabled: boolean): Promise; RetireProviderAccount(providerID: string, accountID: string): Promise; + RestoreProviderAccount(providerID: string, accountID: string): Promise; + SetProviderAccountRouteEnabled(providerID: string, accountID: string, routeID: string, enabled: boolean): Promise; RenameProviderAccount(providerID: string, accountID: string, label: string): Promise; SetProviderAccountKey(providerID: string, accountID: string, value: string): Promise; ClearProviderAccountKey(providerID: string, accountID: string): Promise; @@ -32,7 +34,18 @@ export function mockProviderAccountBindings(settings: SettingsView) { settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, enabled } : a); }, async RetireProviderAccount(providerID: string, accountID: string) { - settings.providerAccounts = asArray(settings.providerAccounts).filter((a) => !(a.providerId === providerID && a.accountId === accountID)); + settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, retired: true, enabled: false, default: false } : a); + }, + async RestoreProviderAccount(providerID: string, accountID: string) { + settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, retired: false, enabled: true, disabledRoutes: [] } : a); + }, + async SetProviderAccountRouteEnabled(providerID: string, accountID: string, routeID: string, enabled: boolean) { + settings.providerAccounts = mapAccounts(settings, (a) => { + if (a.providerId !== providerID || a.accountId !== accountID) return a; + const disabled = new Set(asArray(a.disabledRoutes)); + if (enabled) disabled.delete(routeID); else disabled.add(routeID); + return { ...a, disabledRoutes: Array.from(disabled).sort() }; + }); }, async RenameProviderAccount(providerID: string, accountID: string, label: string) { settings.providerAccounts = mapAccounts(settings, (a) => a.providerId === providerID && a.accountId === accountID ? { ...a, label } : a); diff --git a/desktop/frontend/src/lib/providerAccountTypes.ts b/desktop/frontend/src/lib/providerAccountTypes.ts index 61ef8df0a4..3b7ec1eff5 100644 --- a/desktop/frontend/src/lib/providerAccountTypes.ts +++ b/desktop/frontend/src/lib/providerAccountTypes.ts @@ -1,5 +1,5 @@ export interface ProviderAccountView { providerId: string; accountId: string; presetId?: string; label: string; apiKeyEnv: string; enabled: boolean; default: boolean; retired?: boolean; keySet: boolean; - keySource?: string; keySourcePath?: string; providerNames: string[]; + keySource?: string; keySourcePath?: string; providerNames: string[]; disabledRoutes?: string[]; } diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 74884bf37f..429d18a185 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2025,6 +2025,10 @@ export const en = { "settings.accountEnable": "Enable", "settings.accountDisable": "Disable", "settings.accountRetire": "Retire", + "settings.accountRetired": "retired", + "settings.accountRestore": "Restore", + "settings.accountRoutesDisabled": "routes disabled", + "settings.accountRetireConfirm": "Retire this account? Existing sessions will keep their provider entry.", "settings.addProviderAccess": "Add access", "settings.removeProviderAccess": "Remove access", "settings.confirmRemoveProviderAccess": "Confirm remove access", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 7258416ed1..5c9f86da54 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1563,6 +1563,10 @@ export const zhTW: Record = { "settings.accountEnable": "啟用", "settings.accountDisable": "停用", "settings.accountRetire": "退休", + "settings.accountRetired": "已退休", + "settings.accountRestore": "恢復", + "settings.accountRoutesDisabled": "個路由已停用", + "settings.accountRetireConfirm": "確定退休此帳號嗎?已有工作階段仍會保留其供應商項目。", "settings.addProviderAccess": "新增接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "確認移除接入", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index e1b497489b..512b69f378 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2027,6 +2027,10 @@ export const zh: Record = { "settings.accountEnable": "启用", "settings.accountDisable": "停用", "settings.accountRetire": "退休", + "settings.accountRetired": "已退休", + "settings.accountRestore": "恢复", + "settings.accountRoutesDisabled": "个路由已停用", + "settings.accountRetireConfirm": "确定退休此账号吗?已有会话仍会保留其供应商条目。", "settings.addProviderAccess": "添加接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "确认移除接入", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 09c3e5859f..7fa97ef52c 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -17894,6 +17894,54 @@ body > .mermaid-diagram--fullscreen { .provider-access-card__actions .btn { white-space: nowrap; } +.provider-accounts { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); +} +.provider-account-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + padding: 5px 0; +} +.provider-account-row > strong { + min-width: 8rem; + max-width: 14rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.provider-account-row > span:not(.badge) { + color: var(--fg-dim); + font-size: var(--text-2xs); +} +.provider-account-row .input { + min-width: 10rem; + flex: 1 1 12rem; +} +.provider-account-add { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; + padding-top: 4px; +} +.provider-account-add .input { + min-width: 10rem; + flex: 1 1 12rem; +} +@media (max-width: 620px) { + .provider-account-row > strong { flex-basis: 100%; max-width: 100%; } + .provider-account-row .btn, .provider-account-add .btn { min-height: 28px; } +} .provider-model-chips { display: flex; flex-wrap: wrap; diff --git a/desktop/model_catalog.go b/desktop/model_catalog.go index ac3c6a5df8..3185d992a0 100644 --- a/desktop/model_catalog.go +++ b/desktop/model_catalog.go @@ -82,6 +82,12 @@ func (a *App) remoteProxyModelCatalog(curModel string) []ModelInfo { if !strings.EqualFold(entryKind, kind) || !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) || !entry.Configured() { continue } + if strings.TrimSpace(entry.AccountID) != "" { + account, ok := config.ProviderAccountForEntry(cfg, *entry) + if ok && (account.Retired || !account.IsEnabled()) && !strings.HasPrefix(curModel, entry.Name+"/") { + continue + } + } for _, model := range entry.ChatModelList() { ref := entry.Name + "/" + model out = append(out, ModelInfo{Ref: ref, Provider: entry.Name, Model: model, Current: ref == canonical}) @@ -110,10 +116,10 @@ func (a *App) desktopModelCatalog(curModel, workspaceRoot string, ctrl control.S if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() { continue } - if !cfg.AccountEnabled(p.AccountProviderID, p.AccountID) && strings.TrimSpace(p.AccountID) != "" && !strings.HasPrefix(curModel, p.Name+"/") { + account, accountKnown := config.ProviderAccountForEntry(cfg, *p) + if accountKnown && (account.Retired || !account.IsEnabled()) && !strings.HasPrefix(curModel, p.Name+"/") { continue } - account, _ := config.ProviderAccountForEntry(cfg, *p) for _, m := range p.ChatModelList() { ref := p.Name + "/" + m out = append(out, ModelInfo{ diff --git a/desktop/provider_access_removal.go b/desktop/provider_access_removal.go index 45270bd9d3..1a6611d14d 100644 --- a/desktop/provider_access_removal.go +++ b/desktop/provider_access_removal.go @@ -224,13 +224,69 @@ func providerAccessFallbackRef(c *config.Config, names []string) string { continue } p, ok := c.Provider(candidate) - if ok && p.Configured() && len(p.ModelList()) > 0 { + if ok && p.Configured() && len(p.ModelList()) > 0 && (strings.TrimSpace(p.AccountID) == "" || c.AccountEnabled(p.AccountProviderID, p.AccountID)) { return p.Name + "/" + p.DefaultModel() } } return "" } +// disableAccountRoutesForProviders preserves account-owned provider entries so +// historical session references continue to resolve while removing them from +// desktop access and future model catalogs. Accounts with no remaining enabled +// routes are retired after config references have been retargeted. +func disableAccountRoutesForProviders(c *config.Config, names []string) error { + if c == nil { + return nil + } + retire := map[string][2]string{} + for _, name := range names { + p, ok := c.Provider(name) + if !ok || strings.TrimSpace(p.AccountID) == "" || strings.TrimSpace(p.AccountProviderID) == "" { + continue + } + if err := c.SetProviderAccountRouteEnabled(p.AccountProviderID, p.AccountID, p.AccountRouteID, false); err != nil { + return err + } + key := p.AccountProviderID + "\x00" + p.AccountID + retire[key] = [2]string{p.AccountProviderID, p.AccountID} + } + for _, pair := range retire { + entries, _ := c.ResolveAccountProvider(pair[0], pair[1]) + var account config.ProviderAccount + foundAccount := false + for _, candidate := range c.ProviderAccounts { + if candidate.ProviderID == pair[0] && candidate.ID == pair[1] { + account, foundAccount = candidate, true + break + } + } + if !foundAccount { + continue + } + enabled := false + for _, entry := range entries { + disabled := false + for _, route := range account.DisabledRoutes { + if strings.TrimSpace(route) == strings.TrimSpace(entry.AccountRouteID) { + disabled = true + break + } + } + if account.IsEnabled() && !disabled { + enabled = true + break + } + } + if !enabled { + if err := c.RetireProviderAccount(pair[0], pair[1]); err != nil { + return err + } + } + } + return nil +} + func providerRefMatchesAny(c *config.Config, ref string, names []string) bool { for _, name := range names { if desktopModelRefsProvider(c, ref, name) { @@ -453,6 +509,9 @@ func (a *App) commitOfficialProviderRemoval(plan providerRemovalPlan, names []st } fallbackRef := providerAccessFallbackRef(fresh, plan.targets) retargetProviderReferences(fresh, plan.targets, fallbackRef) + if err := disableAccountRoutesForProviders(fresh, plan.targets); err != nil { + return "", err + } removeProviderAccess(fresh, plan.targets...) return fallbackRef, fresh.SaveTo(path) } @@ -492,10 +551,16 @@ func (a *App) commitCustomProviderRemovals(plan providerRemovalPlan) (string, er } retargetProviderReferences(fresh, plan.targets, persistedFallback) for _, name := range plan.targets { + if p, ok := fresh.Provider(name); ok && strings.TrimSpace(p.AccountID) != "" && strings.TrimSpace(p.AccountProviderID) != "" { + continue + } if err := fresh.RemoveProvider(name); err != nil { return "", err } } + if err := disableAccountRoutesForProviders(fresh, plan.targets); err != nil { + return "", err + } removeProviderAccess(fresh, plan.targets...) return fallbackRef, fresh.SaveTo(path) } diff --git a/desktop/provider_accounts.go b/desktop/provider_accounts.go index a795126a8b..aa274fe80e 100644 --- a/desktop/provider_accounts.go +++ b/desktop/provider_accounts.go @@ -8,18 +8,19 @@ import ( ) type ProviderAccountView struct { - ProviderID string `json:"providerId"` - AccountID string `json:"accountId"` - PresetID string `json:"presetId,omitempty"` - Label string `json:"label"` - APIKeyEnv string `json:"apiKeyEnv"` - Enabled bool `json:"enabled"` - Default bool `json:"default"` - Retired bool `json:"retired,omitempty"` - KeySet bool `json:"keySet"` - KeySource string `json:"keySource,omitempty"` - KeySourcePath string `json:"keySourcePath,omitempty"` - ProviderNames []string `json:"providerNames"` + ProviderID string `json:"providerId"` + AccountID string `json:"accountId"` + PresetID string `json:"presetId,omitempty"` + Label string `json:"label"` + APIKeyEnv string `json:"apiKeyEnv"` + Enabled bool `json:"enabled"` + Default bool `json:"default"` + Retired bool `json:"retired,omitempty"` + KeySet bool `json:"keySet"` + KeySource string `json:"keySource,omitempty"` + KeySourcePath string `json:"keySourcePath,omitempty"` + ProviderNames []string `json:"providerNames"` + DisabledRoutes []string `json:"disabledRoutes,omitempty"` } func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView { @@ -34,6 +35,7 @@ func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, res models := make([]string, 0) modelSeen := map[string]bool{} requiresKey := false + routes := make([]string, 0, len(preset.Entries)) for _, entry := range preset.Entries { if keyEnv == "" { keyEnv = strings.TrimSpace(entry.APIKeyEnv) @@ -44,6 +46,7 @@ func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, res name := strings.TrimSpace(entry.Name) if name != "" { names = append(names, name) + routes = append(routes, name) } for _, model := range chatProviderModels(entry.ChatModelList()) { if modelSeen[model] { @@ -68,6 +71,7 @@ func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, res MissingProviderNames: nonNil(missingNames), KeySet: key.Set, RequiresKey: requiresKey, Configured: !requiresKey || key.Set, KeySource: key.Source.Label, KeySourcePath: key.Source.Path, AccountGroupID: preset.AccountGroupID, Accounts: accountViewsForGroup(cfg, preset.AccountGroupID, root, resolver), CanAddAccount: true, + AvailableRoutes: nonNil(routes), }) } return out @@ -93,18 +97,16 @@ func providerAccountViewsForRoot(cfg *config.Config, root string, resolver *conf resolver = config.NewCredentialResolverForRoot(root) } for _, account := range cfg.ProviderAccounts { - if account.Retired { - continue - } view := ProviderAccountView{ - ProviderID: account.ProviderID, - AccountID: account.ID, - PresetID: account.PresetID, - Label: account.Label, - APIKeyEnv: account.APIKeyEnv, - Enabled: account.IsEnabled(), - Default: account.Default, - ProviderNames: []string{}, + ProviderID: account.ProviderID, + AccountID: account.ID, + PresetID: account.PresetID, + Label: account.Label, + APIKeyEnv: account.APIKeyEnv, + Enabled: account.IsEnabled(), + Default: account.Default, + ProviderNames: []string{}, + DisabledRoutes: nonNil(account.DisabledRoutes), } if env := strings.TrimSpace(account.APIKeyEnv); env != "" { key := resolver.ResolveGlobalFirst(env) @@ -154,30 +156,30 @@ func (a *App) AddProviderPresetAccount(presetID, label, key string) (string, err } keyWarning := "" var created config.ProviderAccount - rebuildWarning, err := a.applyConfigChangeWithWarning("provider account", func(c *config.Config) error { + change, err := a.applyConfigChangeResult("provider account", func(c *config.Config) error { env := "" account, err := c.AddProviderAccount(groupID, preset.ID, label, env) if err != nil { return err } created = account - if strings.TrimSpace(key) != "" && strings.TrimSpace(account.APIKeyEnv) != "" { - keyWarning, err = a.saveProviderCredential(account.APIKeyEnv, key) - if err != nil { - return err - } - } entries, _ := c.ResolveAccountProvider(account.ProviderID, account.ID) addProviderAccess(c, providerEntryNames(entries)...) return nil }) if err != nil { - if strings.TrimSpace(created.APIKeyEnv) != "" && strings.TrimSpace(key) != "" { + if !change.Committed && strings.TrimSpace(created.APIKeyEnv) != "" && strings.TrimSpace(key) != "" { _ = config.RemoveCredential(created.APIKeyEnv) } return "", err } - return appendSettingsWarning(keyWarning, rebuildWarning), nil + if strings.TrimSpace(key) != "" && strings.TrimSpace(created.APIKeyEnv) != "" { + keyWarning, err = a.saveProviderCredential(created.APIKeyEnv, key) + if err != nil { + return "", fmt.Errorf("configuration saved but credential write failed; retry from account settings: %w", err) + } + } + return appendSettingsWarning(keyWarning, change.Warning), nil } func (a *App) SetProviderAccountDefault(providerID, accountID string) error { @@ -199,10 +201,10 @@ func (a *App) RenameProviderAccount(providerID, accountID, label string) error { } func (a *App) RetireProviderAccount(providerID, accountID string) error { - if refs := a.providerAccountLiveRefs(providerID, accountID); len(refs) > 0 { - return fmt.Errorf("cannot retire account %s/%s while it is referenced by %s", providerID, accountID, strings.Join(refs, ", ")) - } - return a.applyConfigChange(func(c *config.Config) error { + _, err := a.applyConfigChangeWithRuntimeMutation("retire provider account", func(c *config.Config) error { + if refs := a.providerAccountLiveRefsFromConfig(c, providerID, accountID); len(refs) > 0 { + return fmt.Errorf("cannot retire account %s/%s while it is referenced by %s", providerID, accountID, strings.Join(refs, ", ")) + } if err := c.RetireProviderAccount(providerID, accountID); err != nil { return err } @@ -214,6 +216,24 @@ func (a *App) RetireProviderAccount(providerID, accountID string) error { } return nil }) + return err +} + +// RestoreProviderAccount re-enables a retired account and all of its routes. +func (a *App) RestoreProviderAccount(providerID, accountID string) error { + _, err := a.applyConfigChangeWithRuntimeMutation("restore provider account", func(c *config.Config) error { + return c.RestoreProviderAccount(providerID, accountID) + }) + return err +} + +// SetProviderAccountRouteEnabled persists a single route toggle while keeping +// account-owned provider entries available for old session references. +func (a *App) SetProviderAccountRouteEnabled(providerID, accountID, routeID string, enabled bool) error { + _, err := a.applyConfigChangeWithRuntimeMutation("provider account route", func(c *config.Config) error { + return c.SetProviderAccountRouteEnabled(providerID, accountID, routeID, enabled) + }) + return err } func (a *App) SetProviderAccountKey(providerID, accountID, value string) (string, error) { @@ -248,6 +268,10 @@ func (a *App) providerAccountLiveRefs(providerID, accountID string) []string { if err != nil { return nil } + return a.providerAccountLiveRefsFromConfig(cfg, providerID, accountID) +} + +func (a *App) providerAccountLiveRefsFromConfig(cfg *config.Config, providerID, accountID string) []string { refs := cfg.ProviderAccountConfigRefs(providerID, accountID) entries, ok := cfg.ResolveAccountProvider(providerID, accountID) if !ok { @@ -292,7 +316,14 @@ func accountKeyEnvShared(c *config.Config, account config.ProviderAccount) bool if other.ProviderID == account.ProviderID && other.ID == account.ID { continue } - if !other.Retired && strings.TrimSpace(other.APIKeyEnv) == env { + if strings.TrimSpace(other.APIKeyEnv) == env { + return true + } + } + // Provider entries can outlive an account (retired routes are retained for + // old sessions), and custom providers may intentionally share the same key. + for _, entry := range c.Providers { + if strings.TrimSpace(entry.APIKeyEnv) == env { return true } } diff --git a/desktop/settings_app.go b/desktop/settings_app.go index 243bd004ef..765fd1fd72 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -121,6 +121,7 @@ type ProviderPresetView struct { AccountGroupID string `json:"accountGroupId,omitempty"` Accounts []ProviderAccountView `json:"accounts"` CanAddAccount bool `json:"canAddAccount,omitempty"` + AvailableRoutes []string `json:"availableRoutes"` } const ( @@ -1272,8 +1273,32 @@ func (a *App) applySkillConfigChangeForFields(fields []string, setting string, m } func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.Config) error) (string, error) { + result, err := a.applyConfigChangeResult(setting, mutate) + return result.Warning, err +} + +func (a *App) applyConfigChangeWithRuntimeMutation(setting string, mutate func(*config.Config) error) (configChangeResult, error) { + defer a.lockRuntimeMutation(setting)() + result, err := a.applyConfigChangeResultInternal(setting, mutate, true) + return result, err +} + +// configChangeResult records whether the user config reached disk before a +// runtime rebuild or subsequent credential operation failed. Callers that +// create credentials must only roll them back while Committed is false. +type configChangeResult struct { + Warning string + Committed bool +} + +func (a *App) applyConfigChangeResult(setting string, mutate func(*config.Config) error) (configChangeResult, error) { + return a.applyConfigChangeResultInternal(setting, mutate, false) +} + +func (a *App) applyConfigChangeResultInternal(setting string, mutate func(*config.Config) error, runtimeHeld bool) (configChangeResult, error) { + var result configChangeResult if err := a.ensureActiveTabRebuildAllowed(setting); err != nil { - return "", err + return result, err } if err := func() error { // Serialize the load-modify-save against other in-process config editors @@ -1291,17 +1316,25 @@ func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.C } return cfg.SaveTo(path) }(); err != nil { - return "", err + return result, err } - if err := a.rebuildSetting(setting); err != nil { + result.Committed = true + var rebuildErr error + if runtimeHeld { + rebuildErr = a.rebuildSettingLocked(setting) + } else { + rebuildErr = a.rebuildSetting(setting) + } + if err := rebuildErr; err != nil { if warning, ok := a.deferredRebuildWarning(setting, err); ok { a.refreshActiveTabMetaExtras() - return warning, nil + result.Warning = warning + return result, nil } - return "", err + return result, err } a.refreshActiveTabMetaExtras() - return "", nil + return result, nil } // refreshActiveTabMetaExtras invalidates the cached model capability snapshot From c436a411e464d788cda900ac4f51575a8cb13cde Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:06 +0800 Subject: [PATCH 14/58] fix(cli): preserve account order during replay Problem: replaying an account mutation removed and appended the whole family, changing account order.\nRoot cause: replacement snapshots were applied by deleting all before entries before appending after entries.\nFix: replace family entries in place and append only newly introduced accounts.\nVerification: go test -mod=mod ./internal/cli -run 'ProviderSetup|Account' -count=1 --- internal/cli/setup_manager.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/cli/setup_manager.go b/internal/cli/setup_manager.go index b773ad68dc..71bb998b16 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -1119,13 +1119,30 @@ func replayProviderAccountOperation(cfg *config.Config, operation providerSetupO for _, account := range before { beforeKeys[providerSetupAccountKey(account.ProviderID, account.ID)] = true } - for i := len(cfg.ProviderAccounts) - 1; i >= 0; i-- { - account := cfg.ProviderAccounts[i] - if account.ProviderID == operation.providerID && beforeKeys[providerSetupAccountKey(account.ProviderID, account.ID)] { - cfg.ProviderAccounts = append(cfg.ProviderAccounts[:i], cfg.ProviderAccounts[i+1:]...) + afterByKey := make(map[string]config.ProviderAccount, len(after)) + for _, account := range after { + afterByKey[providerSetupAccountKey(account.ProviderID, account.ID)] = cloneProviderSetupAccount(account) + } + seenAfter := make(map[string]bool, len(after)) + out := cfg.ProviderAccounts[:0] + for _, account := range cfg.ProviderAccounts { + key := providerSetupAccountKey(account.ProviderID, account.ID) + if account.ProviderID == operation.providerID && beforeKeys[key] { + if replacement, exists := afterByKey[key]; exists { + out = append(out, replacement) + seenAfter[key] = true + } + continue + } + out = append(out, account) + } + for _, account := range after { + key := providerSetupAccountKey(account.ProviderID, account.ID) + if !seenAfter[key] { + out = append(out, cloneProviderSetupAccount(account)) } } - cfg.ProviderAccounts = append(cfg.ProviderAccounts, cloneProviderSetupAccounts(after)...) + cfg.ProviderAccounts = out } if _, _, reconcileErr := config.ReconcileProviderAccounts(cfg); reconcileErr != nil { return fmt.Errorf("replay %s: %w", field, reconcileErr) From 02148997df88393b8c5bf295c4e54a0b47f9b896 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:26 +0800 Subject: [PATCH 15/58] fix(config): recognize legacy family defaults when switching accounts Fix default-account synchronization to infer curated family identity from legacy provider names when account metadata has not yet been stamped. Verification: go test ./internal/config -run SetDefaultAccount -count=1 --- internal/config/provider_account_mutate.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index 10099eb2ec..a34f7e1cef 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -367,6 +367,9 @@ func (c *Config) syncFamilyDefaultModel(providerID, accountID string) { return } family, _, ok := ProviderAccountIdentity(*entry) + if !ok { + family, _, _, ok = curatedProviderIdentity(*entry) + } if !ok || family != providerID { return } From 037c835f27c69dc49747a5c2b2cec1c4b11e4bef Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:35 +0800 Subject: [PATCH 16/58] test(desktop): assert retained account routes on removal Problem: grouped account route removal tests still expected provider entries to be deleted.\n\nFix: assert retained entries carry account metadata and the account is retired after all routes are disabled, preserving historical session references.\n\nVerification: focused desktop provider access test. --- desktop/provider_access_group_removal_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/desktop/provider_access_group_removal_test.go b/desktop/provider_access_group_removal_test.go index 83444a7691..b8d769208d 100644 --- a/desktop/provider_access_group_removal_test.go +++ b/desktop/provider_access_group_removal_test.go @@ -80,10 +80,17 @@ func TestRemoveProviderAccessesRemovesGroupedOpenCodeGoRoutesAtomically(t *testi t.Fatalf("provider_access = %+v, want only mimo-pro", got.Desktop.ProviderAccess) } for _, name := range []string{"opencode-go", "opencode-go-anthropic", "opencode-go-responses", "opencode-go-deepseek-responses"} { - if _, ok := got.Provider(name); ok { - t.Fatalf("provider %q still exists after grouped removal", name) + entry, ok := got.Provider(name) + if !ok { + t.Fatalf("provider %q missing after grouped removal; retained entries are required for old sessions", name) + } + if len(entry.AccountID) == 0 || entry.AccountProviderID == "" { + t.Fatalf("provider %q lost account metadata: %+v", name, entry) } } + if len(got.ProviderAccounts) == 0 || !got.ProviderAccounts[0].Retired { + t.Fatalf("account was not retired after all routes were removed: %+v", got.ProviderAccounts) + } wantFallback := "mimo-pro" if got.DefaultModel != wantFallback || got.Agent.PlannerModel != wantFallback || got.Agent.RecoveryModel != wantFallback || got.Agent.SubagentModel != wantFallback || got.Agent.SubagentModels["review"] != wantFallback { t.Fatalf("provider refs were not retargeted: default=%q planner=%q recovery=%q subagent=%q skills=%+v", got.DefaultModel, got.Agent.PlannerModel, got.Agent.RecoveryModel, got.Agent.SubagentModel, got.Agent.SubagentModels) From 1d82f91605fd1086847a64002759f9c021e92a3a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:51:59 +0800 Subject: [PATCH 17/58] test(cli): make curated account menu ordering explicit Problem: menu ordering depended on map iteration over curated account groups.\nRoot cause: group IDs were sorted before selecting presets, while the desired setup order is preset ID deterministic.\nFix: sort selected presets by preset ID and assert ordering in table-driven coverage.\nVerification: go test -mod=mod ./internal/cli -run TestCuratedAccountSetupPresetsCoverEveryGroupDeterministically -count=1 --- internal/cli/setup_accounts.go | 17 +++++++++-------- internal/cli/setup_manager_test.go | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go index 268d78c4df..d3973854f0 100644 --- a/internal/cli/setup_accounts.go +++ b/internal/cli/setup_accounts.go @@ -233,15 +233,16 @@ func curatedAccountSetupPresets() []config.ProviderPreset { byGroup[group] = preset } } - groups := make([]string, 0, len(byGroup)) - for group := range byGroup { - groups = append(groups, group) - } - sort.Strings(groups) - out := make([]config.ProviderPreset, 0, len(groups)) - for _, group := range groups { - out = append(out, byGroup[group]) + out := make([]config.ProviderPreset, 0, len(byGroup)) + for _, preset := range byGroup { + out = append(out, preset) } + sort.SliceStable(out, func(i, j int) bool { + if out[i].ID != out[j].ID { + return out[i].ID < out[j].ID + } + return out[i].AccountGroupID < out[j].AccountGroupID + }) return out } diff --git a/internal/cli/setup_manager_test.go b/internal/cli/setup_manager_test.go index eb29ce5074..c467e91ce0 100644 --- a/internal/cli/setup_manager_test.go +++ b/internal/cli/setup_manager_test.go @@ -853,6 +853,11 @@ func TestCuratedAccountSetupPresetsCoverEveryGroupDeterministically(t *testing.T t.Fatalf("group %q selected %q, want %q", preset.AccountGroupID, preset.ID, want[preset.AccountGroupID].ID) } } + for i := 1; i < len(got); i++ { + if got[i-1].ID > got[i].ID { + t.Fatalf("setup presets not sorted by ID: %q before %q", got[i-1].ID, got[i].ID) + } + } } func entriesForAccount(t *testing.T, cfg *config.Config, account config.ProviderAccount) []config.ProviderEntry { From 7fc43531d925487d8d5ca8992d41a7bd3e8a9389 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:52:36 +0800 Subject: [PATCH 18/58] fix(frontend): normalize provider route metadata Expose available route metadata in the provider preset view and normalize it at the Wails boundary so account settings never receive null arrays. --- desktop/frontend/src/components/SettingsPanel.tsx | 1 + desktop/frontend/src/lib/types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index a40c33e845..9e9c47d6f0 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1436,6 +1436,7 @@ function normalizeProviderPresetView(p: ProviderPresetView): ProviderPresetView accountGroupId: String(p.accountGroupId ?? ""), accounts: asArray(p.accounts).map(normalizeProviderAccountView), canAddAccount: Boolean(p.canAddAccount), + availableRoutes: asArray(p.availableRoutes), }; } diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index ec6a5ca210..19ef3e72e7 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -1749,7 +1749,7 @@ export interface ProviderPresetView { requiresKey?: boolean; configured?: boolean; keySource?: string; - keySourcePath?: string; accountGroupId?: string; accounts?: ProviderAccountView[]; canAddAccount?: boolean; + keySourcePath?: string; accountGroupId?: string; accounts?: ProviderAccountView[]; canAddAccount?: boolean; availableRoutes?: string[]; } export interface ProviderModelOverrideView { From 7e4119afc6b5e6fd70a44dd072b20bc53a99ca83 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:53:12 +0800 Subject: [PATCH 19/58] fix(config): allow explicit restoration of optional routes Problem: re-enabling an optional route could clear disabled_routes without recreating a provider when the account preset did not include that route. Fix: resolve the curated preset owning an explicitly enabled route, expand and upsert that route, and roll back disabled state if expansion fails. Verification: go test ./internal/config -run TestProviderAccountDisabledRoutesSurviveReconcileAndRestore -count=1 --- internal/config/provider_account_mutate.go | 52 ++++++++++++++++++++++ internal/config/provider_account_test.go | 9 ++++ 2 files changed, 61 insertions(+) diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index a34f7e1cef..056c1c218d 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -123,6 +123,7 @@ func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID s return fmt.Errorf("set account route: unknown route %q for provider %s", routeID, providerID) } disabled := normalizeProviderAccountRoutes(account.DisabledRoutes) + oldDisabled := append([]string(nil), disabled...) filtered := disabled[:0] for _, route := range disabled { if route != routeID { @@ -136,6 +137,16 @@ func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID s if _, _, err := ReconcileProviderAccounts(c); err != nil { return err } + if enabled && indexAccountRouteEntry(c, account, routeID) < 0 { + // Optional routes are normally gated by the selected preset. An explicit + // enable request is an opt-in, so locate the curated preset that owns this + // route and expand that one route even when the account's base preset does + // not include it. + if err := ensureProviderAccountRoute(c, c.ProviderAccounts[idx], routeID); err != nil { + c.ProviderAccounts[idx].DisabledRoutes = oldDisabled + return err + } + } if !enabled { c.removeProviderAccountRouteAccess(account.ProviderID, account.ID, routeID) } else { @@ -144,6 +155,47 @@ func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID s return nil } +func ensureProviderAccountRoute(c *Config, account ProviderAccount, routeID string) error { + for _, tmpl := range accountRouteTemplates(account.ProviderID) { + if tmpl.RouteID != routeID { + continue + } + candidate := account + if tmpl.Optional && strings.TrimSpace(candidate.PresetID) == "" { + for _, preset := range curatedProviderPresets { + if accountGroupIDForPresetID(preset.ID) != account.ProviderID { + continue + } + for _, entry := range preset.Entries { + if strings.TrimSpace(entry.Name) == tmpl.BaseName || strings.TrimSpace(entry.Name) == tmpl.RouteID { + candidate.PresetID = preset.ID + break + } + } + if candidate.PresetID != "" { + break + } + } + } + entries, err := ExpandProviderAccount(c, candidate) + if err != nil { + return err + } + for _, entry := range entries { + if entry.AccountRouteID != routeID { + continue + } + if err := c.UpsertProvider(entry); err != nil { + return err + } + c.markUserProvider(entry.Name) + return nil + } + return fmt.Errorf("set account route: route %q is unavailable in preset %q", routeID, candidate.PresetID) + } + return fmt.Errorf("set account route: unknown route %q for provider %s", routeID, account.ProviderID) +} + // RestoreProviderAccount re-enables an account, clears disabled routes and // recreates any missing generated provider entries. Existing provider names // and user customizations are preserved by reconciliation. diff --git a/internal/config/provider_account_test.go b/internal/config/provider_account_test.go index c186dcfc00..fb06a908eb 100644 --- a/internal/config/provider_account_test.go +++ b/internal/config/provider_account_test.go @@ -121,6 +121,15 @@ func TestProviderAccountDisabledRoutesRoundTripWithoutResurrection(t *testing.T) if err := cfg.SetProviderAccountRouteEnabled(account.ProviderID, account.ID, "opencode-go-responses", false); err != nil { t.Fatal(err) } + if err := cfg.SetProviderAccountRouteEnabled(account.ProviderID, account.ID, "opencode-go-responses", true); err != nil { + t.Fatal(err) + } + if _, ok := cfg.Provider("opencode-go-responses--team"); !ok { + t.Fatal("restored route provider entry missing") + } + if err := cfg.SetProviderAccountRouteEnabled(account.ProviderID, account.ID, "opencode-go-responses", false); err != nil { + t.Fatal(err) + } if err := cfg.SaveTo(path); err != nil { t.Fatal(err) } From 5bc90841eaf64607359350ee9393ced9b8504d69 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:53:32 +0800 Subject: [PATCH 20/58] fix(cli): avoid logging setup prompt defaults Problem: the generic setup prompt rendered arbitrary default values through fmt.Printf, triggering CodeQL sensitive logging analysis.\nRoot cause: a shared prompt helper could be reused with credential defaults.\nFix: keep defaults as input behavior but render only the prompt label; credential prompts already use a dedicated helper.\nVerification: go test -mod=mod ./internal/cli -run 'ProviderSetup|Account' -count=1 --- internal/cli/setup_accounts.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go index d3973854f0..e237212c01 100644 --- a/internal/cli/setup_accounts.go +++ b/internal/cli/setup_accounts.go @@ -291,9 +291,8 @@ func lookupSessionAccount(s *providerSetupSession, providerID, accountID string) } func askLine(label, def string) string { - // Keep the default value in control flow only. Printing arbitrary defaults - // from this generic helper can accidentally expose a secret if a caller is - // added later with a credential as its fallback. + // Defaults are kept out of the rendered prompt so a future caller cannot + // accidentally send a credential value to a logging/output sink. fmt.Printf("%s: ", label) var line string _, _ = fmt.Scanln(&line) From 6f876186e3c4a2717ca34a1e7dc194499b44287a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:53:45 +0800 Subject: [PATCH 21/58] fix(accounts): keep family fallback when provider metadata is sparse Derive account groups from provider IDs or the existing family group key when legacy provider views omit account metadata. This keeps add-account controls available during migration. --- .../components/ProviderAccountSettings.tsx | 13 +++++++++-- desktop/provider_accounts.go | 22 ++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/desktop/frontend/src/components/ProviderAccountSettings.tsx b/desktop/frontend/src/components/ProviderAccountSettings.tsx index b1b6ca328d..99486631e0 100644 --- a/desktop/frontend/src/components/ProviderAccountSettings.tsx +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -23,12 +23,13 @@ type ProviderPresetRef = { id: string; accountGroupId?: string; recommended?: bo export function accountsForProviderGroup(group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }, accounts: AccountView[]): AccountView[] { const ids = new Set(group.providers.map((p) => p.providerId).filter(Boolean) as string[]); - if (group.providerGroup) ids.add(group.providerGroup); + const groupID = accountGroupID(group); + if (groupID) ids.add(groupID); return accounts.filter((account) => ids.has(account.providerId)); } export function addAccountPresetID(group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }, presets: ProviderPresetRef[]): string { - const groupID = group.providerGroup || group.providers.map((p) => p.providerId).find(Boolean) || ""; + const groupID = accountGroupID(group); if (!groupID) return ""; const candidates = presets.filter((preset) => String(preset.accountGroupId ?? "").trim() === groupID); candidates.sort((a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)) @@ -37,6 +38,14 @@ export function addAccountPresetID(group: { id: string; providerGroup?: string; return candidates[0]?.id ?? ""; } +function accountGroupID(group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }): string { + if (group.providerGroup) return group.providerGroup.trim(); + const providerID = group.providers.map((p) => p.providerId).find(Boolean); + if (providerID) return providerID.trim(); + const [, suffix] = String(group.id ?? "").split(":", 2); + return suffix?.trim() ?? ""; +} + export function ProviderAccountManager({ group, accounts, diff --git a/desktop/provider_accounts.go b/desktop/provider_accounts.go index aa274fe80e..29a742daea 100644 --- a/desktop/provider_accounts.go +++ b/desktop/provider_accounts.go @@ -201,21 +201,31 @@ func (a *App) RenameProviderAccount(providerID, accountID, label string) error { } func (a *App) RetireProviderAccount(providerID, accountID string) error { + var retiredEnv string _, err := a.applyConfigChangeWithRuntimeMutation("retire provider account", func(c *config.Config) error { if refs := a.providerAccountLiveRefsFromConfig(c, providerID, accountID); len(refs) > 0 { return fmt.Errorf("cannot retire account %s/%s while it is referenced by %s", providerID, accountID, strings.Join(refs, ", ")) } + if _, account, ok := accountByID(c, providerID, accountID); ok { + retiredEnv = strings.TrimSpace(account.APIKeyEnv) + } if err := c.RetireProviderAccount(providerID, accountID); err != nil { return err } - _, account, ok := accountByID(c, providerID, accountID) - if ok && strings.TrimSpace(account.APIKeyEnv) != "" && !accountKeyEnvShared(c, account) { - if err := config.RemoveCredential(account.APIKeyEnv); err != nil { - return err - } - } return nil }) + if err != nil { + return err + } + if retiredEnv != "" { + if cfg, _, loadErr := a.loadDesktopUserConfigForView(); loadErr == nil { + if _, account, ok := accountByID(cfg, providerID, accountID); ok && !accountKeyEnvShared(cfg, account) { + if removeErr := config.RemoveCredential(retiredEnv); removeErr != nil { + return fmt.Errorf("account retired but credential cleanup failed; retry cleanup: %w", removeErr) + } + } + } + } return err } From f6d4944cc73c347db7f51816adf8a6f65b947fe1 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:54:13 +0800 Subject: [PATCH 22/58] fix(cli): expose disabled account routes for restore Problem: routes already disabled and removed from the provider list could not be restored from CLI setup.\nRoot cause: route menu enumerated only materialized provider entries.\nFix: include disabled route IDs from account metadata in the account route menu.\nVerification: go test -mod=mod ./internal/cli -run 'ProviderSetup|Account' -count=1 --- internal/cli/setup_accounts.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go index e237212c01..6f9537ad12 100644 --- a/internal/cli/setup_accounts.go +++ b/internal/cli/setup_accounts.go @@ -116,6 +116,13 @@ func manageProviderAccount(s *providerSetupSession, providerID, accountID string seenRoutes[routeID] = true routeIDs = append(routeIDs, routeID) } + for _, routeID := range account.DisabledRoutes { + routeID = strings.TrimSpace(routeID) + if routeID != "" && !seenRoutes[routeID] { + seenRoutes[routeID] = true + routeIDs = append(routeIDs, routeID) + } + } sort.Strings(routeIDs) for _, routeID := range routeIDs { disabled := containsString(account.DisabledRoutes, routeID) From 6beb101d4185ce4598326f09145029460a661861 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:54:44 +0800 Subject: [PATCH 23/58] fix(config): roll back route state on reconcile failure Keep route enablement atomic when provider expansion or reconciliation fails. Verification: go test ./internal/config -run ProviderAccount -count=1 --- internal/config/provider_account_mutate.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/provider_account_mutate.go b/internal/config/provider_account_mutate.go index 056c1c218d..9816795321 100644 --- a/internal/config/provider_account_mutate.go +++ b/internal/config/provider_account_mutate.go @@ -135,6 +135,7 @@ func (c *Config) SetProviderAccountRouteEnabled(providerID, accountID, routeID s } c.ProviderAccounts[idx].DisabledRoutes = normalizeProviderAccountRoutes(filtered) if _, _, err := ReconcileProviderAccounts(c); err != nil { + c.ProviderAccounts[idx].DisabledRoutes = oldDisabled return err } if enabled && indexAccountRouteEntry(c, account, routeID) < 0 { From 9d3f516cefd5c535836d7ddd80d03b8d7608fdc8 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:56:18 +0800 Subject: [PATCH 24/58] feat(frontend): expose account route controls Show available curated routes per account and wire enable/disable actions through the desktop bridge, preserving the retired account recovery flow. --- .../src/components/ProviderAccountSettings.tsx | 11 +++++++++++ desktop/frontend/src/components/SettingsPanel.tsx | 2 +- desktop/frontend/src/locales/en.ts | 1 + desktop/frontend/src/locales/zh-TW.ts | 1 + desktop/frontend/src/locales/zh.ts | 1 + desktop/frontend/src/styles.css | 5 +++++ 6 files changed, 20 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/src/components/ProviderAccountSettings.tsx b/desktop/frontend/src/components/ProviderAccountSettings.tsx index 99486631e0..12bcaabe7d 100644 --- a/desktop/frontend/src/components/ProviderAccountSettings.tsx +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -50,12 +50,14 @@ export function ProviderAccountManager({ group, accounts, providerPresets, + availableRoutes = [], busy, apply, }: { group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }; accounts: AccountView[]; providerPresets: ProviderPresetRef[]; + availableRoutes?: string[]; busy: boolean; apply: (fn: () => Promise) => Promise; }) { @@ -99,6 +101,15 @@ export function ProviderAccountManager({ {account.enabled ? t("settings.accountEnabled") : t("settings.accountDisabled")} {account.keySet ? t("settings.keySet") : t("settings.noKey")} {asArray(account.disabledRoutes).length > 0 ? {asArray(account.disabledRoutes).length} {t("settings.accountRoutesDisabled")} : null} + {availableRoutes.length > 1 && !account.retired ? ( + + {availableRoutes.map((route) => { + const disabled = asArray(account.disabledRoutes).includes(route); + return ; + })} + + ) : null} {account.retired ? ( ) : null}