diff --git a/desktop/app.go b/desktop/app.go index b4a41aad85..6188c45ba7 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 { @@ -9624,7 +9628,7 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) { if err != nil { return err } - entry, ok := cfg.ResolveModel(name) + entry, ok, name := resolveDesktopModelSelection(cfg, name) pluginRef := false if !ok { // Plugin-namespaced refs belong to extension sidecars: validate them diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index f3953bd62c..540d5c177f 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -180,14 +180,10 @@ console.log("\nbundle budgets"); // move the combined path to 462.2 KiB. Local spectator reclaim adds the // desktop-vs-remote command branch. Sticky Context's session-scoped file chips // bring the merged stable path to 462.587 KiB. Windows' embedded build metadata -// lands just above the rounded 462.6 KiB boundary; retain one cross-platform -// decimal step without widening any chunk or raw gate. -// Reading the applied item-list transform (instead of the remembered offset) -// keeps the reader/anchor visual guards from compounding under reduced-motion -// WebView2; the merged path measures 462.827 KiB. Retain one decimal step. -// Generation-bound native-thumb transactions and the rebased custom-scrollbar -// drag add 0.3 KiB gzip; the merged path measures 463.102 KiB. -const initialJSBudgetKiB = 463.2; +// and generic provider-account controls land at 463.414 KiB gzip. Keep this +// narrow one-decimal ratchet scoped to the initial shell; all other chunk +// budgets remain unchanged. +const initialJSBudgetKiB = 463.5; assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024); // Render-blocking CSS is intentionally absent: styles.css loads deferred via @@ -251,7 +247,9 @@ for (const path of localeChunks) { // reclaim), while Sticky Context adds file-state and limit diagnostics. The // merged stable chunks measure 60.395 KiB zh and 61.232 KiB zh-TW; retain // only the next one-decimal ceiling for each dialect. - const budget = name.startsWith("zh-TW-") ? 61.3 * 1024 : 60.4 * 1024; + // Account retirement/restore copy adds a bounded locale payload increase. + // Keep per-locale ceilings narrow and leave all other bundle budgets intact. + const budget = name.startsWith("zh-TW-") ? 61.4 * 1024 : 60.6 * 1024; assertBudget(`${name} gzip`, gzipBytes(path), budget); } @@ -329,14 +327,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // path 2465.105 KiB raw; the merged test channel measures 2464.979 KiB. // Session takeover banners and #9703/#9711's provisional-selection handoff // combine with Sticky Context's pinned-file state at 2469.125 KiB raw on the -// merged stable path. Retain only the next one-decimal ceiling. -// The passive reader-anchor lease for delayed WebView2 range commits measures -// 2469.347 KiB raw (+0.222 KiB, +0.009%). Retain only the next one-decimal -// ceiling; gzip and largest-chunk budgets remain unchanged. -// Reading the applied item-list transform for the reader/anchor visual guards -// adds 0.5 KiB raw on top; the merged path measures 2469.815 KiB. -// The scrollbar generation fence and drag rebase add 1.1 KiB raw; the merged -// path measures 2470.932 KiB. -const rawInitialBudgetKiB = 2_471.0; +// merged stable path. Account route controls and canonical selection payload +// add 4.25 KiB raw to the initial graph; retain a narrow one-decimal ceiling. +const rawInitialBudgetKiB = 2_473.5; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); 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..506b2faf54 100644 --- a/desktop/frontend/src/components/ModelSwitcher.tsx +++ b/desktop/frontend/src/components/ModelSwitcher.tsx @@ -80,43 +80,63 @@ 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; const pick = (model: ModelInfo) => { setOpen(false); const pendingKey = tabId ?? ""; + const selectionRef = model.providerGroup && model.accountId + ? `${model.providerGroup}/${model.accountId}/${model.model}` + : model.ref; const pendingPickCount = pendingPickCountByTabRef.current.get(pendingKey) ?? 0; // A catalog refresh can still report the outgoing model as current while // an earlier switch is rebuilding. In that window, selecting it again is @@ -150,7 +170,7 @@ export function ModelSwitcher({ void loadModelsForTab(tabId); }; try { - void Promise.resolve(onPick(model.ref)).then( + void Promise.resolve(onPick(selectionRef)).then( (switched) => settlePick(switched), () => settlePick(false), ); @@ -202,22 +222,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 +259,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 +272,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..dcb8291c2e --- /dev/null +++ b/desktop/frontend/src/components/ProviderAccountSettings.tsx @@ -0,0 +1,171 @@ +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), + disabledRoutes: asArray(p.disabledRoutes), + }; +} + +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[]); + 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 = 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)) + || Number(a.displayOrder ?? 0) - Number(b.displayOrder ?? 0) + || a.id.localeCompare(b.id)); + 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, + providerPresets, + availableRoutes = [], + busy, + apply, +}: { + group: { id: string; providerGroup?: string; providers: { providerId?: string }[] }; + accounts: AccountView[]; + providerPresets: ProviderPresetRef[]; + availableRoutes?: string[]; + 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, providerPresets); + 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.retired ? {t("settings.accountRetire")} : null} + {account.default ? {t("settings.accountDefault")} : null} + {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}× : null} + {availableRoutes.length > 1 && !account.retired ? ( + + {availableRoutes.map((route) => { + const disabled = asArray(account.disabledRoutes).includes(route); + return ; + })} + + ) : null} + {account.retired ? ( + + ) : null} + + + + + + )} +
+ ))} + {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..ddbd9ab0be 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,10 @@ 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), + availableRoutes: asArray(p.availableRoutes), }; } @@ -1454,6 +1465,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 +5269,9 @@ function ProvidersSection({ s, busy, apply }: SectionProps) { Promise) => Promise; busy: boolean; fetching: boolean; fetchResult?: ProviderFetchResult; @@ -5954,6 +5976,7 @@ export function ProviderAccessCard({ ); return (
+ p.accountGroupId === group.providerGroup)?.availableRoutes ?? []} busy={busy} apply={apply} />
@@ -6412,10 +6435,12 @@ export function providerAccessGroups(providers: ProviderView[], t: ReturnType): string { const id = providerGroupID(p); if (id === "builtin:deepseek") return t ? t("settings.providerLabel.deepseek") : "DeepSeek"; @@ -6533,19 +6539,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..c8680800d1 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|RestoreProviderAccount|SetProviderAccountRouteEnabled|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..8368f2de54 --- /dev/null +++ b/desktop/frontend/src/lib/mockProviderAccounts.ts @@ -0,0 +1,60 @@ +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; + 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; +}; + +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 = 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); + }, + 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..3b7ec1eff5 --- /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[]; disabledRoutes?: string[]; +} diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index 951a9cf876..19ef3e72e7 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; availableRoutes?: string[]; } 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..5474a4e3ca 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2014,6 +2014,18 @@ 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.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 f178f9655e..9c3d8b607b 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1552,6 +1552,18 @@ 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.accountRetireConfirm": "確定退休此帳號嗎?已有工作階段仍會保留其供應商項目。", "settings.addProviderAccess": "新增接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "確認移除接入", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index a2588ad135..c44dc25804 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2016,6 +2016,18 @@ 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.accountRetireConfirm": "确定退休此账号吗?已有会话仍会保留其供应商条目。", "settings.addProviderAccess": "添加接入", "settings.removeProviderAccess": "移除接入", "settings.confirmRemoveProviderAccess": "确认移除接入", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 09c3e5859f..87bea219a6 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -17894,6 +17894,59 @@ 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; +} +.provider-account-routes { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} +@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/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..b429ca9157 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}) @@ -101,6 +107,11 @@ func (a *App) desktopModelCatalog(curModel, workspaceRoot string, ctrl control.S if err != nil { return []ModelInfo{} } + requestedRef := strings.TrimSpace(curModel) + if requestedRef == "" { + requestedRef = strings.TrimSpace(cfg.DefaultModel) + curModel = requestedRef + } if entry, ok := cfg.ResolveModel(curModel); ok { curModel = entry.Name + "/" + entry.Model } @@ -110,10 +121,46 @@ func (a *App) desktopModelCatalog(curModel, workspaceRoot string, ctrl control.S if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() { continue } + account, accountKnown := config.ProviderAccountForEntry(cfg, *p) + if accountKnown && (account.Retired || !account.IsEnabled()) && !strings.HasPrefix(curModel, p.Name+"/") { + continue + } 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, + }) } } + appendLegacyAccountFamilyModels(&out, cfg, requestedRef) return mergeExtensionModelInfos(out, extensionCatalog, curModel) } + +func appendLegacyAccountFamilyModels(out *[]ModelInfo, cfg *config.Config, requestedRef string) { + if out == nil || cfg == nil { + return + } + family, model, ok := strings.Cut(strings.TrimSpace(requestedRef), "/") + if !ok || family == "" { + return + } + entry, found := cfg.ResolveModel(requestedRef) + if !found { + return + } + account, ok := config.ProviderAccountForEntry(cfg, *entry) + if !ok || account.ProviderID == family || !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) { + return + } + family = account.ProviderID + for _, item := range *out { + if item.Ref == family+"/"+model { + return + } + } + if model == "" { + return + } + *out = append(*out, ModelInfo{Ref: family + "/" + model, Provider: family, Model: model, Current: strings.TrimSpace(requestedRef) == family+"/"+model, ProviderGroup: account.ProviderID, AccountID: account.ID, AccountLabel: account.Label, AccountDefault: account.Default}) +} diff --git a/desktop/provider_access_group_removal_test.go b/desktop/provider_access_group_removal_test.go index 83444a7691..4dc5fa33ac 100644 --- a/desktop/provider_access_group_removal_test.go +++ b/desktop/provider_access_group_removal_test.go @@ -80,9 +80,16 @@ 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 { @@ -166,8 +173,15 @@ func TestRemoveProviderAccessesKeepsCoreRoutesWhenRemovingOpenCodeGoSearchSubset } } for _, name := range searchNames { - if _, ok := got.Provider(name); ok { - t.Fatalf("search provider %q still exists", name) + entry, ok := got.Provider(name) + if !ok { + t.Fatalf("search provider %q missing; retained account routes are needed for old sessions", name) + } + if entry.AccountProviderID == "" || entry.AccountID == "" { + t.Fatalf("search provider %q lost account metadata: %+v", name, entry) + } + if providerAccessSet(got.Desktop.ProviderAccess)[name] { + t.Fatalf("search provider %q remains in provider access", name) } } if got.DefaultModel != cfg.DefaultModel { diff --git a/desktop/provider_access_removal.go b/desktop/provider_access_removal.go index 45270bd9d3..57f1165ac3 100644 --- a/desktop/provider_access_removal.go +++ b/desktop/provider_access_removal.go @@ -28,6 +28,15 @@ type providerRemovalTab struct { retargetModel bool } +func officialProviderKindFromName(name string) string { + switch strings.TrimSpace(name) { + case "deepseek", "deepseek-flash", "deepseek-pro", "deepseek-anthropic", "deepseek-responses": + return "deepseek" + default: + return "" + } +} + // DeleteProvider removes a provider and retargets open idle tabs that used it. func (a *App) DeleteProvider(name string) error { return a.deleteProviderAndRetargetTabs(name) @@ -55,7 +64,26 @@ func (a *App) RemoveProviderAccesses(rawNames []string) error { for _, name := range names { p, ok := cfg.Provider(name) if !ok { - return fmt.Errorf("remove provider access: provider %q not found", name) + // Family cards may be represented by generated official routes. + kind := officialProviderKindFromName(name) + if kind == "" { + return fmt.Errorf("remove provider access: provider %q not found", name) + } + found := false + for _, candidate := range cfg.Providers { + if officialProviderKindFromEntry(candidate) == kind { + found = true + break + } + } + if !found { + return fmt.Errorf("remove provider access: provider %q not found", name) + } + if officialKind != "" && officialKind != kind { + return fmt.Errorf("remove provider access: providers do not belong to one official group") + } + officialKind = kind + continue } kind := officialProviderKindFromEntry(*p) if kind == "" { @@ -111,7 +139,26 @@ func validateOfficialProviderRemoval(c *config.Config, names []string) error { for _, name := range names { p, ok := c.Provider(name) if !ok { - return fmt.Errorf("remove provider access: provider %q not found", name) + // Accept generated legacy routes when the canonical family alias is absent. + kind := officialProviderKindFromName(name) + if kind == "" { + return fmt.Errorf("remove provider access: provider %q not found", name) + } + found := false + for _, candidate := range c.Providers { + if officialProviderKindFromEntry(candidate) == kind { + found = true + break + } + } + if !found { + return fmt.Errorf("remove provider access: provider %q not found", name) + } + if officialKind != "" && officialKind != kind { + return fmt.Errorf("remove provider access: providers do not belong to one official group") + } + officialKind = kind + continue } kind := officialProviderKindFromEntry(*p) if kind == "" { @@ -224,13 +271,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 +556,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) } @@ -480,22 +586,23 @@ func (a *App) commitCustomProviderRemovals(plan providerRemovalPlan) (string, er return "", err } fallbackRef := providerAccessFallbackRef(fresh, plan.targets) - // Config.RemoveProvider has a compatibility fallback across every configured - // provider. Settings access removal is narrower: hidden providers must not - // silently become the new default after restart. Retarget first so the - // persisted config and every rebuilt tab use the same visible provider. Keep - // the historical provider-only persisted form while the runtime uses the - // exact provider/model reference returned below. + // Persist a provider-only fallback while runtimes use the exact model ref. persistedFallback := fallbackRef if providerName, _, ok := strings.Cut(fallbackRef, "/"); ok { persistedFallback = providerName } 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) } @@ -522,10 +629,7 @@ func (a *App) applyProviderRemovalRuntime(affected []providerRemovalTab, fallbac reset = append(reset, item) continue } - // lockRuntimeTurnGates already holds this tab's turnStartMu, and the - // outer runtime mutation owns runtimeRebuildMu plus admission. Reuse - // the settings build-and-swap core without reacquiring either lock so - // the old controller remains usable if replacement construction fails. + // Reuse the locked settings build-and-swap core without reacquiring locks. modelOverride := "" if item.retargetModel { modelOverride = fallbackRef diff --git a/desktop/provider_access_removal_test.go b/desktop/provider_access_removal_test.go index 39156254fe..3115773f95 100644 --- a/desktop/provider_access_removal_test.go +++ b/desktop/provider_access_removal_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "reflect" "strings" "testing" @@ -41,7 +42,6 @@ func TestRemoveProviderAccessesRemovesGroupedOfficialAliasesAtomically(t *testin if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - app := NewApp() flashTab := &WorkspaceTab{ID: "flash", Scope: "global", model: "deepseek-flash/deepseek-v4-flash"} proTab := &WorkspaceTab{ID: "pro", Scope: "global", model: "deepseek-pro/deepseek-v4-pro"} @@ -251,7 +251,6 @@ func TestDeleteProviderRebuildsEveryVisibleRuntimeUsingAuxiliaryProvider(t *test if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - app := NewApp() app.ctx = context.Background() app.readyHook = func() {} @@ -376,7 +375,6 @@ func TestDeleteProviderRejectsDetachedRuntimeUsingAuxiliaryProvider(t *testing.T if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - app := NewApp() app.ctx = context.Background() detachedCtrl := control.New(control.Options{Label: cfg.DefaultModel, Sink: event.Discard}) @@ -481,7 +479,7 @@ func TestRemoveProviderAccessRejectsDetachedRuntimeBeforeMutation(t *testing.T) if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - + beforeAccess := providerAccessSet(config.LoadForEdit(config.UserConfigPath()).Desktop.ProviderAccess) app := NewApp() detachedCtrl := control.New(control.Options{Label: "deepseek"}) detached := &WorkspaceTab{ @@ -496,7 +494,7 @@ func TestRemoveProviderAccessRejectsDetachedRuntimeBeforeMutation(t *testing.T) t.Fatalf("RemoveProviderAccess error = %v, want detached-runtime guard", err) } got := config.LoadForEdit(config.UserConfigPath()) - if !providerAccessSet(got.Desktop.ProviderAccess)["deepseek"] { + if !reflect.DeepEqual(providerAccessSet(got.Desktop.ProviderAccess), beforeAccess) { t.Fatalf("provider access changed after detached-runtime rejection: %+v", got.Desktop.ProviderAccess) } if detached.Ctrl != detachedCtrl || detached.model != "deepseek/deepseek-v4-flash" { @@ -515,7 +513,6 @@ func TestRemoveProviderAccessesRejectsMixedGroupBeforeMutation(t *testing.T) { if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - if err := NewApp().RemoveProviderAccesses([]string{"deepseek", "custom"}); err == nil { t.Fatal("RemoveProviderAccesses accepted mixed official and custom providers") } @@ -653,7 +650,7 @@ func TestRemoveProviderAccessesRejectsInUseProviderWithoutConfiguredFallback(t * if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - + beforeAccess := providerAccessSet(config.LoadForEdit(config.UserConfigPath()).Desktop.ProviderAccess) app := NewApp() tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel} app.tabs = map[string]*WorkspaceTab{tab.ID: tab} @@ -666,7 +663,7 @@ func TestRemoveProviderAccessesRejectsInUseProviderWithoutConfiguredFallback(t * } got := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(got.Desktop.ProviderAccess) - if !access["deepseek"] || !access["mimo-pro"] { + if !reflect.DeepEqual(access, beforeAccess) { t.Fatalf("provider access changed after rejected removal: %+v", got.Desktop.ProviderAccess) } if got.DefaultModel != cfg.DefaultModel || tab.model != cfg.DefaultModel { @@ -685,7 +682,6 @@ func TestRemoveProviderAccessesRejectsOfficialProviderChangedDuringSnapshot(t *t if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - app := NewApp() ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"})) tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl} @@ -700,9 +696,12 @@ func TestRemoveProviderAccessesRejectsOfficialProviderChangedDuringSnapshot(t *t unlock := config.LockUserConfigEdits() changed := config.LoadForEdit(config.UserConfigPath()) provider, ok := changed.Provider("deepseek") + if !ok { + provider, ok = changed.Provider("deepseek-flash") + } if !ok { unlock() - t.Fatal("deepseek provider missing") + t.Fatal("DeepSeek provider route missing") } provider.BaseURL = "https://proxy.example/v1" if err := changed.SaveTo(config.UserConfigPath()); err != nil { @@ -717,7 +716,8 @@ func TestRemoveProviderAccessesRejectsOfficialProviderChangedDuringSnapshot(t *t } got := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(got.Desktop.ProviderAccess) - if !access["deepseek"] || !access["mimo-pro"] { + deepseekAccess := access["deepseek"] || access["deepseek-flash"] || access["deepseek-pro"] + if !deepseekAccess || !access["mimo-pro"] { t.Fatalf("provider access changed after rejected overlap: %+v", got.Desktop.ProviderAccess) } if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel { @@ -736,7 +736,7 @@ func TestRemoveProviderAccessesRejectsCredentialChangeDuringSnapshot(t *testing. if err := cfg.SaveTo(config.UserConfigPath()); err != nil { t.Fatalf("save config: %v", err) } - + beforeAccess := providerAccessSet(config.LoadForEdit(config.UserConfigPath()).Desktop.ProviderAccess) app := NewApp() ctrl := newBlockingSnapshotCtrl(control.New(control.Options{Label: "deepseek"})) tab := &WorkspaceTab{ID: "deepseek", Scope: "global", model: cfg.DefaultModel, Ctrl: ctrl} @@ -755,7 +755,7 @@ func TestRemoveProviderAccessesRejectsCredentialChangeDuringSnapshot(t *testing. } got := config.LoadForEdit(config.UserConfigPath()) access := providerAccessSet(got.Desktop.ProviderAccess) - if !access["deepseek"] || !access["mimo-pro"] { + if !reflect.DeepEqual(access, beforeAccess) { t.Fatalf("provider access changed after rejected credential overlap: %+v", got.Desktop.ProviderAccess) } if ctrl.closeCount.Load() != 0 || tab.Ctrl != ctrl || tab.model != cfg.DefaultModel { diff --git a/desktop/provider_account_compat.go b/desktop/provider_account_compat.go new file mode 100644 index 0000000000..04a4c5f5df --- /dev/null +++ b/desktop/provider_account_compat.go @@ -0,0 +1,49 @@ +package main + +import "reasonix/internal/config" + +func resolveDesktopModelSelection(cfg *config.Config, ref string) (*config.ProviderEntry, bool, string) { + entry, ok := cfg.ResolveModel(ref) + if selection, selectionErr := config.ParseProviderSelection(cfg, ref); selectionErr == nil { + if resolved, resolveErr := cfg.ResolveSelection(selection); resolveErr == nil { + return resolved, true, selection.Ref() + } + } + return entry, ok, ref +} + +// appendLegacyAccountFamilyViews keeps the historical family provider name +// visible while account routes remain the canonical persisted entries. +func appendLegacyAccountFamilyViews(views *[]ProviderView, cfg *config.Config, added map[string]bool, root string, resolver *config.CredentialResolver, credentialsRevision string) { + if views == nil || cfg == nil { + return + } + if len(cfg.Desktop.ProviderAccess) == 0 && configDeclaresProviderAccess(config.UserConfigPath()) { + return + } + for _, family := range []string{"deepseek"} { + if _, exists := cfg.Provider(family); exists { + continue + } + account, ok := cfg.DefaultAccount(family) + if !ok { + continue + } + entries, ok := cfg.ResolveAccountProvider(family, account.ID) + if !ok { + continue + } + for _, entry := range entries { + if !entry.Configured() || len(entry.ModelList()) == 0 { + continue + } + view := providerViewFromEntryForRootWithResolverAndCredentials(entry, true, added[family], root, resolver, credentialsRevision) + view.Name = family + view.Added = true + view.ProviderID, view.AccountID, view.AccountLabel = account.ProviderID, account.ID, account.Label + view.AccountEnabled, view.AccountDefault = account.IsEnabled(), account.Default + *views = append(*views, view) + break + } + } +} diff --git a/desktop/provider_accounts.go b/desktop/provider_accounts.go new file mode 100644 index 0000000000..271fbf9fde --- /dev/null +++ b/desktop/provider_accounts.go @@ -0,0 +1,377 @@ +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"` + DisabledRoutes []string `json:"disabledRoutes,omitempty"` +} + +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 + routes := make([]string, 0, len(preset.Entries)) + 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) + routes = append(routes, 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, + AvailableRoutes: nonNil(routes), + }) + } + 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 { + 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{}, + DisabledRoutes: nonNil(account.DisabledRoutes), + } + 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 + 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 + entries, _ := c.ResolveAccountProvider(account.ProviderID, account.ID) + addProviderAccess(c, providerEntryNames(entries)...) + return nil + }) + if err != nil { + if !change.Committed && strings.TrimSpace(created.APIKeyEnv) != "" && strings.TrimSpace(key) != "" { + _ = config.RemoveCredential(created.APIKeyEnv) + } + return "", err + } + 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 { + 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 { + 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 + } + 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 +} + +// 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) { + 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 + } + 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 { + 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 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 { + continue + } + if entry.AccountProviderID == account.ProviderID && entry.AccountID == account.ID { + continue + } + 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..59b2d7307e --- /dev/null +++ b/desktop/provider_accounts_test.go @@ -0,0 +1,102 @@ +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 +} + +func TestAccountKeyEnvSharedScansRetainedAndCustomProviders(t *testing.T) { + account := config.ProviderAccount{ProviderID: "deepseek", ID: "team", APIKeyEnv: "SHARED_KEY", Retired: true} + cfg := &config.Config{ + ProviderAccounts: []config.ProviderAccount{account}, + Providers: []config.ProviderEntry{ + {Name: "deepseek--team", APIKeyEnv: "SHARED_KEY", AccountProviderID: "deepseek", AccountID: "team"}, + }, + } + if accountKeyEnvShared(cfg, account) { + t.Fatal("retained entries belonging to the same retired account should not keep its key live") + } + cfg.Providers = append(cfg.Providers, config.ProviderEntry{Name: "custom", APIKeyEnv: "SHARED_KEY"}) + if !accountKeyEnvShared(cfg, account) { + t.Fatal("custom provider sharing the key env must prevent key deletion") + } +} 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..0b928ee529 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,33 @@ 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"` + AvailableRoutes []string `json:"availableRoutes"` } const ( @@ -302,33 +311,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 +695,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 +727,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 +942,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 +1015,11 @@ 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) } + appendLegacyAccountFamilyViews(&v.Providers, cfg, added, root, resolver, credentialsRevision) + v.ProviderAccounts = providerAccountViewsForRoot(cfg, root, resolver) return v } @@ -1333,8 +1274,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 @@ -1352,17 +1317,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 diff --git a/docs/CLI.md b/docs/CLI.md index fd00446ee9..edc3cc4c24 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -28,7 +28,7 @@ Running `reasonix` without a subcommand starts the interactive terminal UI. Use | Flag | Purpose | | --- | --- | -| `--model NAME` | Select a configured provider or `provider/model` reference. | +| `--model NAME` | Select a configured provider, `provider/model`, or curated `family/account/model` reference. | | `--effort LEVEL` | Override reasoning effort for this session. | | `--max-steps N` | Set a one-off maximum tool-call round budget; `0` uses automatic execution. | | `--dir PATH` | Change the workspace root before loading config and tools. | @@ -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 /` selects an account; generated provider names remain accepted. | | `/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..f99ae1b514 100644 --- a/docs/CLI.zh-CN.md +++ b/docs/CLI.zh-CN.md @@ -23,7 +23,7 @@ reasonix --dir /path/to/project | 参数 | 用途 | | --- | --- | -| `--model NAME` | 选择已配置的 provider 或 `provider/model` 引用。 | +| `--model NAME` | 选择已配置的 provider、`provider/model`,或精选供应商的 `family/account/model` 引用。 | | `--effort LEVEL` | 覆盖当前会话的 reasoning effort。 | | `--max-steps N` | 为本次运行设置工具调用轮数上限;`0` 使用自动执行。 | | `--dir PATH` | 加载配置和工具前切换 workspace 根目录。 | @@ -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..025d825bf0 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -39,8 +39,16 @@ 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 -`.env`, home `.env`, inherited shell environment variables, legacy credentials, +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. New model selections use `family/account/model` (for example, +`deepseek/team/deepseek-v4-flash`); older `provider/model` and generated provider +names remain valid for compatibility. 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, non-provider `${VAR}` expansion for MCP/plugin settings without importing diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 8e7ef72adf..83d88bf031 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 可能导致服务商侧缓存 +按租户分裂。新模型选择使用 `family/account/model`(例如 `deepseek/team/deepseek-v4-flash`);旧的 `provider/model` 和生成 provider 名仍可兼容使用。项目 `.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..8519f17cc3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -97,10 +97,20 @@ 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 compatibility identities such as `deepseek--team/deepseek-v4-flash`. + New selectors use the stable `family/account/model` form, for example + `deepseek/team/deepseek-v4-flash`; generated provider names remain accepted + for existing sessions and project references. + 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 + model), a bare model name, an explicit `provider/model`, or a curated + `family/account/model` selection. `context_window` is the provider-wide fallback; `model_overrides..context_window` can replace it for one model. Per-model `prices` use model IDs as keys. - Streaming tool-call deltas are accumulated by index inside the provider; only @@ -1007,7 +1017,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 +1052,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..9acb55ebfb 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/team/deepseek-v4-flash` 形式;裸家族名如 `deepseek` 解析到该族默认账号。账号切换是手动选择,不做轮询或故障切换。`default_model`、`--model` 和桌面端模型选择器都经 `Config.ResolveModel` 解析,可接受 provider 名、裸模型名、`provider/model` 或 `family/account/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..07c508d2a6 --- /dev/null +++ b/internal/boot/provider_account_auth_test.go @@ -0,0 +1,140 @@ +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.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 + } + 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)) + } + 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) + } + } +} 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/model.go b/internal/cli/model.go index 95d7f8d318..94e9075698 100644 --- a/internal/cli/model.go +++ b/internal/cli/model.go @@ -165,18 +165,42 @@ func modelRefs() []string { return nil } var out []string + seen := map[string]bool{} for i := range cfg.Providers { p := &cfg.Providers[i] if !p.Configured() { continue } + if account, ok := config.ProviderAccountForEntry(cfg, *p); ok && !account.IsEnabled() { + continue + } + if account, ok := config.ProviderAccountForEntry(cfg, *p); ok && !selectableAccountRoute(account, p.AccountRouteID) { + continue + } for _, model := range p.ChatModelList() { - out = append(out, p.Name+"/"+model) + ref := p.Name + "/" + model + if selection, ok := cfg.SelectionForProviderModel(*p, model); ok { + ref = selection.Ref() + } + if seen[ref] { + continue + } + seen[ref] = true + out = append(out, ref) } } return out } +func selectableAccountRoute(account config.ProviderAccount, routeID string) bool { + for _, disabled := range account.DisabledRoutes { + if strings.TrimSpace(disabled) == strings.TrimSpace(routeID) { + return false + } + } + return true +} + // mergeExtensionModelRefs folds the session's extension provider catalog into // the config-backed picker list. Extension refs arrive fully namespaced // (plugin///); entries already listed (or blank) are diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 3c845cf6d6..0b7c9c4f99 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -31,11 +31,55 @@ func (m *chatTUI) openProviderPicker() { return } curProvider := strings.SplitN(m.modelRef, "/", 2)[0] + curSelection, _ := config.ParseProviderSelection(cfg, m.modelRef) 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 + } + entries = selectableAccountEntries(account, entries) + if len(entries) == 0 { + 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 || (curSelection.FamilyID == account.ProviderID && curSelection.AccountID == account.ID) { + status = "active" + selected = len(items) + } + } + modelNames := make([]string, 0, len(entries)) + for _, entry := range entries { + models := entry.ChatModelList() + if len(models) == 0 { + models = entry.ModelList() + } + modelNames = append(modelNames, models...) + } + items = append(items, quickPickerItem{ + ID: account.ProviderID + "/" + account.ID, Label: account.Label, + Description: fmt.Sprintf("%s · %d route(s) · %s", account.ProviderID, len(entries), strings.Join(modelNames, ", ")), Status: status, + }) + } for i := range cfg.Providers { p := &cfg.Providers[i] - if !p.Configured() { + if seen[p.Name] || !p.Configured() { continue } models := p.ChatModelList() @@ -70,13 +114,25 @@ func (m *chatTUI) switchToProvider(name string) { return } var entry *config.ProviderEntry + entry = providerEntryForAccountSelection(cfg, name) 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 @@ -100,6 +156,9 @@ func (m *chatTUI) switchToProvider(name string) { // If only one model, switch directly. if len(models) == 1 { ref := entry.Name + "/" + models[0] + if selection, ok := cfg.SelectionForProviderModel(*entry, models[0]); ok { + ref = selection.Ref() + } if entry.Name == curProvider && models[0] == "" { m.notice(fmt.Sprintf(i18n.M.ProviderAlreadyOnFmt, name)) return @@ -110,10 +169,14 @@ func (m *chatTUI) switchToProvider(name string) { items := make([]quickPickerItem, 0, len(models)) selected := 0 + currentSelection, _ := config.ParseProviderSelection(cfg, m.modelRef) for _, model := range models { ref := entry.Name + "/" + model + if selection, ok := cfg.SelectionForProviderModel(*entry, model); ok { + ref = selection.Ref() + } status := "" - if ref == m.modelRef { + if ref == m.modelRef || (currentSelection.Model == model && currentSelection.FamilyID == entry.AccountProviderID && currentSelection.AccountID == entry.AccountID) { status = "active" selected = len(items) } @@ -125,6 +188,53 @@ func (m *chatTUI) switchToProvider(name string) { } } +func mustResolveAccountProvider(cfg *config.Config, providerID, accountID string) []config.ProviderEntry { + entries, _ := cfg.ResolveAccountProvider(providerID, accountID) + return entries +} + +func providerEntryForAccountSelection(cfg *config.Config, name string) *config.ProviderEntry { + family, accountID, ok := strings.Cut(strings.TrimSpace(name), "/") + if !ok || !config.IsProviderAccountID(accountID) { + return nil + } + for _, account := range cfg.ProviderAccounts { + if account.ProviderID != family || account.ID != accountID || !account.IsEnabled() { + continue + } + for _, candidate := range selectableAccountEntries(account, mustResolveAccountProvider(cfg, family, accountID)) { + if candidate.Configured() && len(candidate.ChatModelList()) > 0 { + copy := candidate + return © + } + } + } + return nil +} + +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 { diff --git a/internal/cli/provider_account_setup_test.go b/internal/cli/provider_account_setup_test.go new file mode 100644 index 0000000000..39967d99f0 --- /dev/null +++ b/internal/cli/provider_account_setup_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "strings" + "testing" + + "reasonix/internal/config" +) + +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) + } + for _, entry := range reloaded.Providers { + if entry.AccountProviderID == team.ProviderID && entry.AccountID == team.ID && entry.AccountRouteID == "deepseek-responses" && containsString(reloaded.Desktop.ProviderAccess, entry.Name) { + t.Fatalf("disabled route %q remains in provider access", entry.Name) + } + } + 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) + } + } + 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 { + 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 +} diff --git a/internal/cli/setup_accounts.go b/internal/cli/setup_accounts.go new file mode 100644 index 0000000000..9b82fa7915 --- /dev/null +++ b/internal/cli/setup_accounts.go @@ -0,0 +1,324 @@ +package cli + +import ( + "fmt" + "os" + "sort" + "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 { + 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" + } + 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 { + 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 + } + 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) + } + 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) + action := "Enable route" + if !disabled { + action = "Disable route" + } + items = append(items, menuItem{name: fmt.Sprintf("%s: %s", action, routeID)}) + } + } 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: + 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: + 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.mutateProviderAccount(account.ProviderID, account.ID, func() error { + return s.cfg.RetireProviderAccount(account.ProviderID, account.ID) + }); err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + entries, _ := s.cfg.ResolveAccountProvider(account.ProviderID, account.ID) + for _, entry := range entries { + s.removeProviderAccess(entry.Name) + } + } + 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) + } + } + } +} + +func addProviderAccountToSession(s *providerSetupSession) bool { + if s.projectScoped { + fmt.Fprintln(os.Stderr, i18n.M.SetupProjectNoAccounts) + return false + } + presets := curatedAccountSetupPresets() + 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(askCredentialLine()) + 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) + return false + } + } + entries, _ := s.cfg.ResolveAccountProvider(account.ProviderID, account.ID) + s.addProviderAccess(entries) + s.promoteDefaultToNewProviders(entries) + 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 + } + } + 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 +} + +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(askCredentialLine()) + 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 + } + 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) { + 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 { + // 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) + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} + +func askCredentialLine() string { + // Keep the credential prompt constant so tainted input cannot reach logging. + fmt.Print("API key: ") + 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 dd01180019..9a545692ed 100644 --- a/internal/cli/setup_manager.go +++ b/internal/cli/setup_manager.go @@ -27,6 +27,7 @@ type providerSetupSession struct { projectScoped bool declaredProviders []string operations []providerSetupOperation + originalAccounts map[string]config.ProviderAccount } const setupManagerContinue = 2 @@ -39,19 +40,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,10 +106,14 @@ 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 } @@ -416,44 +432,6 @@ func (s *providerSetupSession) credentialLines() []string { return lines } -func (s *providerSetupSession) summary() []string { - var added, edited []string - for _, p := range s.cfg.Providers { - old, existed := s.originalProviders[p.Name] - switch { - case !existed: - added = append(added, p.Name) - case !providerSetupEqual(old, p): - edited = append(edited, p.Name) - } - } - var out []string - if len(added) > 0 { - out = append(out, fmt.Sprintf(i18n.M.SetupSummaryAddedFmt, strings.Join(added, ", "))) - } - if len(edited) > 0 { - out = append(out, fmt.Sprintf(i18n.M.SetupSummaryEditedFmt, strings.Join(edited, ", "))) - } - if len(s.removed) > 0 { - names := make([]string, 0, len(s.removed)) - for name := range s.removed { - names = append(names, name) - } - sort.Strings(names) - out = append(out, fmt.Sprintf(i18n.M.SetupSummaryRemovedFmt, strings.Join(names, ", "))) - } - if s.cfg.DefaultModel != s.originalDefault { - out = append(out, fmt.Sprintf(i18n.M.SetupSummaryDefaultFmt, s.cfg.DefaultModel)) - } - if len(s.pendingCredentials) > 0 { - out = append(out, fmt.Sprintf(i18n.M.SetupSummaryKeysFmt, len(s.pendingCredentials))) - } - if len(out) == 0 { - out = append(out, i18n.M.SetupSummaryNoChanges) - } - return out -} - 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. @@ -477,60 +455,43 @@ 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}, - ) -} - func addProviderToSession(s *providerSetupSession, anthropic bool) bool { var result providerPromptResult var err error @@ -839,6 +800,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) } @@ -846,16 +811,6 @@ func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclar return nil } -func providerSetupAccessContains(names []string, want string) bool { - want = strings.TrimSpace(want) - for _, name := range names { - if strings.TrimSpace(name) == want { - return true - } - } - return false -} - func commitProviderSetupSession(s *providerSetupSession, configPath string) (bool, error) { if len(s.operations) == 0 { return false, nil diff --git a/internal/cli/setup_manager_accounts.go b/internal/cli/setup_manager_accounts.go new file mode 100644 index 0000000000..8361a07608 --- /dev/null +++ b/internal/cli/setup_manager_accounts.go @@ -0,0 +1,423 @@ +package cli + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "reasonix/internal/config" + "reasonix/internal/i18n" +) + +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) || defaultBefore != s.cfg.DefaultModel { + 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 + accessBefore := append([]string(nil), s.cfg.Desktop.ProviderAccess...) + 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) || 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 + } + } + s.recordAccessTransition(accessBefore) + return nil +} + +func (s *providerSetupSession) restoreProviderAccount(providerID, accountID string) error { + familyBefore := s.accountFamilySnapshots(providerID) + defaultBefore := s.cfg.DefaultModel + accessBefore := append([]string(nil), s.cfg.Desktop.ProviderAccess...) + 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) || defaultBefore != s.cfg.DefaultModel { + 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 + } + } + s.recordAccessTransition(accessBefore) + 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 (s *providerSetupSession) summary() []string { + var added, edited []string + for _, p := range s.cfg.Providers { + old, existed := s.originalProviders[p.Name] + switch { + case !existed: + added = append(added, p.Name) + case !providerSetupEqual(old, p): + edited = append(edited, p.Name) + } + } + var out []string + if len(added) > 0 { + out = append(out, fmt.Sprintf(i18n.M.SetupSummaryAddedFmt, strings.Join(added, ", "))) + } + 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 := providerSetupAccountSummary(account) + 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 { + names = append(names, name) + } + sort.Strings(names) + out = append(out, fmt.Sprintf(i18n.M.SetupSummaryRemovedFmt, strings.Join(names, ", "))) + } + if s.cfg.DefaultModel != s.originalDefault { + out = append(out, fmt.Sprintf(i18n.M.SetupSummaryDefaultFmt, s.cfg.DefaultModel)) + } + if len(s.pendingCredentials) > 0 { + out = append(out, fmt.Sprintf(i18n.M.SetupSummaryKeysFmt, len(s.pendingCredentials))) + } + if len(out) == 0 { + out = append(out, i18n.M.SetupSummaryNoChanges) + } + 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 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") + } + if err := replayProviderAccountSnapshots(cfg, operation, field); err != nil { + return err + } + if err := replayProviderAccountDefaultModel(cfg, operation); err != nil { + return err + } + return replayProviderAccountRoute(cfg, operation, field) +} + +func replayProviderAccountSnapshots(cfg *config.Config, operation providerSetupOperation, field string) error { + 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} + } + if len(before) == 0 && len(after) == 0 { + return fmt.Errorf("replay %s: empty account change", field) + } + 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 + } + 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 = out + } + if _, _, reconcileErr := config.ReconcileProviderAccounts(cfg); reconcileErr != nil { + return fmt.Errorf("replay %s: %w", field, reconcileErr) + } + return nil +} + +func replayProviderAccountDefaultModel(cfg *config.Config, operation providerSetupOperation) error { + 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 replayProviderAccountRoute(cfg *config.Config, operation providerSetupOperation, field string) error { + if operation.routeID == "" || operation.afterRoute == nil || len(operation.beforeAccounts) != 0 || len(operation.afterAccounts) != 0 { + return nil + } + _, 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 { + if strings.TrimSpace(name) == want { + return true + } + } + return false +} 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..312aeacf16 --- /dev/null +++ b/internal/config/provider_account.go @@ -0,0 +1,317 @@ +package config + +import ( + "fmt" + "hash/fnv" + "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"` + DisabledRoutes []string `toml:"disabled_routes,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 + } + return "a" + providerIdentityHash(strings.TrimSpace(providerID) + "\x00" + label)[: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 + } + } + 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 { + 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 + } + hashed := candidate + "_" + strings.ToUpper(providerIdentityHash(candidate)[:6]) + 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 + } + short := providerIdentityHash(base)[:6] + 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 + "-" + 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 { + 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 + } + if a.DisabledRoutes != nil { + a.DisabledRoutes = append([]string(nil), a.DisabledRoutes...) + } + 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_change.go b/internal/config/provider_account_change.go new file mode 100644 index 0000000000..f28ffdf584 --- /dev/null +++ b/internal/config/provider_account_change.go @@ -0,0 +1,158 @@ +package config + +import ( + "fmt" + "maps" + "reflect" + "strings" +) + +// ProviderAccountChange is an atomic before/after patch for one curated account. +// Provider entries remain a derived compatibility projection. +type ProviderAccountChange struct { + FamilyID string + AccountID string + Before *ProviderAccount + After *ProviderAccount + BeforeDefaultModel string + AfterDefaultModel string + BeforeProviderAccess []string + AfterProviderAccess []string + syncDefaultModel bool +} + +// ApplyProviderAccountChange validates and applies an account patch, then +// materializes its provider routes. It is intentionally side-effect free on +// validation or reconcile failure. +func (c *Config) ApplyProviderAccountChange(change ProviderAccountChange) (err error) { + if c == nil { + return fmt.Errorf("apply provider account change: nil config") + } + accountsBefore := cloneProviderAccounts(c.ProviderAccounts) + providersBefore := cloneProviderEntries(c.Providers) + sourcesBefore := maps.Clone(c.providerSources) + accessBefore := append([]string(nil), c.Desktop.ProviderAccess...) + defaultBefore := c.DefaultModel + committed := false + defer func() { + if committed || err == nil { + return + } + c.ProviderAccounts = accountsBefore + c.Providers = providersBefore + c.providerSources = sourcesBefore + c.Desktop.ProviderAccess = accessBefore + c.DefaultModel = defaultBefore + }() + familyID, accountID, err := providerAccountChangeIdentity(change) + if err != nil { + return err + } + if err := c.applyProviderAccountChangePatch(change, familyID, accountID); err != nil { + return err + } + committed = true + return nil +} + +func providerAccountChangeIdentity(change ProviderAccountChange) (string, string, error) { + familyID := strings.TrimSpace(change.FamilyID) + accountID := strings.TrimSpace(change.AccountID) + if change.After != nil { + if familyID == "" { + familyID = strings.TrimSpace(change.After.ProviderID) + } + if accountID == "" { + accountID = strings.TrimSpace(change.After.ID) + } + } + if familyID == "" || accountID == "" { + return "", "", fmt.Errorf("apply provider account change: family and account are required") + } + if _, ok := curatedFamilyByID(familyID); !ok { + return "", "", fmt.Errorf("apply provider account change: provider family %q is not curated", familyID) + } + return familyID, accountID, nil +} + +func (c *Config) applyProviderAccountChangePatch(change ProviderAccountChange, familyID, accountID string) error { + idx, current, exists := c.lookupProviderAccount(familyID, accountID) + if change.Before != nil && (!exists || !reflect.DeepEqual(current, *change.Before)) { + return fmt.Errorf("provider account %s/%s changed concurrently", familyID, accountID) + } + if change.Before == nil && exists { + return fmt.Errorf("provider account %s/%s already exists", familyID, accountID) + } + if change.After == nil && !exists { + return fmt.Errorf("provider account %s/%s does not exist", familyID, accountID) + } + if err := c.applyProviderAccountValue(change.After, familyID, accountID, idx, exists); err != nil { + return err + } + if change.After == nil { + c.ProviderAccounts = append(c.ProviderAccounts[:idx], c.ProviderAccounts[idx+1:]...) + return nil + } + if change.AfterDefaultModel != "" { + if change.BeforeDefaultModel != "" && c.DefaultModel != change.BeforeDefaultModel { + return fmt.Errorf("default_model changed concurrently") + } + if err := c.SetDefaultModel(strings.TrimSpace(change.AfterDefaultModel)); err != nil { + return err + } + } + if change.AfterProviderAccess != nil { + if change.BeforeProviderAccess != nil && !reflect.DeepEqual(c.Desktop.ProviderAccess, change.BeforeProviderAccess) { + return fmt.Errorf("desktop.provider_access changed concurrently") + } + c.Desktop.ProviderAccess = append([]string(nil), change.AfterProviderAccess...) + } + if change.syncDefaultModel { + if change.After.IsEnabled() && change.After.Default { + c.syncFamilyDefaultModel(familyID, accountID) + } else if replacement, ok := c.DefaultAccount(familyID); ok { + c.syncFamilyDefaultModel(familyID, replacement.ID) + } + } + if _, _, err := ReconcileProviderAccounts(c); err != nil { + return err + } + return nil +} + +func (c *Config) applyProviderAccountValue(value *ProviderAccount, familyID, accountID string, idx int, exists bool) error { + if value == nil { + return nil + } + after := cloneProviderAccount(*value) + after.ProviderID, after.ID = familyID, accountID + if err := validateProviderAccount(after); err != nil { + return err + } + if !after.IsEnabled() && after.Default { + return fmt.Errorf("provider account %s/%s cannot be default while disabled", familyID, accountID) + } + if exists { + c.ProviderAccounts[idx] = after + } else { + c.ProviderAccounts = append(c.ProviderAccounts, after) + idx = len(c.ProviderAccounts) - 1 + } + if after.Default { + for i := range c.ProviderAccounts { + if i != idx && c.ProviderAccounts[i].ProviderID == familyID { + c.ProviderAccounts[i].Default = false + } + } + } + return nil +} + +func curatedFamilyByID(id string) (ProviderFamilyDefinition, bool) { + for _, family := range CuratedProviderFamilies() { + if family.ID == strings.TrimSpace(id) { + return family, true + } + } + return ProviderFamilyDefinition{}, false +} diff --git a/internal/config/provider_account_expand.go b/internal/config/provider_account_expand.go new file mode 100644 index 0000000000..d39058870b --- /dev/null +++ b/internal/config/provider_account_expand.go @@ -0,0 +1,329 @@ +package config + +import ( + "fmt" + "sort" + "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 providerAccountRouteDisabled(account, tmpl.RouteID) { + continue + } + 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 +} + +// MaterializeProviderAccount is the stable account-to-runtime projection used +// by new callers. ExpandProviderAccount remains as a compatibility alias. +func MaterializeProviderAccount(c *Config, account ProviderAccount) ([]ProviderEntry, error) { + return ExpandProviderAccount(c, account) +} + +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 := MaterializeProviderAccount(c, account) + if expandErr != nil { + warnings = append(warnings, expandErr.Error()) + continue + } + 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 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 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) + account.DisabledRoutes = normalizeProviderAccountRoutes(account.DisabledRoutes) + 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 +} + +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 new file mode 100644 index 0000000000..146a8f1dbc --- /dev/null +++ b/internal/config/provider_account_group.go @@ -0,0 +1,229 @@ +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 + } + } + } + return "", "", "", false +} + +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..11ec08af27 --- /dev/null +++ b/internal/config/provider_account_migrate.go @@ -0,0 +1,181 @@ +package config + +import "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, + } + // Preserve sparse legacy route declarations; restore can opt into the full 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) + } + 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 + } + 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 { + 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 new file mode 100644 index 0000000000..9e7e2a350a --- /dev/null +++ b/internal/config/provider_account_mutate.go @@ -0,0 +1,437 @@ +package config + +import ( + "fmt" + "slices" + "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 len(accountRouteTemplates(providerID)) == 0 { + return ProviderAccount{}, fmt.Errorf("unknown provider family %q", providerID) + } + 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 + } + change := ProviderAccountChange{FamilyID: providerID, AccountID: account.ID, After: &account} + if err := c.ApplyProviderAccountChange(change); err != nil { + return ProviderAccount{}, err + } + return account, nil +} + +func (c *Config) SetProviderAccountDefault(providerID, accountID string) error { + _, 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) + } + after := cloneProviderAccount(account) + after.Default = true + return c.ApplyProviderAccountChange(ProviderAccountChange{FamilyID: account.ProviderID, AccountID: account.ID, Before: &account, After: &after, syncDefaultModel: true}) +} + +func (c *Config) SetProviderAccountEnabled(providerID, accountID string, enabled bool) error { + _, 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) + } + after := cloneProviderAccount(account) + after.Enabled = boolPointer(enabled) + if !enabled { + after.Default = false + } + return c.ApplyProviderAccountChange(ProviderAccountChange{FamilyID: account.ProviderID, AccountID: account.ID, Before: &account, After: &after, syncDefaultModel: true}) +} + +// SetProviderAccountRouteEnabled toggles a generated route for new selection; +// retained entries keep old sessions resolvable. +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) + oldDisabled := append([]string(nil), disabled...) + 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 { + c.ProviderAccounts[idx].DisabledRoutes = oldDisabled + return err + } + if enabled && indexAccountRouteEntry(c, account, routeID) < 0 { + // Explicitly enabling an optional route opts into its curated preset route. + 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 { + c.restoreProviderAccountRouteAccess(account.ProviderID, account.ID, routeID) + } + 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 := MaterializeProviderAccount(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. +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 := MaterializeProviderAccount(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 + } + 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 + present = slices.Contains(c.Desktop.ProviderAccess, entry.Name) + if present { + continue + } + c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, entry.Name) + break + } +} + +func (c *Config) RenameProviderAccount(providerID, accountID, label string) error { + _, 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") + } + after := cloneProviderAccount(account) + after.Label = label + return c.ApplyProviderAccountChange(ProviderAccountChange{FamilyID: account.ProviderID, AccountID: account.ID, Before: &account, After: &after}) +} + +func (c *Config) RetireProviderAccount(providerID, accountID string) error { + _, 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, ", ")) + } + after := cloneProviderAccount(account) + after.Retired = true + after.Enabled = boolPointer(false) + after.Default = false + for _, tmpl := range accountRouteTemplates(account.ProviderID) { + after.DisabledRoutes = append(after.DisabledRoutes, tmpl.RouteID) + } + after.DisabledRoutes = normalizeProviderAccountRoutes(after.DisabledRoutes) + return c.ApplyProviderAccountChange(ProviderAccountChange{FamilyID: account.ProviderID, AccountID: account.ID, Before: &account, After: &after, syncDefaultModel: true}) +} + +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) 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, _, _, ok = curatedProviderIdentity(*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 { + 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..e5b00d7fb3 --- /dev/null +++ b/internal/config/provider_account_render.go @@ -0,0 +1,170 @@ +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") + } + if routes := normalizeProviderAccountRoutes(a.DisabledRoutes); len(routes) > 0 { + fmt.Fprintf(b, "disabled_routes = %s\n", renderStringArray(routes)) + } + 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..deb63578ca --- /dev/null +++ b/internal/config/provider_account_resolve.go @@ -0,0 +1,251 @@ +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.IsEnabled() { + 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 + } + // New curated references use family/account/model. Resolve them through the + // account selection layer before consulting compatibility provider names. + if strings.Count(strings.TrimSpace(ref), "/") >= 2 { + if selection, err := ParseProviderSelection(c, ref); err == nil { + if entry, resolveErr := c.ResolveSelection(selection); resolveErr == nil { + return entry, true + } + } + } + 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) && c.accountSelectable(*entry) && 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..d87381109c --- /dev/null +++ b/internal/config/provider_account_test.go @@ -0,0 +1,515 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "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 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 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.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) + } + 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") + 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) + } + _ = changed // first reconcile may stamp metadata; the second must be stable. + 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) + } +} + +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) + } +} + +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") + } +} 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/provider_selection.go b/internal/config/provider_selection.go new file mode 100644 index 0000000000..f67262c22a --- /dev/null +++ b/internal/config/provider_selection.go @@ -0,0 +1,401 @@ +package config + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// ProviderRouteDefinition describes one protocol route in a curated family. +// Models is derived from the preset and is used only for deterministic route +// selection; secrets and mutable provider fields remain in ProviderEntry. +type ProviderRouteDefinition struct { + ID string + PresetID string + Kind string + DisplayOrder int + Models []string +} + +// ProviderFamilyDefinition is the user-facing grouping of curated presets. +type ProviderFamilyDefinition struct { + ID string + PresetIDs []string + RecommendedPresetID string + Routes []ProviderRouteDefinition +} + +// ProviderSelection is the stable family/account/model identity used by new +// callers. ProviderEntry names remain a compatibility projection. +type ProviderSelection struct { + FamilyID string + AccountID string + Model string +} + +// SelectionForProviderModel projects a materialized provider entry into the +// canonical family/account/model identity. It returns false for ordinary +// custom providers, which continue using their provider/model reference. +func (c *Config) SelectionForProviderModel(entry ProviderEntry, model string) (ProviderSelection, bool) { + model = strings.TrimSpace(model) + if c == nil || model == "" { + return ProviderSelection{}, false + } + if family, account, ok := ProviderAccountIdentity(entry); ok { + if _, _, found := c.lookupProviderAccount(family, account); found { + return ProviderSelection{FamilyID: family, AccountID: account, Model: model}, true + } + } + if family, _, _, ok := curatedProviderIdentity(entry); ok { + if _, _, found := c.lookupProviderAccount(family, MainProviderAccountID); found { + return ProviderSelection{FamilyID: family, AccountID: MainProviderAccountID, Model: model}, true + } + } + return ProviderSelection{}, false +} + +func (s ProviderSelection) Ref() string { + return strings.TrimSpace(s.FamilyID) + "/" + strings.TrimSpace(s.AccountID) + "/" + strings.TrimSpace(s.Model) +} + +// CuratedProviderFamilies derives deterministic family metadata from the +// curated preset registry. No provider name or endpoint is used as identity. +func CuratedProviderFamilies() []ProviderFamilyDefinition { + byID := map[string]*ProviderFamilyDefinition{} + for _, preset := range CuratedProviderPresets() { + familyID := preset.resolvedAccountGroupID() + if familyID == "" { + continue + } + family := byID[familyID] + if family == nil { + family = &ProviderFamilyDefinition{ID: familyID} + byID[familyID] = family + } + family.PresetIDs = appendUniqueString(family.PresetIDs, preset.ID) + if preset.Recommended && preferredPreset(preset.ID, family.RecommendedPresetID) { + family.RecommendedPresetID = preset.ID + } + for _, entry := range preset.Entries { + routeID := strings.TrimSpace(entry.Name) + if routeID == "" { + continue + } + idx := -1 + for i := range family.Routes { + if family.Routes[i].ID == routeID { + idx = i + break + } + } + if idx < 0 { + family.Routes = append(family.Routes, ProviderRouteDefinition{ + ID: routeID, PresetID: preset.ID, Kind: strings.TrimSpace(entry.Kind), + DisplayOrder: preset.DisplayOrder, Models: append([]string(nil), entry.ModelList()...), + }) + continue + } + family.Routes[idx].Models = mergeSelectionModels(family.Routes[idx].Models, entry.ModelList()) + if preset.DisplayOrder < family.Routes[idx].DisplayOrder { + family.Routes[idx].DisplayOrder = preset.DisplayOrder + family.Routes[idx].PresetID = preset.ID + } + } + } + families := make([]ProviderFamilyDefinition, 0, len(byID)) + for _, family := range byID { + // Include migrated DeepSeek default routes in the family catalog. + for _, template := range accountRouteTemplates(family.ID) { + found := false + for _, route := range family.Routes { + if route.ID == template.RouteID { + found = true + break + } + } + if !found { + family.Routes = append(family.Routes, ProviderRouteDefinition{ + ID: template.RouteID, PresetID: template.Entry.PresetID, + Kind: strings.TrimSpace(template.Entry.Kind), Models: append([]string(nil), template.Entry.ModelList()...), + }) + } + } + if family.RecommendedPresetID == "" { + for _, presetID := range family.PresetIDs { + if preferredPreset(presetID, family.RecommendedPresetID) { + family.RecommendedPresetID = presetID + } + } + } + sort.Strings(family.PresetIDs) + sort.SliceStable(family.Routes, func(i, j int) bool { + if family.Routes[i].DisplayOrder != family.Routes[j].DisplayOrder { + return family.Routes[i].DisplayOrder < family.Routes[j].DisplayOrder + } + return family.Routes[i].ID < family.Routes[j].ID + }) + families = append(families, *family) + } + sort.Slice(families, func(i, j int) bool { return families[i].ID < families[j].ID }) + return families +} + +func presetRank(p ProviderPreset) int { + if p.Recommended { + return -1 + } + return p.DisplayOrder +} + +func preferredPreset(candidate, current string) bool { + if current == "" { + return true + } + candidateRank, currentRank := presetRankByID(candidate), presetRankByID(current) + return candidateRank < currentRank || candidateRank == currentRank && candidate < current +} + +func presetRankByID(id string) int { + if preset, ok := CuratedProviderPreset(id); ok { + return presetRank(preset) + } + return int(^uint(0) >> 1) +} + +func appendUniqueString(values []string, value string) []string { + if slices.Contains(values, value) { + return values + } + return append(values, value) +} + +func mergeSelectionModels(primary, extra []string) []string { + seen := make(map[string]bool, len(primary)+len(extra)) + out := make([]string, 0, len(primary)+len(extra)) + for _, list := range [][]string{primary, extra} { + for _, model := range list { + model = strings.TrimSpace(model) + if model == "" || seen[model] { + continue + } + seen[model] = true + out = append(out, model) + } + } + return out +} + +func ParseProviderSelection(c *Config, ref string) (ProviderSelection, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return ProviderSelection{}, fmt.Errorf("provider selection is empty") + } + parts := strings.SplitN(ref, "/", 3) + if len(parts) == 3 && IsProviderAccountID(strings.TrimSpace(parts[1])) { + familyID, accountID, model := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]) + if model != "" && isCuratedFamilyID(c, familyID) { + return ProviderSelection{FamilyID: familyID, AccountID: accountID, Model: model}, nil + } + } + provider, model, hasModel := strings.Cut(ref, "/") + provider = strings.TrimSpace(provider) + model = strings.TrimSpace(model) + if !hasModel || provider == "" || model == "" { + return ProviderSelection{}, fmt.Errorf("provider selection %q must include provider and model", ref) + } + if strings.Contains(provider, "/") { + return ProviderSelection{}, fmt.Errorf("provider selection %q has an invalid provider", ref) + } + if c != nil { + if entry, ok := c.Provider(provider); ok { + if family, account, identityOK := ProviderAccountIdentity(*entry); identityOK { + return ProviderSelection{FamilyID: family, AccountID: account, Model: model}, nil + } + if family, route, _, identityOK := curatedProviderIdentity(*entry); identityOK { + _ = route + return ProviderSelection{FamilyID: family, AccountID: MainProviderAccountID, Model: model}, nil + } + } + } + if family, accountID, ok := splitGeneratedProviderName(provider); ok { + return ProviderSelection{FamilyID: family, AccountID: accountID, Model: model}, nil + } + if c != nil { + if _, ok := c.DefaultAccount(provider); ok { + return ProviderSelection{FamilyID: provider, AccountID: MainProviderAccountID, Model: model}, nil + } + } + if family, route, _, ok := knownProviderIdentity(provider); ok { + _ = route + return ProviderSelection{FamilyID: family, AccountID: MainProviderAccountID, Model: model}, nil + } + return ProviderSelection{}, fmt.Errorf("provider %q is not a curated provider family", provider) +} + +func isCuratedFamilyID(c *Config, familyID string) bool { + for _, family := range CuratedProviderFamilies() { + if family.ID == familyID { + return true + } + } + if c != nil { + _, ok := c.DefaultAccount(familyID) + return ok + } + return false +} + +func splitGeneratedProviderName(provider string) (family, accountID string, ok bool) { + base, account, found := strings.Cut(strings.TrimSpace(provider), "--") + if !found || base == "" || account == "" || !IsProviderAccountID(account) { + return "", "", false + } + if family, _, _, known := knownProviderIdentity(base); known { + return family, account, true + } + for _, familyDef := range CuratedProviderFamilies() { + for _, route := range familyDef.Routes { + if route.ID == base { + return familyDef.ID, account, true + } + } + } + return "", "", false +} + +func (c *Config) ResolveSelection(selection ProviderSelection) (*ProviderEntry, error) { + if c == nil { + return nil, fmt.Errorf("resolve provider selection: nil config") + } + selection.FamilyID = strings.TrimSpace(selection.FamilyID) + selection.AccountID = strings.TrimSpace(selection.AccountID) + selection.Model = strings.TrimSpace(selection.Model) + if selection.FamilyID == "" || selection.AccountID == "" || selection.Model == "" { + return nil, fmt.Errorf("provider selection requires family, account, and model") + } + _, account, ok := c.lookupProviderAccount(selection.FamilyID, selection.AccountID) + if !ok { + return nil, fmt.Errorf("provider account %s/%s not found", selection.FamilyID, selection.AccountID) + } + if !account.IsEnabled() { + return nil, fmt.Errorf("provider account %s/%s is unavailable", selection.FamilyID, selection.AccountID) + } + route, err := c.RouteForSelection(selection) + if err != nil { + return nil, err + } + for _, entry := range c.Providers { + if entry.AccountProviderID == selection.FamilyID && entry.AccountID == selection.AccountID && entry.AccountRouteID == route.ID && entry.HasModel(selection.Model) { + return copyResolvedEntry(&entry, selection.Model), nil + } + } + return nil, fmt.Errorf("model %q is not available on %s/%s", selection.Model, selection.FamilyID, selection.AccountID) +} + +func (c *Config) ResolveSelectionRef(ref string) (*ProviderEntry, ProviderSelection, error) { + selection, err := ParseProviderSelection(c, ref) + if err != nil { + return nil, ProviderSelection{}, err + } + entry, err := c.ResolveSelection(selection) + return entry, selection, err +} + +func (c *Config) RouteForSelection(selection ProviderSelection) (ProviderRouteDefinition, error) { + if c == nil { + return ProviderRouteDefinition{}, fmt.Errorf("resolve provider route: nil config") + } + families := CuratedProviderFamilies() + var family *ProviderFamilyDefinition + for i := range families { + if families[i].ID == strings.TrimSpace(selection.FamilyID) { + family = &families[i] + break + } + } + if family == nil { + return ProviderRouteDefinition{}, fmt.Errorf("provider family %q is not curated", selection.FamilyID) + } + _, account, ok := c.lookupProviderAccount(selection.FamilyID, selection.AccountID) + if !ok { + return ProviderRouteDefinition{}, fmt.Errorf("provider account %s/%s not found", selection.FamilyID, selection.AccountID) + } + disabledRoutes := make([]string, 0, len(account.DisabledRoutes)) + for _, route := range family.Routes { + if providerAccountRouteDisabled(account, route.ID) { + disabledRoutes = append(disabledRoutes, route.ID) + continue + } + for _, entry := range c.Providers { + if entry.AccountProviderID == account.ProviderID && entry.AccountID == account.ID && entry.AccountRouteID == route.ID && entry.HasModel(selection.Model) { + return route, nil + } + } + } + if len(disabledRoutes) > 0 { + return ProviderRouteDefinition{}, fmt.Errorf("model %q has no enabled route for %s/%s (disabled routes: %s)", selection.Model, selection.FamilyID, selection.AccountID, strings.Join(disabledRoutes, ", ")) + } + return ProviderRouteDefinition{}, fmt.Errorf("model %q has no enabled route for %s/%s", selection.Model, selection.FamilyID, selection.AccountID) +} + +func (c *Config) DefaultSelection(familyID string) (ProviderSelection, bool) { + if c == nil { + return ProviderSelection{}, false + } + account, ok := c.DefaultAccount(strings.TrimSpace(familyID)) + if !ok { + return ProviderSelection{}, false + } + entries, ok := c.ResolveAccountProvider(account.ProviderID, account.ID) + if !ok { + return ProviderSelection{}, false + } + families := CuratedProviderFamilies() + var family *ProviderFamilyDefinition + for i := range families { + if families[i].ID == account.ProviderID { + family = &families[i] + break + } + } + if family == nil { + return ProviderSelection{}, false + } + for _, route := range family.Routes { + if providerAccountRouteDisabled(account, route.ID) { + continue + } + for _, entry := range entries { + if entry.AccountRouteID != route.ID || len(entry.ChatModelList()) == 0 || !entry.Configured() { + continue + } + model := entry.DefaultModel() + if model == "" { + model = entry.ChatModelList()[0] + } + return ProviderSelection{FamilyID: account.ProviderID, AccountID: account.ID, Model: model}, true + } + } + return ProviderSelection{}, false +} + +func (c *Config) ResolveNewSessionSelection() (ProviderSelection, bool) { + if c == nil { + return ProviderSelection{}, false + } + if ref, _, ok := c.ResolveNewSessionChatModel(); ok { + if selection, err := ParseProviderSelection(c, ref); err == nil { + return selection, true + } + // A valid custom provider/model remains outside the curated selection + // schema; do not silently replace it with the first curated family. + return ProviderSelection{}, false + } + for _, family := range CuratedProviderFamilies() { + if selection, ok := c.DefaultSelection(family.ID); ok { + return selection, true + } + } + return ProviderSelection{}, false +} diff --git a/internal/config/provider_selection_test.go b/internal/config/provider_selection_test.go new file mode 100644 index 0000000000..2f69bede79 --- /dev/null +++ b/internal/config/provider_selection_test.go @@ -0,0 +1,141 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestProviderFamilyDefinitionsAreDeterministic(t *testing.T) { + families := CuratedProviderFamilies() + if len(families) == 0 { + t.Fatal("expected curated provider families") + } + seen := map[string]bool{} + for _, family := range families { + if family.ID == "" || seen[family.ID] { + t.Fatalf("invalid or duplicate family: %+v", family) + } + seen[family.ID] = true + if family.RecommendedPresetID == "" || len(family.Routes) == 0 { + t.Fatalf("family %q missing recommendation/routes: %+v", family.ID, family) + } + for i := 1; i < len(family.Routes); i++ { + if family.Routes[i-1].DisplayOrder > family.Routes[i].DisplayOrder { + t.Fatalf("family %q routes not ordered: %+v", family.ID, family.Routes) + } + } + } +} + +func TestProviderSelectionParsesCanonicalAndLegacyRefs(t *testing.T) { + cfg := Default() + if _, err := cfg.AddProviderAccount("deepseek", "", "Team", "DEEPSEEK_TEAM_KEY"); err != nil { + t.Fatal(err) + } + tests := []struct { + ref string + want ProviderSelection + }{ + {"deepseek/team/deepseek-v4-flash", ProviderSelection{FamilyID: "deepseek", AccountID: "team", Model: "deepseek-v4-flash"}}, + {"deepseek--team/deepseek-v4-flash", ProviderSelection{FamilyID: "deepseek", AccountID: "team", Model: "deepseek-v4-flash"}}, + {"deepseek-flash/deepseek-v4-flash", ProviderSelection{FamilyID: "deepseek", AccountID: "main", Model: "deepseek-v4-flash"}}, + } + for _, tc := range tests { + got, err := ParseProviderSelection(cfg, tc.ref) + if err != nil { + t.Fatalf("ParseProviderSelection(%q): %v", tc.ref, err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ParseProviderSelection(%q) = %+v, want %+v", tc.ref, got, tc.want) + } + } +} + +func TestProviderSelectionResolvesFamilyAccountModel(t *testing.T) { + cfg := Default() + account, err := cfg.AddProviderAccount("opencode-go", "opencode-go-recommended", "Team", "OPENCODE_TEAM_KEY") + if err != nil { + t.Fatal(err) + } + selection := ProviderSelection{FamilyID: account.ProviderID, AccountID: account.ID, Model: "grok-4.5"} + entry, err := cfg.ResolveSelection(selection) + if err != nil { + t.Fatal(err) + } + if entry.AccountProviderID != account.ProviderID || entry.AccountID != account.ID || entry.Model != selection.Model { + t.Fatalf("resolved entry = %+v", entry) + } + route, err := cfg.RouteForSelection(selection) + if err != nil { + t.Fatal(err) + } + if route.ID == "" || route.Kind == "" { + t.Fatalf("invalid route = %+v", route) + } +} + +func TestProviderSelectionKeepsModelPathAndRejectsCustomFamily(t *testing.T) { + cfg := Default() + if _, err := cfg.AddProviderAccount("deepseek", "", "Team", "DEEPSEEK_TEAM_KEY"); err != nil { + t.Fatal(err) + } + selection, err := ParseProviderSelection(cfg, "deepseek/team/vendor/model-v1") + if err != nil { + t.Fatal(err) + } + if selection.Model != "vendor/model-v1" { + t.Fatalf("model path = %q, want vendor/model-v1", selection.Model) + } + custom := &Config{Providers: []ProviderEntry{{Name: "my-gateway", Kind: "openai", Model: "model"}}} + if _, err := ParseProviderSelection(custom, "my-gateway/model"); err == nil { + t.Fatal("custom provider unexpectedly parsed as curated selection") + } +} + +func TestProviderSelectionSkipsDisabledRoute(t *testing.T) { + cfg := Default() + 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) + } + selection := ProviderSelection{FamilyID: account.ProviderID, AccountID: account.ID, Model: "grok-4.5"} + if _, err := cfg.ResolveSelection(selection); err == nil { + t.Fatal("disabled route unexpectedly resolved") + } +} + +func TestApplyProviderAccountChangeRollsBackOnInvalidPatch(t *testing.T) { + cfg := Default() + account, err := cfg.AddProviderAccount("deepseek", "", "Team", "DEEPSEEK_TEAM_KEY") + if err != nil { + t.Fatal(err) + } + before := cloneProviderAccounts(cfg.ProviderAccounts) + bad := account + bad.Label = "" + if err := cfg.ApplyProviderAccountChange(ProviderAccountChange{ + FamilyID: account.ProviderID, AccountID: account.ID, Before: &account, After: &bad, + }); err == nil { + t.Fatal("invalid account patch unexpectedly succeeded") + } + if !reflect.DeepEqual(cfg.ProviderAccounts, before) { + t.Fatalf("account patch was not rolled back: before=%+v after=%+v", before, cfg.ProviderAccounts) + } +} + +func TestSetDefaultModelAcceptsCanonicalSelection(t *testing.T) { + cfg := Default() + if _, err := cfg.AddProviderAccount("deepseek", "", "Team", "DEEPSEEK_TEAM_KEY"); err != nil { + t.Fatal(err) + } + ref := "deepseek/team/deepseek-v4-flash" + if err := cfg.SetDefaultModel(ref); err != nil { + t.Fatalf("SetDefaultModel(%q): %v", ref, err) + } + if cfg.DefaultModel != ref { + t.Fatalf("default model = %q, want %q", cfg.DefaultModel, ref) + } +} 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.