Q168: a connected provider's default model changes without its credential
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26c3365896
commit
5cd413ffcf
12 files changed
+130
-2
No files matched your search
@@ -17,6 +17,7 @@ export const platformContracts = {
|
||||
setIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES), value: z.string() }) satisfies z.ZodType<C.SetIntegrationKeyInput>,
|
||||
clearIntegrationKey: z.strictObject({ name: z.enum(INTEGRATION_KEY_NAMES) }) satisfies z.ZodType<C.ClearIntegrationKeyInput>,
|
||||
saveProviderCredential: z.strictObject({ orgId: id, providerId: id, secret: z.string(), model: z.string().nullable() }) satisfies z.ZodType<C.SaveProviderCredentialInput>,
|
||||
setProviderModel: z.strictObject({ orgId: id, providerId: id, model: z.string().max(200) }) satisfies z.ZodType<C.SetProviderModelInput>,
|
||||
revokeProviderCredential: z.strictObject({ orgId: id, providerId: id }) satisfies z.ZodType<C.RevokeProviderCredentialInput>,
|
||||
startProviderOAuth: z.strictObject({
|
||||
providerKey: z.string(), enterpriseDomain: z.string().nullable().optional(), orgId: id.optional(), providerId: id.optional(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -678,6 +678,8 @@ export const zh: Record<string, string> = {
|
||||
"{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": "会话开始时确定",
|
||||
|
||||
@@ -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<ProviderCatalogEntry["models"] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -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<ProviderRecord | null>(null);
|
||||
const [modelFor, setModelFor] = useState<ProviderRecord | null>(null);
|
||||
const [modelError, setModelError] = useState<string | null>(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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">{t("Providers")}</h1>
|
||||
@@ -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
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* Only the model changes here: the credential and its sign-in stay as they are. */}
|
||||
<Dialog
|
||||
key={modelFor?.id ?? "none"}
|
||||
title={modelFor ? t("Default model for {label}", { label: modelFor.label }) : ""}
|
||||
open={Boolean(modelFor)}
|
||||
submitLabel={t("Save")}
|
||||
onClose={() => openModel(null)}
|
||||
onSubmit={saveModel}
|
||||
>
|
||||
{modelFor && modelForEntry && (
|
||||
<>
|
||||
<ModelPicker entry={modelForEntry} provider={modelFor} />
|
||||
{modelError && <div className="form-error" role="alert">{modelError}</div>}
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>
|
||||
{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 })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
<span className="reveal row" style={{ gap: 6 }}>
|
||||
<Button onClick={onCheck}>{t("Check")}</Button>
|
||||
{onChangeModel && <Button onClick={onChangeModel}>{t("Change model")}</Button>}
|
||||
<Button onClick={onManage}>{t("Manage")}</Button>
|
||||
<Button
|
||||
tone="ghost"
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface SaveProviderCredentialInput {
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface SetProviderModelInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface RevokeProviderCredentialInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
|
||||
@@ -72,6 +72,7 @@ import type {
|
||||
RunTaskInput,
|
||||
RevokeProviderCredentialInput,
|
||||
SaveProviderCredentialInput,
|
||||
SetProviderModelInput,
|
||||
ConnectComposioToolkitInput,
|
||||
ConnectComposioToolkitResult,
|
||||
ConnectorCatalogEntry,
|
||||
@@ -163,6 +164,7 @@ export interface DesktopApi {
|
||||
deleteMessage(input: DeleteMessageInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setProviderModel(input: SetProviderModelInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
||||
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const IPC_CHANNELS = {
|
||||
deleteMessage: "agentcompany:delete-message",
|
||||
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||
setProviderModel: "agentcompany:set-provider-model",
|
||||
revokeProviderCredential: "agentcompany:revoke-provider-credential",
|
||||
startProviderOAuth: "agentcompany:start-provider-oauth",
|
||||
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
||||
|
||||
@@ -195,6 +195,23 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Save OpenAI policy" }).click();
|
||||
await expect(page.getByText("0 / 25").first()).toBeVisible();
|
||||
|
||||
// Changing only the default model asks for no key and keeps the sign-in.
|
||||
const changeModel = async (pick: (options: string[]) => string) => {
|
||||
await page.getByRole("button", { name: "Change model" }).first().click();
|
||||
const modelSelect = page.locator(".modal select[name=model]");
|
||||
const options = await modelSelect.locator("option").evaluateAll((nodes) => nodes.map((node) => (node as HTMLOptionElement).value));
|
||||
const chosen = pick(options);
|
||||
await modelSelect.selectOption(chosen);
|
||||
await expect(page.locator(".modal input[type=password]")).toHaveCount(0);
|
||||
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.locator(".modal")).toHaveCount(0);
|
||||
return chosen;
|
||||
};
|
||||
const otherModel = await changeModel((options) => options.find((option) => option !== "gpt-5.4") ?? "");
|
||||
await expect(page.getByText(`${otherModel} · API key`).first()).toBeVisible();
|
||||
await changeModel(() => "gpt-5.4");
|
||||
await expect(page.getByText("gpt-5.4 · API key").first()).toBeVisible();
|
||||
|
||||
// Skills are built-in now: no Skills tab exists, yet the bundled
|
||||
// catalogue imported itself during onboarding.
|
||||
await expect(page.getByRole("button", { name: "Skills" })).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { setProviderModel } from "../../src/main/services/provider-default-model";
|
||||
|
||||
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
|
||||
|
||||
describe("changing a connected provider's default model", () => {
|
||||
test("changes only the model: the credential, its sign-in and its health stay as they were", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-default-model-${randomUUID()}.db`));
|
||||
const db = connection.db;
|
||||
const providers = new ProviderService(db, providerAdapters, vault);
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
});
|
||||
const idOf = (key: string) => (db.prepare("select id from providers where org_id = ? and provider_key = ?").get(orgId, key) as { id: string }).id;
|
||||
const openai = idOf("openai");
|
||||
providers.saveCredential({ orgId, providerId: openai, secret: "sk-test", model: "gpt-5.4" });
|
||||
db.prepare("update providers set health_state = 'healthy' where id = ?").run(openai);
|
||||
const before = db.prepare("select credential_id, auth_state, health_state from providers where id = ?").get(openai);
|
||||
|
||||
setProviderModel(db, { orgId, providerId: openai, model: " gpt-6 " });
|
||||
expect(db.prepare("select model from providers where id = ?").get(openai)).toEqual({ model: "gpt-6" });
|
||||
expect(db.prepare("select credential_id, auth_state, health_state from providers where id = ?").get(openai)).toEqual(before);
|
||||
expect(db.prepare("select action, payload_json from audit_events where entity_id = ? and action = 'default_model_changed'").get(openai))
|
||||
.toEqual({ action: "default_model_changed", payload_json: JSON.stringify({ from: "gpt-5.4", to: "gpt-6" }) });
|
||||
|
||||
expect(() => setProviderModel(db, { orgId, providerId: openai, model: " " })).toThrow("Name the default model.");
|
||||
expect(() => setProviderModel(db, { orgId, providerId: idOf("anthropic"), model: "claude" })).toThrow("Connect this provider first; its default model is set when it connects.");
|
||||
db.prepare("update providers set credential_id = (select credential_id from providers where id = ?), auth_state = 'configured' where id = ?").run(openai, idOf("azure-openai"));
|
||||
expect(() => setProviderModel(db, { orgId, providerId: idOf("azure-openai"), model: "gpt-6" }))
|
||||
.toThrow("Azure OpenAI calls the deployment saved with its credential; change the deployment under Manage.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user