Add provider health adapters

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 16:53:42 -04:00
1 parent eba83ef794
commit 28007b3691
9 files changed
+537 -86

No files matched your search

@@ -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<ProviderExecutionResult>;
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<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>;
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> {
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 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<string, ProviderAdapter> = {
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<ProviderHealthResult> {
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<ProviderExecutionResult> {
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<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 { 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<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 {
const provider = this.db.prepare(`
select id, provider_key, label, auth_type
@@ -110,28 +139,14 @@ export class ProviderService {
async createResponse(input: CreateProviderResponseInput): Promise<void> {
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) => {