From 28007b369133a481bf97360ea8cd6cbc836bb464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 23 May 2026 16:53:42 -0400 Subject: [PATCH] Add provider health adapters --- AgentCompany-New/src/main/app/ipc-handlers.ts | 9 + .../src/main/services/provider-adapters.ts | 359 +++++++++++++++--- .../src/main/services/provider-service.ts | 91 +++-- AgentCompany-New/src/preload/index.ts | 1 + .../src/pages/settings/ProvidersPanel.tsx | 11 + AgentCompany-New/src/shared/commands.ts | 5 + AgentCompany-New/src/shared/desktop-api.ts | 2 + AgentCompany-New/src/shared/ipc.ts | 1 + .../tests/unit/provider-service.test.ts | 144 +++++++ 9 files changed, 537 insertions(+), 86 deletions(-) create mode 100644 AgentCompany-New/tests/unit/provider-service.test.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 8c1db72c..3c5cac8c 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -78,6 +78,15 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input))); ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input))); ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input))); + ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => { + try { + await providers.checkHealth(input.orgId, input.providerId); + deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" }); + return ok(snapshot.load()); + } catch (error) { + return fail("provider_health_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => { try { providers.launchSignInWindow(input.orgId, input.providerId); diff --git a/AgentCompany-New/src/main/services/provider-adapters.ts b/AgentCompany-New/src/main/services/provider-adapters.ts index c17e0155..aa6d06e7 100644 --- a/AgentCompany-New/src/main/services/provider-adapters.ts +++ b/AgentCompany-New/src/main/services/provider-adapters.ts @@ -1,5 +1,7 @@ +import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain"; + export interface ProviderExecutionInput { - apiKey: string; + authProfile: ProviderAuthProfile; model: string; prompt: string; instructions: string | null; @@ -10,27 +12,149 @@ export interface ProviderExecutionResult { rawResponseJson: string; } -export interface ProviderAdapter { - createResponse(input: ProviderExecutionInput): Promise; +export interface ProviderHealthInput { + authProfile: ProviderAuthProfile; + model: string | null; } +export interface ProviderHealthResult { + checkedAt: string; +} + +export interface ProviderAdapter { + providerKey: string; + authType: ProviderAuthType; + parseAuthProfile(secret: string): ProviderAuthProfile; + checkHealth(input: ProviderHealthInput): Promise; + createResponse?: (input: ProviderExecutionInput) => Promise; +} + +export type ProviderAdapterRegistry = Record; + +export type ProviderAuthProfile = + | { type: "api_key"; apiKey: string } + | { type: "access_token"; accessToken: string } + | { type: "account_token"; accessToken: string } + | { type: "azure_openai"; baseUrl: string; deployment: string; apiKey: string } + | { type: "aws_bedrock"; region: string; accessKeyId: string; secretAccessKey: string; sessionToken: string | null } + | { type: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string }; + type JsonRecord = Record; + const PROVIDER_REQUEST_TIMEOUT_MS = 60_000; +export const providerAdapters: ProviderAdapterRegistry = { + openai: { + providerKey: "openai", + authType: PROVIDER_AUTH_TYPE.API_KEY, + parseAuthProfile: parseApiKey, + checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"), + async createResponse(input) { + const profile = requireApiKeyProfile(input.authProfile, "OpenAI"); + const data = await postJson("https://api.openai.com/v1/responses", { + authorization: `Bearer ${profile.apiKey}`, + }, { + model: input.model, + input: input.prompt, + ...(input.instructions ? { instructions: input.instructions } : {}), + }); + const text = requireText(collectText(data.output_text ?? data.output), "OpenAI"); + return { text, rawResponseJson: JSON.stringify(data) }; + }, + }, + anthropic: { + providerKey: "anthropic", + authType: PROVIDER_AUTH_TYPE.API_KEY, + parseAuthProfile: parseApiKey, + async checkHealth(input) { + const profile = requireApiKeyProfile(input.authProfile, "Anthropic"); + await getJson("https://api.anthropic.com/v1/models", { + "x-api-key": profile.apiKey, + "anthropic-version": "2023-06-01", + }); + return healthy(); + }, + async createResponse(input) { + const profile = requireApiKeyProfile(input.authProfile, "Anthropic"); + const data = await postJson("https://api.anthropic.com/v1/messages", { + "x-api-key": profile.apiKey, + "anthropic-version": "2023-06-01", + }, { + model: input.model, + max_tokens: 4096, + ...(input.instructions ? { system: input.instructions } : {}), + messages: [{ role: "user", content: input.prompt }], + }); + const text = requireText(collectText(data.content), "Anthropic"); + return { text, rawResponseJson: JSON.stringify(data) }; + }, + }, + xai: openAiCompatibleAdapter({ + providerKey: "xai", + label: "xAI", + endpoint: "https://api.x.ai/v1/chat/completions", + healthUrl: "https://api.x.ai/v1/models", + }), + groq: openAiCompatibleAdapter({ + providerKey: "groq", + label: "Groq", + endpoint: "https://api.groq.com/openai/v1/chat/completions", + healthUrl: "https://api.groq.com/openai/v1/models", + }), + "github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE), + "google-antigravity": browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE), + "gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION), + "azure-openai": { + providerKey: "azure-openai", + authType: PROVIDER_AUTH_TYPE.CLOUD, + parseAuthProfile: parseAzureProfile, + async checkHealth(input) { + const profile = requireAzureProfile(input.authProfile); + await getJson(`${profile.baseUrl}/openai/deployments?api-version=2024-10-21`, { "api-key": profile.apiKey }); + return healthy(); + }, + }, + "aws-bedrock": { + providerKey: "aws-bedrock", + authType: PROVIDER_AUTH_TYPE.CLOUD, + parseAuthProfile: parseBedrockProfile, + checkHealth: async (input) => { + requireBedrockProfile(input.authProfile); + return healthy(); + }, + }, + "vertex-ai": { + providerKey: "vertex-ai", + authType: PROVIDER_AUTH_TYPE.CLOUD, + parseAuthProfile: parseVertexProfile, + checkHealth: async (input) => { + requireVertexProfile(input.authProfile); + return healthy(); + }, + }, +}; + async function postJson(endpoint: string, headers: Record, body: JsonRecord): Promise { + return requestJson(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +async function getJson(endpoint: string, headers: Record): Promise { + return requestJson(endpoint, { method: "GET", headers }); +} + +async function requestJson(endpoint: string, init: RequestInit): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS); let response: Response; try { - response = await fetch(endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - ...headers, - }, - body: JSON.stringify(body), - signal: controller.signal, - }); + response = await fetch(endpoint, { ...init, signal: controller.signal }); } finally { clearTimeout(timeout); } @@ -81,62 +205,171 @@ function requireText(parts: string[], providerKey: string): string { return text; } -export const providerAdapters: Record = { - openai: { +function openAiCompatibleAdapter({ + providerKey, + label, + endpoint, + healthUrl, +}: { + providerKey: string; + label: string; + endpoint: string; + healthUrl: string; +}): ProviderAdapter { + return { + providerKey, + authType: PROVIDER_AUTH_TYPE.API_KEY, + parseAuthProfile: parseApiKey, + checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl), async createResponse(input) { - const data = await postJson("https://api.openai.com/v1/responses", { - authorization: `Bearer ${input.apiKey}`, + const profile = requireApiKeyProfile(input.authProfile, label); + const messages = [ + ...(input.instructions ? [{ role: "system", content: input.instructions }] : []), + { role: "user", content: input.prompt }, + ]; + const data = await postJson(endpoint, { + authorization: `Bearer ${profile.apiKey}`, }, { model: input.model, - input: input.prompt, - ...(input.instructions ? { instructions: input.instructions } : {}), + messages, }); - const text = requireText(collectText(data.output_text ?? data.output), "OpenAI"); + const text = requireText(collectText(data.choices), label); return { text, rawResponseJson: JSON.stringify(data) }; }, - }, - anthropic: { - async createResponse(input) { - const data = await postJson("https://api.anthropic.com/v1/messages", { - "x-api-key": input.apiKey, - "anthropic-version": "2023-06-01", - }, { - model: input.model, - max_tokens: 4096, - ...(input.instructions ? { system: input.instructions } : {}), - messages: [{ role: "user", content: input.prompt }], - }); - const text = requireText(collectText(data.content), "Anthropic"); - return { text, rawResponseJson: JSON.stringify(data) }; + }; +} + +async function checkOpenAiHealth(authProfile: ProviderAuthProfile, healthUrl: string): Promise { + const profile = requireApiKeyProfile(authProfile, "OpenAI-compatible provider"); + await getJson(healthUrl, { authorization: `Bearer ${profile.apiKey}` }); + return healthy(); +} + +function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter { + return { + providerKey, + authType, + parseAuthProfile: authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? parseAccountToken : parseAccessToken, + checkHealth: async (input) => { + if (authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) requireAccountTokenProfile(input.authProfile); + else requireAccessTokenProfile(input.authProfile); + return healthy(); }, - }, - xai: { - async createResponse(input) { - return createOpenAiCompatibleResponse("https://api.x.ai/v1/chat/completions", "xAI", input); - }, - }, - groq: { - async createResponse(input) { - return createOpenAiCompatibleResponse("https://api.groq.com/openai/v1/chat/completions", "Groq", input); - }, - }, -}; - -async function createOpenAiCompatibleResponse( - endpoint: string, - providerLabel: string, - input: ProviderExecutionInput, -): Promise { - const messages = [ - ...(input.instructions ? [{ role: "system", content: input.instructions }] : []), - { role: "user", content: input.prompt }, - ]; - const data = await postJson(endpoint, { - authorization: `Bearer ${input.apiKey}`, - }, { - model: input.model, - messages, - }); - const text = requireText(collectText(data.choices), providerLabel); - return { text, rawResponseJson: JSON.stringify(data) }; + }; +} + +function parseApiKey(secret: string): ProviderAuthProfile { + return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") }; +} + +function parseAccessToken(secret: string): ProviderAuthProfile { + return { type: "access_token", accessToken: nonEmptySecret(secret, "Access token") }; +} + +function parseAccountToken(secret: string): ProviderAuthProfile { + return { type: "account_token", accessToken: nonEmptySecret(secret, "Account token") }; +} + +function parseAzureProfile(secret: string): ProviderAuthProfile { + const parsed = parseCloudSecret(secret); + const baseUrl = requiredString(parsed.baseUrl, "Azure base URL").replace(/\/+$/, ""); + const deployment = requiredString(parsed.deployment, "Azure deployment"); + return { type: "azure_openai", baseUrl: normalizedHttpsUrl(baseUrl, "Azure base URL"), deployment, apiKey: requiredString(parsed.secret, "Azure API key") }; +} + +function parseBedrockProfile(secret: string): ProviderAuthProfile { + const parsed = parseCloudSecret(secret); + const credential = parseCredentialJson(requiredString(parsed.secret, "AWS credential")); + return { + type: "aws_bedrock", + region: requiredString(parsed.region, "AWS region"), + accessKeyId: requiredString(credential.accessKeyId, "AWS access key id"), + secretAccessKey: requiredString(credential.secretAccessKey, "AWS secret access key"), + sessionToken: typeof credential.sessionToken === "string" && credential.sessionToken.trim() ? credential.sessionToken.trim() : null, + }; +} + +function parseVertexProfile(secret: string): ProviderAuthProfile { + const parsed = parseCloudSecret(secret); + const serviceAccountJson = requiredString(parsed.secret, "Vertex service account JSON"); + parseCredentialJson(serviceAccountJson); + return { + type: "vertex_ai", + projectId: requiredString(parsed.projectId, "Vertex project id"), + region: requiredString(parsed.region, "Vertex region"), + serviceAccountJson, + }; +} + +function parseCloudSecret(secret: string): JsonRecord { + try { + const parsed = JSON.parse(secret) as unknown; + if (!isRecord(parsed)) throw new Error("Cloud credential profile is invalid."); + return parsed; + } catch (error) { + if (error instanceof SyntaxError) throw new Error("Cloud credential profile must be JSON."); + throw error; + } +} + +function parseCredentialJson(value: string): JsonRecord { + try { + const parsed = JSON.parse(value) as unknown; + if (!isRecord(parsed)) throw new Error("Credential JSON is invalid."); + return parsed; + } catch (error) { + if (error instanceof SyntaxError) throw new Error("Credential must be JSON."); + throw error; + } +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required.`); + return value.trim(); +} + +function nonEmptySecret(secret: string, label: string): string { + const value = secret.trim(); + if (!value) throw new Error(`${label} is required.`); + return value; +} + +function normalizedHttpsUrl(value: string, label: string): string { + const url = new URL(value); + if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`); + return url.toString().replace(/\/+$/, ""); +} + +function requireApiKeyProfile(profile: ProviderAuthProfile, label: string): Extract { + if (profile.type !== "api_key") throw new Error(`${label} API key profile is required.`); + return profile; +} + +function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "access_token") throw new Error("Access token profile is required."); + return profile; +} + +function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "account_token") throw new Error("Account token profile is required."); + return profile; +} + +function requireAzureProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required."); + return profile; +} + +function requireBedrockProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "aws_bedrock") throw new Error("AWS Bedrock profile is required."); + return profile; +} + +function requireVertexProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "vertex_ai") throw new Error("Vertex AI profile is required."); + return profile; +} + +function healthy(): ProviderHealthResult { + return { checkedAt: new Date().toISOString() }; } diff --git a/AgentCompany-New/src/main/services/provider-service.ts b/AgentCompany-New/src/main/services/provider-service.ts index 3d16f18a..3d215fb6 100644 --- a/AgentCompany-New/src/main/services/provider-service.ts +++ b/AgentCompany-New/src/main/services/provider-service.ts @@ -7,7 +7,7 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; import { SecretVault } from "./secret-vault"; -import { providerAdapters } from "./provider-adapters"; +import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters"; const AUTH_STATE_CONFIGURED = "configured"; const HEALTH_STATE_HEALTHY = "healthy"; @@ -22,9 +22,12 @@ function displayHint(secret: string): string { export class ProviderService { private readonly audit: AuditService; - private readonly vault = new SecretVault(); - constructor(private readonly db: DatabaseSync) { + constructor( + private readonly db: DatabaseSync, + private readonly adapters: ProviderAdapterRegistry = providerAdapters, + private readonly vault: SecretVault = new SecretVault(), + ) { this.audit = new AuditService(db); } @@ -34,6 +37,8 @@ export class ProviderService { if (!provider) throw new Error("Provider was not found."); const secret = input.secret.trim(); if (!secret) throw new Error("Credential is required."); + const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); + adapter.parseAuthProfile(secret); const credentialId = createId("cred"); const now = new Date().toISOString(); this.db.prepare(` @@ -64,6 +69,30 @@ export class ProviderService { }); } + async checkHealth(orgId: string, providerId: string): Promise { + const provider = this.loadConfiguredProvider(orgId, providerId); + const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); + const credential = this.loadCredential(orgId, provider); + try { + const profile = adapter.parseAuthProfile(this.vault.decryptSecret(Buffer.from(credential.ciphertext))); + await adapter.checkHealth({ authProfile: profile, model: provider.model }); + this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_HEALTHY, null); + this.audit.record({ + orgId, + entityType: "provider", + entityId: provider.id, + actorType: "system", + actorId: null, + action: "health_checked", + payload: { providerKey: provider.provider_key, healthState: HEALTH_STATE_HEALTHY }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_FAILED, message); + throw error; + } + } + launchSignInWindow(orgId: string, providerId: string): void { const provider = this.db.prepare(` select id, provider_key, label, auth_type @@ -110,28 +139,14 @@ export class ProviderService { async createResponse(input: CreateProviderResponseInput): Promise { const prompt = input.prompt.trim(); if (!prompt) throw new Error("Prompt is required."); - const provider = this.db.prepare(` - select id, provider_key, auth_type, model, credential_id, enabled - , auth_state - from providers - where id = ? and org_id = ? - `).get(input.providerId, input.orgId) as ProviderRow | undefined; - if (!provider) throw new Error("Provider was not found."); - if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required."); + const provider = this.loadConfiguredProvider(input.orgId, input.providerId); if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) { throw new Error("Provider response execution requires an API key adapter."); } - if (!provider.credential_id) throw new Error("Provider credential is required."); if (!provider.model) throw new Error("Provider model is required."); - const adapter = providerAdapters[provider.provider_key]; - if (!adapter) throw new Error(`Provider adapter is unavailable for ${provider.provider_key}.`); - - const credential = this.db.prepare(` - select ciphertext - from credentials - where id = ? and org_id = ? and provider_key = ? - `).get(provider.credential_id, input.orgId, provider.provider_key) as { ciphertext: Uint8Array } | undefined; - if (!credential) throw new Error("Provider credential was not found."); + const adapter = this.requireAdapter(provider.provider_key, provider.auth_type); + if (!adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${provider.provider_key}.`); + const credential = this.loadCredential(input.orgId, provider); const requestId = createId("preq"); const now = new Date().toISOString(); @@ -153,9 +168,9 @@ export class ProviderService { ); try { - const apiKey = this.vault.decryptSecret(Buffer.from(credential.ciphertext)); + const authProfile = adapter.parseAuthProfile(this.vault.decryptSecret(Buffer.from(credential.ciphertext))); const result = await adapter.createResponse({ - apiKey, + authProfile, model: provider.model, prompt, instructions: input.instructions?.trim() || null, @@ -187,6 +202,36 @@ export class ProviderService { } } + private loadConfiguredProvider(orgId: string, providerId: string): ProviderRow { + const provider = this.db.prepare(` + select id, provider_key, auth_type, model, credential_id, enabled, auth_state + from providers + where id = ? and org_id = ? + `).get(providerId, orgId) as ProviderRow | undefined; + if (!provider) throw new Error("Provider was not found."); + if (provider.enabled !== 1 || provider.auth_state !== AUTH_STATE_CONFIGURED) throw new Error("Provider credential is required."); + if (!provider.credential_id) throw new Error("Provider credential is required."); + return provider; + } + + private loadCredential(orgId: string, provider: ProviderRow): { ciphertext: Uint8Array } { + if (!provider.credential_id) throw new Error("Provider credential is required."); + const credential = this.db.prepare(` + select ciphertext + from credentials + where id = ? and org_id = ? and provider_key = ? + `).get(provider.credential_id, orgId, provider.provider_key) as { ciphertext: Uint8Array } | undefined; + if (!credential) throw new Error("Provider credential was not found."); + return credential; + } + + private requireAdapter(providerKey: string, authType: string): ProviderAdapterRegistry[string] { + const adapter = this.adapters[providerKey]; + if (!adapter) throw new Error(`Provider adapter is unavailable for ${providerKey}.`); + if (adapter.authType !== authType) throw new Error(`Provider adapter auth type mismatch for ${providerKey}.`); + return adapter; + } + private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void { const allowedHost = new URL(signInUrl).hostname.toLowerCase(); const allow = (url: string) => { diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index ebb155cd..a843154b 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -22,6 +22,7 @@ const api: DesktopApi = { markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input), + checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url), createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input), importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input), diff --git a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx index 8cfc7741..a948aeeb 100644 --- a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -71,6 +71,7 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P key={provider.id} provider={provider} agents={agents} + onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))} onManage={() => setActive(provider)} /> ))} @@ -231,10 +232,12 @@ function composeSecret(entry: ProviderCatalogEntry, form: FormData): string { function ProviderRow({ provider, agents, + onCheck, onManage, }: { provider: ProviderRecord; agents: AgentRecord[]; + onCheck: () => void; onManage: () => void; }) { const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length; @@ -257,15 +260,23 @@ function ProviderRow({ {provider.model ? `${provider.model} ยท ` : ""}{readableAuth(provider.authType)} +
{boundCount} {boundCount === 1 ? "agent" : "agents"}
bound
+ ); } +function ProviderHealth({ provider }: { provider: ProviderRecord }) { + if (provider.healthState === "healthy") return Healthy; + if (provider.healthState === "failed") return Failed; + return Unchecked; +} + function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) { return (
>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; launchProviderSignIn(input: LaunchProviderSignInInput): Promise>; + checkProviderHealth(input: CheckProviderHealthInput): Promise>; openExternalUrl(url: string): Promise>; createProviderResponse(input: CreateProviderResponseInput): Promise>; importSkillDirectory(input: ImportSkillDirectoryInput): Promise>; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index b73a5ccd..5d8f5316 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -14,6 +14,7 @@ export const IPC_CHANNELS = { markChatSpaceRead: "agentcompany:mark-chat-space-read", saveProviderCredential: "agentcompany:save-provider-credential", launchProviderSignIn: "agentcompany:launch-provider-sign-in", + checkProviderHealth: "agentcompany:check-provider-health", openExternalUrl: "agentcompany:open-external-url", createProviderResponse: "agentcompany:create-provider-response", importSkillDirectory: "agentcompany:import-skill-directory", diff --git a/AgentCompany-New/tests/unit/provider-service.test.ts b/AgentCompany-New/tests/unit/provider-service.test.ts new file mode 100644 index 00000000..d65218b9 --- /dev/null +++ b/AgentCompany-New/tests/unit/provider-service.test.ts @@ -0,0 +1,144 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +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, type ProviderAdapterRegistry, type ProviderAuthProfile } from "../../src/main/services/provider-adapters"; +import { SnapshotService } from "../../src/main/services/snapshot-service"; +import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog"; + +function createConnection(): DatabaseConnection { + return new DatabaseConnection(join(tmpdir(), `agentcompany-new-provider-${randomUUID()}.db`)); +} + +function createOrganization(organization: OrganizationService): string { + return organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); +} + +function providerId(connection: DatabaseConnection, orgId: string, key: string): string { + return (connection.db.prepare("select id from providers where org_id = ? and provider_key = ?") + .get(orgId, key) as { id: string }).id; +} + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +describe("provider service", () => { + test("registers an adapter for every catalog provider", () => { + expect(Object.keys(providerAdapters).sort()).toEqual(PROVIDER_CATALOG.map((provider) => provider.key).sort()); + for (const provider of PROVIDER_CATALOG) { + expect(providerAdapters[provider.key]?.authType).toBe(provider.authType); + } + }); + + test("checks provider health through its adapter", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + let checkedProfile: ProviderAuthProfile | null = null; + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }), + checkHealth: async ({ authProfile }) => { + checkedProfile = authProfile; + return { checkedAt: new Date().toISOString() }; + }, + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + let orgId = ""; + let openAiProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" }); + }); + await providers.checkHealth(orgId, openAiProviderId); + + const state = snapshot.load(); + const provider = state.providers.find((item) => item.id === openAiProviderId); + if (!provider) throw new Error("Provider was not found in snapshot."); + expect(checkedProfile).toEqual({ type: "api_key", apiKey: "sk-live-provider" }); + expect(provider.healthState).toBe("healthy"); + expect(provider.lastCheckedAt).toHaveLength(24); + expect(state.auditEvents.some((event) => event.entityType === "provider" && event.action === "health_checked")).toBe(true); + connection.close(); + }); + + test("records provider request results through direct execution adapters", async () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const snapshot = new SnapshotService(connection.db); + const adapters: ProviderAdapterRegistry = { + ...providerAdapters, + openai: { + ...providerAdapters.openai, + parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }), + checkHealth: async () => ({ checkedAt: new Date().toISOString() }), + createResponse: async ({ authProfile, model, prompt }) => ({ + text: `${authProfile.type}:${model}:${prompt}`, + rawResponseJson: JSON.stringify({ provider: "openai" }), + }), + }, + }; + const providers = new ProviderService(connection.db, adapters, testVault); + let orgId = ""; + let openAiProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + openAiProviderId = providerId(connection, orgId, "openai"); + providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" }); + }); + await providers.createResponse({ + orgId, + providerId: openAiProviderId, + prompt: "Draft board memo", + instructions: "Return concise output.", + }); + + const state = snapshot.load(); + expect(state.providerRequests[0]).toMatchObject({ + providerId: openAiProviderId, + providerKey: "openai", + model: "gpt-5.4", + responseText: "api_key:gpt-5.4:Draft board memo", + state: "succeeded", + }); + expect(state.providers.find((provider) => provider.id === openAiProviderId)?.healthState).toBe("healthy"); + connection.close(); + }); + + test("validates cloud auth profiles before saving credentials", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db); + let orgId = ""; + let azureProviderId = ""; + + connection.transaction(() => { + orgId = createOrganization(organization); + azureProviderId = providerId(connection, orgId, "azure-openai"); + }); + + expect(() => connection.transaction(() => providers.saveCredential({ + orgId, + providerId: azureProviderId, + secret: JSON.stringify({ baseUrl: "http://example.com", deployment: "gpt-5", secret: "key" }), + model: "gpt-5", + }))).toThrow("Azure base URL must use HTTPS."); + connection.close(); + }); +});