Add provider health adapters
This commit is contained in:
1 parent
eba83ef794
commit
28007b3691
9 files changed
+537
-86
No files matched your search
@@ -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.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(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.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) => {
|
ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => {
|
||||||
try {
|
try {
|
||||||
providers.launchSignInWindow(input.orgId, input.providerId);
|
providers.launchSignInWindow(input.orgId, input.providerId);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain";
|
||||||
|
|
||||||
export interface ProviderExecutionInput {
|
export interface ProviderExecutionInput {
|
||||||
apiKey: string;
|
authProfile: ProviderAuthProfile;
|
||||||
model: string;
|
model: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
instructions: string | null;
|
instructions: string | null;
|
||||||
@@ -10,27 +12,149 @@ export interface ProviderExecutionResult {
|
|||||||
rawResponseJson: string;
|
rawResponseJson: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderAdapter {
|
export interface ProviderHealthInput {
|
||||||
createResponse(input: ProviderExecutionInput): Promise<ProviderExecutionResult>;
|
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<ProviderHealthResult>;
|
||||||
|
createResponse?: (input: ProviderExecutionInput) => Promise<ProviderExecutionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderAdapterRegistry = Record<string, ProviderAdapter>;
|
||||||
|
|
||||||
|
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<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
const PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
|
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<string, string>, body: JsonRecord): Promise<JsonRecord> {
|
async function postJson(endpoint: string, headers: Record<string, string>, body: JsonRecord): Promise<JsonRecord> {
|
||||||
|
return requestJson(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(endpoint: string, headers: Record<string, string>): Promise<JsonRecord> {
|
||||||
|
return requestJson(endpoint, { method: "GET", headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(endpoint: string, init: RequestInit): Promise<JsonRecord> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(endpoint, {
|
response = await fetch(endpoint, { ...init, signal: controller.signal });
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@@ -81,62 +205,171 @@ function requireText(parts: string[], providerKey: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerAdapters: Record<string, ProviderAdapter> = {
|
function openAiCompatibleAdapter({
|
||||||
openai: {
|
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) {
|
async createResponse(input) {
|
||||||
const data = await postJson("https://api.openai.com/v1/responses", {
|
const profile = requireApiKeyProfile(input.authProfile, label);
|
||||||
authorization: `Bearer ${input.apiKey}`,
|
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,
|
model: input.model,
|
||||||
input: input.prompt,
|
messages,
|
||||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
|
||||||
});
|
});
|
||||||
const text = requireText(collectText(data.output_text ?? data.output), "OpenAI");
|
const text = requireText(collectText(data.choices), label);
|
||||||
return { text, rawResponseJson: JSON.stringify(data) };
|
return { text, rawResponseJson: JSON.stringify(data) };
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
anthropic: {
|
}
|
||||||
async createResponse(input) {
|
|
||||||
const data = await postJson("https://api.anthropic.com/v1/messages", {
|
async function checkOpenAiHealth(authProfile: ProviderAuthProfile, healthUrl: string): Promise<ProviderHealthResult> {
|
||||||
"x-api-key": input.apiKey,
|
const profile = requireApiKeyProfile(authProfile, "OpenAI-compatible provider");
|
||||||
"anthropic-version": "2023-06-01",
|
await getJson(healthUrl, { authorization: `Bearer ${profile.apiKey}` });
|
||||||
}, {
|
return healthy();
|
||||||
model: input.model,
|
}
|
||||||
max_tokens: 4096,
|
|
||||||
...(input.instructions ? { system: input.instructions } : {}),
|
function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter {
|
||||||
messages: [{ role: "user", content: input.prompt }],
|
return {
|
||||||
});
|
providerKey,
|
||||||
const text = requireText(collectText(data.content), "Anthropic");
|
authType,
|
||||||
return { text, rawResponseJson: JSON.stringify(data) };
|
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);
|
function parseApiKey(secret: string): ProviderAuthProfile {
|
||||||
},
|
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
||||||
},
|
}
|
||||||
groq: {
|
|
||||||
async createResponse(input) {
|
function parseAccessToken(secret: string): ProviderAuthProfile {
|
||||||
return createOpenAiCompatibleResponse("https://api.groq.com/openai/v1/chat/completions", "Groq", input);
|
return { type: "access_token", accessToken: nonEmptySecret(secret, "Access token") };
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
function parseAccountToken(secret: string): ProviderAuthProfile {
|
||||||
|
return { type: "account_token", accessToken: nonEmptySecret(secret, "Account token") };
|
||||||
async function createOpenAiCompatibleResponse(
|
}
|
||||||
endpoint: string,
|
|
||||||
providerLabel: string,
|
function parseAzureProfile(secret: string): ProviderAuthProfile {
|
||||||
input: ProviderExecutionInput,
|
const parsed = parseCloudSecret(secret);
|
||||||
): Promise<ProviderExecutionResult> {
|
const baseUrl = requiredString(parsed.baseUrl, "Azure base URL").replace(/\/+$/, "");
|
||||||
const messages = [
|
const deployment = requiredString(parsed.deployment, "Azure deployment");
|
||||||
...(input.instructions ? [{ role: "system", content: input.instructions }] : []),
|
return { type: "azure_openai", baseUrl: normalizedHttpsUrl(baseUrl, "Azure base URL"), deployment, apiKey: requiredString(parsed.secret, "Azure API key") };
|
||||||
{ role: "user", content: input.prompt },
|
}
|
||||||
];
|
|
||||||
const data = await postJson(endpoint, {
|
function parseBedrockProfile(secret: string): ProviderAuthProfile {
|
||||||
authorization: `Bearer ${input.apiKey}`,
|
const parsed = parseCloudSecret(secret);
|
||||||
}, {
|
const credential = parseCredentialJson(requiredString(parsed.secret, "AWS credential"));
|
||||||
model: input.model,
|
return {
|
||||||
messages,
|
type: "aws_bedrock",
|
||||||
});
|
region: requiredString(parsed.region, "AWS region"),
|
||||||
const text = requireText(collectText(data.choices), providerLabel);
|
accessKeyId: requiredString(credential.accessKeyId, "AWS access key id"),
|
||||||
return { text, rawResponseJson: JSON.stringify(data) };
|
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<ProviderAuthProfile, { type: "api_key" }> {
|
||||||
|
if (profile.type !== "api_key") throw new Error(`${label} API key profile is required.`);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "access_token" }> {
|
||||||
|
if (profile.type !== "access_token") throw new Error("Access token profile is required.");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "account_token" }> {
|
||||||
|
if (profile.type !== "account_token") throw new Error("Account token profile is required.");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAzureProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "azure_openai" }> {
|
||||||
|
if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required.");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBedrockProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "aws_bedrock" }> {
|
||||||
|
if (profile.type !== "aws_bedrock") throw new Error("AWS Bedrock profile is required.");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireVertexProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "vertex_ai" }> {
|
||||||
|
if (profile.type !== "vertex_ai") throw new Error("Vertex AI profile is required.");
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthy(): ProviderHealthResult {
|
||||||
|
return { checkedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
|||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { SecretVault } from "./secret-vault";
|
import { SecretVault } from "./secret-vault";
|
||||||
import { providerAdapters } from "./provider-adapters";
|
import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||||
|
|
||||||
const AUTH_STATE_CONFIGURED = "configured";
|
const AUTH_STATE_CONFIGURED = "configured";
|
||||||
const HEALTH_STATE_HEALTHY = "healthy";
|
const HEALTH_STATE_HEALTHY = "healthy";
|
||||||
@@ -22,9 +22,12 @@ function displayHint(secret: string): string {
|
|||||||
|
|
||||||
export class ProviderService {
|
export class ProviderService {
|
||||||
private readonly audit: AuditService;
|
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);
|
this.audit = new AuditService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +37,8 @@ export class ProviderService {
|
|||||||
if (!provider) throw new Error("Provider was not found.");
|
if (!provider) throw new Error("Provider was not found.");
|
||||||
const secret = input.secret.trim();
|
const secret = input.secret.trim();
|
||||||
if (!secret) throw new Error("Credential is required.");
|
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 credentialId = createId("cred");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
@@ -64,6 +69,30 @@ export class ProviderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkHealth(orgId: string, providerId: string): Promise<void> {
|
||||||
|
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 {
|
launchSignInWindow(orgId: string, providerId: string): void {
|
||||||
const provider = this.db.prepare(`
|
const provider = this.db.prepare(`
|
||||||
select id, provider_key, label, auth_type
|
select id, provider_key, label, auth_type
|
||||||
@@ -110,28 +139,14 @@ export class ProviderService {
|
|||||||
async createResponse(input: CreateProviderResponseInput): Promise<void> {
|
async createResponse(input: CreateProviderResponseInput): Promise<void> {
|
||||||
const prompt = input.prompt.trim();
|
const prompt = input.prompt.trim();
|
||||||
if (!prompt) throw new Error("Prompt is required.");
|
if (!prompt) throw new Error("Prompt is required.");
|
||||||
const provider = this.db.prepare(`
|
const provider = this.loadConfiguredProvider(input.orgId, input.providerId);
|
||||||
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.");
|
|
||||||
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
|
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) {
|
||||||
throw new Error("Provider response execution requires an API key adapter.");
|
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.");
|
if (!provider.model) throw new Error("Provider model is required.");
|
||||||
const adapter = providerAdapters[provider.provider_key];
|
const adapter = this.requireAdapter(provider.provider_key, provider.auth_type);
|
||||||
if (!adapter) throw new Error(`Provider adapter is unavailable for ${provider.provider_key}.`);
|
if (!adapter.createResponse) throw new Error(`Provider response execution is unavailable for ${provider.provider_key}.`);
|
||||||
|
const credential = this.loadCredential(input.orgId, provider);
|
||||||
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 requestId = createId("preq");
|
const requestId = createId("preq");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -153,9 +168,9 @@ export class ProviderService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
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({
|
const result = await adapter.createResponse({
|
||||||
apiKey,
|
authProfile,
|
||||||
model: provider.model,
|
model: provider.model,
|
||||||
prompt,
|
prompt,
|
||||||
instructions: input.instructions?.trim() || null,
|
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 {
|
private attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void {
|
||||||
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
const allowedHost = new URL(signInUrl).hostname.toLowerCase();
|
||||||
const allow = (url: string) => {
|
const allow = (url: string) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const api: DesktopApi = {
|
|||||||
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
||||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||||
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
||||||
|
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||||
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export function ProvidersPanel({ orgId, providers, catalog, agents, actions }: P
|
|||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
agents={agents}
|
agents={agents}
|
||||||
|
onCheck={() => void actions.run(() => window.agentCompany.checkProviderHealth({ orgId, providerId: provider.id }))}
|
||||||
onManage={() => setActive(provider)}
|
onManage={() => setActive(provider)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -231,10 +232,12 @@ function composeSecret(entry: ProviderCatalogEntry, form: FormData): string {
|
|||||||
function ProviderRow({
|
function ProviderRow({
|
||||||
provider,
|
provider,
|
||||||
agents,
|
agents,
|
||||||
|
onCheck,
|
||||||
onManage,
|
onManage,
|
||||||
}: {
|
}: {
|
||||||
provider: ProviderRecord;
|
provider: ProviderRecord;
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
|
onCheck: () => void;
|
||||||
onManage: () => void;
|
onManage: () => void;
|
||||||
}) {
|
}) {
|
||||||
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
|
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
|
||||||
@@ -257,15 +260,23 @@ function ProviderRow({
|
|||||||
{provider.model ? `${provider.model} · ` : ""}{readableAuth(provider.authType)}
|
{provider.model ? `${provider.model} · ` : ""}{readableAuth(provider.authType)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProviderHealth provider={provider} />
|
||||||
<div style={{ textAlign: "right", minWidth: 120 }}>
|
<div style={{ textAlign: "right", minWidth: 120 }}>
|
||||||
<div className="num mono" style={{ fontSize: 13, fontWeight: 540 }}>{boundCount} {boundCount === 1 ? "agent" : "agents"}</div>
|
<div className="num mono" style={{ fontSize: 13, fontWeight: 540 }}>{boundCount} {boundCount === 1 ? "agent" : "agents"}</div>
|
||||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>bound</div>
|
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>bound</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={onCheck}>Check</Button>
|
||||||
<Button onClick={onManage}>Manage</Button>
|
<Button onClick={onManage}>Manage</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderHealth({ provider }: { provider: ProviderRecord }) {
|
||||||
|
if (provider.healthState === "healthy") return <Pill tone="green">Healthy</Pill>;
|
||||||
|
if (provider.healthState === "failed") return <Pill tone="red">Failed</Pill>;
|
||||||
|
return <Pill>Unchecked</Pill>;
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
|
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ export interface LaunchProviderSignInInput {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckProviderHealthInput {
|
||||||
|
orgId: string;
|
||||||
|
providerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProviderResponseInput {
|
export interface CreateProviderResponseInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
CreateApprovalInput,
|
CreateApprovalInput,
|
||||||
CreateChatSpaceInput,
|
CreateChatSpaceInput,
|
||||||
CreateMigrationReportInput,
|
CreateMigrationReportInput,
|
||||||
|
CheckProviderHealthInput,
|
||||||
MarkChatSpaceReadInput,
|
MarkChatSpaceReadInput,
|
||||||
CreateOrganizationInput,
|
CreateOrganizationInput,
|
||||||
CreateProviderResponseInput,
|
CreateProviderResponseInput,
|
||||||
@@ -49,6 +50,7 @@ export interface DesktopApi {
|
|||||||
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
||||||
|
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
importSkillDirectory(input: ImportSkillDirectoryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const IPC_CHANNELS = {
|
|||||||
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
||||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||||
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
||||||
|
checkProviderHealth: "agentcompany:check-provider-health",
|
||||||
openExternalUrl: "agentcompany:open-external-url",
|
openExternalUrl: "agentcompany:open-external-url",
|
||||||
createProviderResponse: "agentcompany:create-provider-response",
|
createProviderResponse: "agentcompany:create-provider-response",
|
||||||
importSkillDirectory: "agentcompany:import-skill-directory",
|
importSkillDirectory: "agentcompany:import-skill-directory",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user