diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index 17d4daec..0e84140c 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, + providerEndpoint: z.strictObject({ orgId: id, providerId: id }), 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({ diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 0b7b1080..f6115840 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -226,6 +226,13 @@ 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("providerEndpoint", (input) => { + try { + return ok(providers.endpoint(input.orgId, input.providerId)); + } catch (error) { + return fail("provider_endpoint_failed", error instanceof Error ? error.message : String(error)); + } + }); handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input))); handle("revokeProviderCredential", async (input) => { try { diff --git a/src/main/services/provider-endpoint.ts b/src/main/services/provider-endpoint.ts new file mode 100644 index 00000000..fd68cba5 --- /dev/null +++ b/src/main/services/provider-endpoint.ts @@ -0,0 +1,29 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ProviderEndpoint } from "../../shared/commands"; +import type { AuthBrokerService } from "./auth-broker-service"; +import { loadCredential, type ProviderRow } from "./provider-internal"; + +function savedPayload(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string): Record | null { + const provider = db.prepare("select * from providers where id = ? and org_id = ?").get(providerId, orgId) as ProviderRow | undefined; + if (!provider) throw new Error("Provider was not found."); + if (provider.auth_type !== "cloud" || !provider.credential_id) return null; + return JSON.parse(loadCredential(authBroker, orgId, provider).secret) as Record; +} + +const text = (value: unknown): string => (typeof value === "string" ? value : ""); + +/** The saved address, region, project and deployment of a cloud or custom provider, so its form shows what is in use; never the key. */ +export function providerEndpoint(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string): ProviderEndpoint | null { + const saved = savedPayload(db, authBroker, orgId, providerId); + return saved ? { baseUrl: text(saved.baseUrl), region: text(saved.region), projectId: text(saved.projectId), deployment: text(saved.deployment) } : null; +} + +/** A cloud form that left its key blank asks to keep the saved key; the saved key goes into the new payload. */ +export function withSavedSecret(db: DatabaseSync, authBroker: AuthBrokerService, orgId: string, providerId: string, secret: string): string { + const payload = JSON.parse(secret) as Record; + if (payload.keepSavedSecret !== true) return secret; + const saved = savedPayload(db, authBroker, orgId, providerId); + if (!saved) throw new Error("There is no saved key to keep; enter the key."); + delete payload.keepSavedSecret; + return JSON.stringify({ ...payload, secret: saved.secret }); +} diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index 10a9ff2a..ab1d9963 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; -import type { CancelProviderResponseInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands"; +import type { CancelProviderResponseInput, ListProviderModelsInput, ProviderEndpoint, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands"; +import { providerEndpoint, withSavedSecret } from "./provider-endpoint"; import type { ProviderModelEntry } from "../../shared/provider-models"; import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain"; import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain"; @@ -97,8 +98,9 @@ export class ProviderService { const provider = this.db.prepare("select * from providers where id = ? and org_id = ?") .get(input.providerId, input.orgId) as { provider_key: string; auth_type: ProviderAuthType } | undefined; if (!provider) throw new Error("Provider was not found."); - const secret = input.secret.trim(); - if (!secret) throw new Error("Credential is required."); + const typed = input.secret.trim(); + if (!typed) throw new Error("Credential is required."); + const secret = provider.auth_type === "cloud" ? withSavedSecret(this.db, this.authBroker, input.orgId, input.providerId, typed) : typed; const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); adapter.parseAuthProfile(secret); const catalogEntry = requireCatalogEntry(provider.provider_key); @@ -130,6 +132,10 @@ export class ProviderService { }); } + endpoint(orgId: string, providerId: string): ProviderEndpoint | null { + return providerEndpoint(this.db, this.authBroker, orgId, providerId); + } + /** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */ async revokeCredential(input: RevokeProviderCredentialInput): Promise { const provider = this.db.prepare(` diff --git a/src/preload/index.ts b/src/preload/index.ts index 6c7c6a17..77b05f5b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,6 +55,7 @@ const api: DesktopApi = { markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), setProviderModel: (input) => invoke(IPC_CHANNELS.setProviderModel, input), + providerEndpoint: (input) => invoke(IPC_CHANNELS.providerEndpoint, 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 fc8fa49a..cb911d94 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -680,6 +680,9 @@ export const zh: Record = { "{percent}% of cap": "上限的 {percent}%", "Default model for {label}": "{label} 的默认模型", "Change model": "更换模型", + "Saved. Leave blank to keep it.": "已保存。留空即保留。", + "The saved settings could not be read: {error}": "无法读取已保存的设置:{error}", + "Loading saved settings…": "正在读取已保存的设置…", "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 0fb244e8..47423532 100644 --- a/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx +++ b/src/renderer/src/pages/settings/ProviderConnectionDialog.tsx @@ -1,18 +1,38 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain"; import { ProviderOAuthConnect } from "../../shared/ProviderOAuthConnect"; import { Field } from "../../shared/ui"; import { t } from "../../i18n/i18n"; import type { ModelCheckResult } from "../../../../shared/desktop-api"; +import type { ProviderEndpoint } from "../../../../shared/commands"; -interface CloudFields { - baseUrl: string; - region: string; - projectId: string; - deployment: string; +const CLOUD_DEFAULTS: ProviderEndpoint = { baseUrl: "", region: "", projectId: "", deployment: "" }; + +/** The saved endpoint of a connected cloud or custom provider: undefined while loading, null when nothing is saved. */ +function useSavedEndpoint(provider: ProviderRecord, entry: ProviderCatalogEntry): { endpoint: ProviderEndpoint | null | undefined; error: string | null } { + const saved = entry.authType === "cloud" && Boolean(provider.credentialId); + const [state, setState] = useState<{ endpoint: ProviderEndpoint | null | undefined; error: string | null }>({ endpoint: saved ? undefined : null, error: null }); + useEffect(() => { + if (!saved) return; + let live = true; + void window.agentCompany.providerEndpoint({ orgId: provider.orgId, providerId: provider.id }).then((result) => { + if (!live) return; + setState(result.ok ? { endpoint: result.data, error: null } : { endpoint: null, error: result.error.message }); + }); + return () => { live = false; }; + }, [saved, provider.orgId, provider.id]); + return state; } -const CLOUD_DEFAULTS: CloudFields = { baseUrl: "", region: "", projectId: "", deployment: "" }; +/** With a saved key the field may stay blank, which keeps it. */ +function SecretField({ label, saved, required }: { label: string; saved: boolean; required: boolean }) { + return ( + + + {saved && } + + ); +} export function ProviderConnectFields({ provider, @@ -30,10 +50,16 @@ export function ProviderConnectFields({ ); } - if (entry.customEndpoint) { - return ; - } - return ; + return ; +} + +function SavedEndpointForm({ provider, entry }: { provider: ProviderRecord; entry: ProviderCatalogEntry }) { + const { endpoint, error } = useSavedEndpoint(provider, entry); + if (error) return
{t("The saved settings could not be read: {error}", { error })}
; + if (endpoint === undefined) return

{t("Loading saved settings…")}

; + return entry.customEndpoint + ? + : ; } export function ProviderOAuthFields({ @@ -75,8 +101,8 @@ export function ProviderOAuthFields({ ); } -function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) { - const [baseUrl, setBaseUrl] = useState(""); +function CompatFieldsForm({ entry, provider, saved }: { entry: ProviderCatalogEntry; provider: ProviderRecord; saved: ProviderEndpoint | null }) { + const [baseUrl, setBaseUrl] = useState(saved?.baseUrl ?? ""); return ( <>

{entry.credentialHelp}

@@ -88,7 +114,7 @@ function CompatFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pr onChange={(event) => setBaseUrl(event.target.value)} /> - + ); @@ -159,9 +185,9 @@ export function ModelPicker({ entry, provider }: { entry: ProviderCatalogEntry; ); } -function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; provider: ProviderRecord }) { - const [state, setState] = useState(CLOUD_DEFAULTS); - const update = (patch: Partial) => setState((current) => ({ ...current, ...patch })); +function CloudFieldsForm({ entry, provider, saved }: { entry: ProviderCatalogEntry; provider: ProviderRecord; saved: ProviderEndpoint | null }) { + const [state, setState] = useState(saved ?? CLOUD_DEFAULTS); + const update = (patch: Partial) => setState((current) => ({ ...current, ...patch })); return ( <>

{entry.credentialHelp}

@@ -177,9 +203,7 @@ function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pro update({ deployment: event.target.value })} /> - - - + diff --git a/src/renderer/src/pages/settings/provider-secret.ts b/src/renderer/src/pages/settings/provider-secret.ts index 7aad244e..e1e09727 100644 --- a/src/renderer/src/pages/settings/provider-secret.ts +++ b/src/renderer/src/pages/settings/provider-secret.ts @@ -9,6 +9,7 @@ export function composeSecret(entry: ProviderCatalogEntry, form: FormData): stri projectId: String(form.get("cloudProject") ?? "").trim(), deployment: String(form.get("cloudDeployment") ?? "").trim(), secret: raw, + ...(raw === "" && form.get("keepSavedSecret") === "1" ? { keepSavedSecret: true } : {}), }; return JSON.stringify(payload); } diff --git a/src/shared/commands/provider.ts b/src/shared/commands/provider.ts index f1cb4a1f..9b9c7543 100644 --- a/src/shared/commands/provider.ts +++ b/src/shared/commands/provider.ts @@ -7,6 +7,14 @@ export interface SaveProviderCredentialInput { model: string | null; } +/** The non-secret part of a saved cloud or custom-endpoint credential. */ +export interface ProviderEndpoint { + baseUrl: string; + region: string; + projectId: string; + deployment: string; +} + export interface SetProviderModelInput { orgId: string; providerId: string; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 25a9e5af..9e0ccc1c 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -73,6 +73,7 @@ import type { RevokeProviderCredentialInput, SaveProviderCredentialInput, SetProviderModelInput, + ProviderEndpoint, ConnectComposioToolkitInput, ConnectComposioToolkitResult, ConnectorCatalogEntry, @@ -165,6 +166,8 @@ export interface DesktopApi { markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; setProviderModel(input: SetProviderModelInput): Promise>; + /** Null when the provider has no saved cloud or custom-endpoint credential. */ + providerEndpoint(input: { orgId: string; providerId: string }): Promise>; revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; startProviderOAuth(input: StartProviderOAuthInput): Promise>; cancelProviderOAuth(input: CancelProviderOAuthInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index fa2ed8eb..71837836 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -28,6 +28,7 @@ export const IPC_CHANNELS = { markChatSpaceRead: "agentcompany:mark-chat-space-read", saveProviderCredential: "agentcompany:save-provider-credential", setProviderModel: "agentcompany:set-provider-model", + providerEndpoint: "agentcompany:provider-endpoint", revokeProviderCredential: "agentcompany:revoke-provider-credential", startProviderOAuth: "agentcompany:start-provider-oauth", cancelProviderOAuth: "agentcompany:cancel-provider-oauth", diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 46318b64..05325f38 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -212,6 +212,22 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await changeModel(() => "gpt-5.4"); await expect(page.getByText("gpt-5.4 · API key").first()).toBeVisible(); + // A saved custom endpoint reopens with its address filled in, and a blank key keeps the saved one. + await page.locator("div", { hasText: /^Custom OpenAI-compatible$/ }).locator("xpath=../..").getByRole("button", { name: "Connect" }).click(); + await page.locator(".modal input[name=cloudBaseUrl]").fill("http://localhost:11434/v1"); + await page.locator(".modal input[name=secret]").fill("local-key"); + await page.locator(".modal [name=model]").fill("llama3"); + await page.locator(".modal").getByRole("button", { name: "Save" }).click(); + const customRow = page.locator(".reveal-row", { hasText: "Custom OpenAI-compatible" }); + await customRow.getByRole("button", { name: "Manage" }).click(); + await expect(page.locator(".modal input[name=cloudBaseUrl]")).toHaveValue("http://localhost:11434/v1"); + await expect(page.locator(".modal input[name=secret]")).toHaveAttribute("placeholder", "Saved. Leave blank to keep it."); + await page.locator(".modal [name=model]").fill("llama3.1"); + await page.locator(".modal").getByRole("button", { name: "Save" }).click(); + await expect(customRow.getByText("llama3.1 · Cloud")).toBeVisible(); + await customRow.getByRole("button", { name: "Disconnect" }).click(); + await expect(customRow).toHaveCount(0); + // 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); diff --git a/tests/unit/provider-endpoint.test.ts b/tests/unit/provider-endpoint.test.ts new file mode 100644 index 00000000..64b6c1a2 --- /dev/null +++ b/tests/unit/provider-endpoint.test.ts @@ -0,0 +1,37 @@ +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 { AuthBrokerService } from "../../src/main/services/auth-broker-service"; +import { loadCredential, type ProviderRow } from "../../src/main/services/provider-internal"; + +const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; + +describe("a saved cloud endpoint", () => { + test("refills its form without the key, and a blank key keeps the saved one", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-endpoint-${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 azure = (db.prepare("select id from providers where org_id = ? and provider_key = 'azure-openai'").get(orgId) as { id: string }).id; + const storedSecret = () => JSON.parse(loadCredential(new AuthBrokerService(db, vault), orgId, db.prepare("select * from providers where id = ?").get(azure) as unknown as ProviderRow).secret) as Record; + + expect(providers.endpoint(orgId, azure)).toBeNull(); + const keep = (deployment: string) => JSON.stringify({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment, secret: "", keepSavedSecret: true }); + expect(() => providers.saveCredential({ orgId, providerId: azure, secret: keep("d1"), model: "d1" })).toThrow("There is no saved key to keep; enter the key."); + + providers.saveCredential({ orgId, providerId: azure, secret: JSON.stringify({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d1", secret: "az-key" }), model: "d1" }); + expect(providers.endpoint(orgId, azure)).toEqual({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d1" }); + + providers.saveCredential({ orgId, providerId: azure, secret: keep("d2"), model: "d2" }); + expect(providers.endpoint(orgId, azure)?.deployment).toBe("d2"); + expect(storedSecret()).toEqual({ baseUrl: "https://acme.openai.azure.com", region: "", projectId: "", deployment: "d2", secret: "az-key" }); + }); +});