From 5cd413ffcf73348ec922aee7faf80613e8c700d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 03:55:55 -0400 Subject: [PATCH] Q168: a connected provider's default model changes without its credential Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/platform.ts | 1 + src/main/app/ipc-handlers.ts | 2 + src/main/services/provider-default-model.ts | 17 ++++++++ src/preload/index.ts | 1 + src/renderer/src/i18n/zh.ts | 2 + .../settings/ProviderConnectionDialog.tsx | 2 +- .../src/pages/settings/ProvidersPanel.tsx | 41 ++++++++++++++++++- src/shared/commands/provider.ts | 6 +++ src/shared/desktop-api.ts | 2 + src/shared/ipc.ts | 1 + tests/e2e/desktop.spec.ts | 17 ++++++++ tests/unit/provider-default-model.test.ts | 40 ++++++++++++++++++ 12 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/main/services/provider-default-model.ts create mode 100644 tests/unit/provider-default-model.test.ts diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index c9ce3f6e..17d4daec 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -17,6 +17,7 @@ export const platformContracts = { setIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES), value: z.string() }) satisfies z.ZodType, clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType, saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType, + setProviderModel: z.strictObject({ orgId: id, providerId: id, model: z.string().max(200) }) satisfies z.ZodType, revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType, startProviderOAuth: z.strictObject({ providerKey: z.string(), enterpriseDomain: z.string().nullable().optional(), orgId: id.optional(), providerId: id.optional(), diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 40961d96..0b7b1080 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -40,6 +40,7 @@ import { stopAllRuns, stopRun } from "../services/run-control"; import { cancelQueuedRun } from "../services/run-cancel"; import { runHistory } from "../services/run-history"; import { deleteWorkspace, setPrimaryWorkspace, updateWorkspace } from "../services/workspace-admin"; +import { setProviderModel } from "../services/provider-default-model"; export interface IpcDeps { connection: DatabaseConnection; @@ -225,6 +226,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("deleteMessage", (input) => mutate(() => chat.deleteMessage(input))); handle("markChatSpaceRead", (input) => mutate(() => chat.markSpaceRead(input))); handle("saveProviderCredential", (input) => mutate(() => providers.saveCredential(input))); + handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input))); handle("revokeProviderCredential", async (input) => { try { await providers.revokeCredential(input); diff --git a/src/main/services/provider-default-model.ts b/src/main/services/provider-default-model.ts new file mode 100644 index 00000000..5b0d9cb3 --- /dev/null +++ b/src/main/services/provider-default-model.ts @@ -0,0 +1,17 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { SetProviderModelInput } from "../../shared/commands"; +import { AuditService } from "./audit-service"; + +/** Changes a connected provider's default model without touching its credential; sessions already started keep their pinned model. */ +export function setProviderModel(db: DatabaseSync, input: SetProviderModelInput): void { + const provider = db.prepare("select provider_key, credential_id, model from providers where id = ? and org_id = ?") + .get(input.providerId, input.orgId) as { provider_key: string; credential_id: string | null; model: string | null } | undefined; + if (!provider) throw new Error("Provider was not found."); + const model = input.model.trim(); + if (!model) throw new Error("Name the default model."); + if (!provider.credential_id) throw new Error("Connect this provider first; its default model is set when it connects."); + if (provider.provider_key === "azure-openai") throw new Error("Azure OpenAI calls the deployment saved with its credential; change the deployment under Manage."); + db.prepare("update providers set model = ? where id = ? and org_id = ?").run(model, input.providerId, input.orgId); + new AuditService(db).record({ orgId: input.orgId, entityType: "provider", entityId: input.providerId, actorType: "employee", actorId: null, action: "default_model_changed", payload: { from: provider.model, to: model } }); + console.info(`[providers] ${provider.provider_key} default model: ${provider.model ?? "none"} -> ${model}`); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 05081d85..6c7c6a17 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,7 @@ const api: DesktopApi = { deleteMessage: (input) => invoke(IPC_CHANNELS.deleteMessage, input), markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), + setProviderModel: (input) => invoke(IPC_CHANNELS.setProviderModel, input), revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input), cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 30d50660..fc8fa49a 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -678,6 +678,8 @@ export const zh: Record = { "{passed}/{total} passed": "{passed}/{total} 通过", "{pending} pending · {approved} approved this week": "{pending} 待处理 · 本周已批准 {approved}", "{percent}% of cap": "上限的 {percent}%", + "Default model for {label}": "{label} 的默认模型", + "Change model": "更换模型", "Leave the model blank to use the provider's default. A changed model applies from the agent's next session.": "模型留空即使用 Provider 的默认模型。修改后的模型从该 Agent 的下一次会话起生效。", "{model} (provider default)": "{model}(Provider 默认)", "Chosen when the session starts": "会话开始时确定", diff --git a/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx b/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx index 4a7fa34d..0fb244e8 100644 --- a/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx +++ b/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx @@ -94,7 +94,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr ); } -function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) { +export function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) { const [fetched, setFetched] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index c6b85a92..de10e08f 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -13,7 +13,7 @@ import type { } from "../../../../shared/domain"; import { PROVIDER_DATA_POLICY } from "../../../../shared/domain"; import { ProviderLogo } from "../../shared/ProviderLogo"; -import { ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog"; +import { ModelPicker, ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog"; import { composeSecret } from "./provider-secret"; import type { SnapshotActions } from "../../app/useSnapshot"; @@ -35,6 +35,10 @@ type WorkspaceScope = "organization" | "workspace"; export function ProvidersPanel({ orgId, companyName, employeeId, providers, authProfiles, catalog, agents, providerRateLimits, actions }: ProvidersPanelProps) { const [active, setActive] = useState(null); + const [modelFor, setModelFor] = useState(null); + const [modelError, setModelError] = useState(null); + const openModel = (provider: ProviderRecord | null) => { setModelError(null); setModelFor(provider); }; + const modelForEntry = modelFor ? requireCatalogEntry(catalog, modelFor.providerKey) : null; const connected = providers.filter((provider) => provider.enabled); const available = providers.filter((provider) => !provider.enabled); const activeCatalogEntry = active ? catalog.find((entry) => entry.key === active.providerKey) ?? null : null; @@ -58,6 +62,16 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth })).then((result) => { if (result) close(); }); }; + const saveModel = (event: FormEvent) => { + event.preventDefault(); + if (!modelFor) return; + const model = String(new FormData(event.currentTarget).get("model") ?? ""); + setModelError(null); + void actions.run(() => window.agentCompany.setProviderModel({ orgId, providerId: modelFor.id, model })) + .then((result) => { if (result) setModelFor(null); }) + .catch((reason: unknown) => setModelError(reason instanceof Error ? reason.message : String(reason))); + }; + return (

{t("Providers")}

@@ -75,6 +89,7 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth agents={agents} onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} + onChangeModel={provider.providerKey === "azure-openai" ? null : () => openModel(provider)} onRevoke={() => void actions.run(() => window.agentCompany.revokeProviderCredential({ orgId, providerId: provider.id }))} revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely} /> @@ -128,6 +143,26 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth

)} + + {/* Only the model changes here: the credential and its sign-in stay as they are. */} + openModel(null)} + onSubmit={saveModel} + > + {modelFor && modelForEntry && ( + <> + + {modelError &&
{modelError}
} +

+ {t("The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.", { following: defaultModelReach(agents, modelFor.id).following, own: defaultModelReach(agents, modelFor.id).ownModel })} +

+ + )} +
); } @@ -326,6 +361,7 @@ function ProviderRow({ agents, onCheck, onManage, + onChangeModel, onRevoke, revokesRemotely, }: { @@ -334,6 +370,8 @@ function ProviderRow({ agents: AgentRecord[]; onCheck: () => void; onManage: () => void; + /** Null where the model lives in the credential (Azure's deployment). */ + onChangeModel: (() => void) | null; onRevoke: () => void; revokesRemotely: boolean; }) { @@ -364,6 +402,7 @@ function ProviderRow({ + {onChangeModel && }