From 6c68836455311c5be5918fd26283506dbf78e821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 29 May 2026 22:36:56 -0400 Subject: [PATCH] Add custom OpenAI- and Anthropic-compatible providers New 'Custom OpenAI-compatible' and 'Custom Anthropic-compatible' providers let an agent run against any compatible endpoint by base URL + key: the turn posts Chat Completions (/chat/completions) or Anthropic Messages (/v1/messages) at the configured base URL. Backfilled into existing orgs by migration 037. --- .../migrations/037-custom-providers.ts | 12 ++++++ AgentCompany-New/src/main/database/schema.ts | 2 + .../src/main/services/provider-adapters.ts | 40 +++++++++++++++++++ .../main/services/provider-auth-profiles.ts | 30 +++++++++++++- .../src/main/services/provider-tool-turn.ts | 10 +++++ .../credential-resolver.ts | 20 +++++++++- .../services/provider-tool-turns/router.ts | 4 ++ .../services/provider-tool-turns/turns.ts | 32 +++++++++++++++ .../src/shared/provider-catalog.ts | 18 +++++++++ .../tests/contract/provider-contracts.test.ts | 2 + .../tests/unit/domain-services.test.ts | 2 +- .../tests/unit/provider-tool-turns.test.ts | 2 +- 12 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 AgentCompany-New/src/main/database/migrations/037-custom-providers.ts diff --git a/AgentCompany-New/src/main/database/migrations/037-custom-providers.ts b/AgentCompany-New/src/main/database/migrations/037-custom-providers.ts new file mode 100644 index 00000000..ad6a541e --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/037-custom-providers.ts @@ -0,0 +1,12 @@ +export const customProvidersSchema = ` + insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at) + select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'custom-openai', 'Custom OpenAI-compatible', 'cloud', null, null, 0, datetime('now') + from organizations; + + insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at) + select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'custom-anthropic', 'Custom Anthropic-compatible', 'cloud', null, null, 0, datetime('now') + from organizations; + + insert into schema_migrations(version, name, applied_at) + values (37, 'custom_providers', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 6b594a33..3c0c9c99 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -35,6 +35,7 @@ import { auditEventImmutabilitySchema } from "./migrations/033-audit-event-immut import { providerAuthSessionsSchema } from "./migrations/034-provider-auth-sessions"; import { taskRunIsolationSchema } from "./migrations/035-task-run-isolation"; import { additionalConnectorsSchema } from "./migrations/036-additional-connectors"; +import { customProvidersSchema } from "./migrations/037-custom-providers"; interface Migration { version: number; @@ -79,6 +80,7 @@ const MIGRATIONS: Migration[] = [ { version: 34, name: "provider_auth_sessions", sql: providerAuthSessionsSchema }, { version: 35, name: "task_run_isolation", sql: taskRunIsolationSchema }, { version: 36, name: "additional_connectors", sql: additionalConnectorsSchema }, + { version: 37, name: "custom_providers", sql: customProvidersSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/provider-adapters.ts b/AgentCompany-New/src/main/services/provider-adapters.ts index cf332843..5e8c9a70 100644 --- a/AgentCompany-New/src/main/services/provider-adapters.ts +++ b/AgentCompany-New/src/main/services/provider-adapters.ts @@ -5,14 +5,18 @@ import { parseAccessToken, parseAccountToken, parseApiKey, + parseAnthropicCompatProfile, parseAzureProfile, parseBedrockProfile, + parseOpenAiCompatProfile, parseVertexProfile, requireAccessTokenProfile, requireAccountTokenProfile, + requireAnthropicCompatProfile, requireApiKeyProfile, requireAzureProfile, requireBedrockProfile, + requireOpenAiCompatProfile, requireVertexProfile, type ProviderAuthProfile, } from "./provider-auth-profiles"; @@ -330,6 +334,42 @@ export const providerAdapters: ProviderAdapterRegistry = { return healthy(null); }, }, + "custom-openai": { + providerKey: "custom-openai", + authType: PROVIDER_AUTH_TYPE.CLOUD, + authorize: authorizeWithParser(parseOpenAiCompatProfile), + refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"), + revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }), + parseAuthProfile: parseOpenAiCompatProfile, + listModels: () => listStaticModels("custom-openai"), + getRateLimitState: providerRateLimitUnknown, + streamResponse: streamUnsupported("Custom OpenAI-compatible endpoint"), + cancelResponse: cancelUnsupported("Custom OpenAI-compatible endpoint"), + createBackgroundResponse: backgroundUnsupported("Custom OpenAI-compatible endpoint"), + retrieveResponse: retrieveUnsupported("Custom OpenAI-compatible endpoint"), + checkHealth: async (input) => { + requireOpenAiCompatProfile(input.authProfile); + return healthy(null); + }, + }, + "custom-anthropic": { + providerKey: "custom-anthropic", + authType: PROVIDER_AUTH_TYPE.CLOUD, + authorize: authorizeWithParser(parseAnthropicCompatProfile), + refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"), + revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }), + parseAuthProfile: parseAnthropicCompatProfile, + listModels: () => listStaticModels("custom-anthropic"), + getRateLimitState: providerRateLimitUnknown, + streamResponse: streamUnsupported("Custom Anthropic-compatible endpoint"), + cancelResponse: cancelUnsupported("Custom Anthropic-compatible endpoint"), + createBackgroundResponse: backgroundUnsupported("Custom Anthropic-compatible endpoint"), + retrieveResponse: retrieveUnsupported("Custom Anthropic-compatible endpoint"), + checkHealth: async (input) => { + requireAnthropicCompatProfile(input.authProfile); + return healthy(null); + }, + }, }; function collectText(value: unknown): string[] { diff --git a/AgentCompany-New/src/main/services/provider-auth-profiles.ts b/AgentCompany-New/src/main/services/provider-auth-profiles.ts index 88e10577..19ffe39a 100644 --- a/AgentCompany-New/src/main/services/provider-auth-profiles.ts +++ b/AgentCompany-New/src/main/services/provider-auth-profiles.ts @@ -7,7 +7,9 @@ export type ProviderAuthProfile = | { 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: "vertex_ai"; projectId: string; region: string; serviceAccountJson: string } + | { type: "openai_compat"; baseUrl: string; apiKey: string } + | { type: "anthropic_compat"; baseUrl: string; apiKey: string }; export function parseApiKey(secret: string): ProviderAuthProfile { return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") }; @@ -71,6 +73,26 @@ export function parseVertexProfile(secret: string): ProviderAuthProfile { }; } +export function parseOpenAiCompatProfile(secret: string): ProviderAuthProfile { + const parsed = parseCloudSecret(secret); + return { type: "openai_compat", baseUrl: normalizedEndpointUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") }; +} + +export function parseAnthropicCompatProfile(secret: string): ProviderAuthProfile { + const parsed = parseCloudSecret(secret); + return { type: "anthropic_compat", baseUrl: normalizedEndpointUrl(requiredString(parsed.baseUrl, "Base URL"), "Base URL"), apiKey: requiredString(parsed.secret, "API key") }; +} + +export function requireOpenAiCompatProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "openai_compat") throw new Error("OpenAI-compatible profile is required."); + return profile; +} + +export function requireAnthropicCompatProfile(profile: ProviderAuthProfile): Extract { + if (profile.type !== "anthropic_compat") throw new Error("Anthropic-compatible profile is required."); + return profile; +} + export function requireApiKeyProfile(profile: ProviderAuthProfile, label: string): Extract { if (profile.type !== "api_key") throw new Error(`${label} API key profile is required.`); return profile; @@ -139,3 +161,9 @@ function normalizedHttpsUrl(value: string, label: string): string { if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`); return url.toString().replace(/\/+$/, ""); } + +function normalizedEndpointUrl(value: string, label: string): string { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`${label} must be an http(s) URL.`); + return url.toString().replace(/\/+$/, ""); +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turn.ts b/AgentCompany-New/src/main/services/provider-tool-turn.ts index a1233a35..867db307 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turn.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turn.ts @@ -39,6 +39,16 @@ export interface TurnCredential { accountId?: string; projectId?: string; enterpriseUrl?: string | null; + /** Base URL for Azure and custom OpenAI/Anthropic-compatible endpoints. */ + baseUrl?: string; + deployment?: string; + /** AWS Bedrock. */ + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string | null; + /** Vertex AI service-account JSON. */ + serviceAccountJson?: string; } export interface ProviderToolTurnInput { diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts index 10cdb5e0..8a8416b4 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts @@ -8,7 +8,7 @@ import { PROVIDER_AUTH_TYPE } from "../../../shared/domain"; import type { AuthBrokerService } from "../auth-broker-service"; import { loadCredential, type ProviderRow } from "../provider-internal"; -import { parseApiKey, requireApiKeyProfile } from "../provider-auth-profiles"; +import { parseAnthropicCompatProfile, parseApiKey, parseOpenAiCompatProfile, requireAnthropicCompatProfile, requireApiKeyProfile, requireOpenAiCompatProfile } from "../provider-auth-profiles"; import type { TurnCredential } from "../provider-tool-turn"; import type { OAuthCredentials } from "../oauth/oauth-types"; import { refreshCodex } from "../oauth/openai-codex-flow"; @@ -55,6 +55,21 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise = { "github-copilot": runCopilotTurn, "google-antigravity": runAntigravityTurn, "claude-pro": runClaudeProTurn, + "custom-openai": runCustomOpenAiTurn, + "custom-anthropic": runCustomAnthropicTurn, }; export function supportsToolTurn(providerKey: string): boolean { diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts index 518701a6..938ba792 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts @@ -154,6 +154,38 @@ export async function runCopilotTurn(input: ProviderToolTurnInput): Promise { + if (!input.credential.baseUrl) throw new Error("Custom OpenAI-compatible provider is missing its base URL."); + const body: JsonRecord = { + model: input.model, + messages: toChatMessages(input.instructions, input.messages), + tools: chatToolDefs(input.tools), + tool_choice: "auto", + stream: false, + }; + const response = await postJson(`${input.credential.baseUrl}/chat/completions`, { authorization: `Bearer ${requireApiKey(input.credential, "Custom OpenAI-compatible provider")}` }, body); + const parsed = parseChatChoice(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + +// ---- Custom Anthropic-compatible endpoint (base URL + key) ---- + +export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Promise { + if (!input.credential.baseUrl) throw new Error("Custom Anthropic-compatible provider is missing its base URL."); + const body: JsonRecord = { + model: input.model, + max_tokens: 4096, + tools: anthropicToolDefs(input.tools), + messages: toAnthropicMessages(input.messages), + ...(input.instructions ? { system: input.instructions } : {}), + }; + const response = await postJson(`${input.credential.baseUrl}/v1/messages`, { "x-api-key": requireApiKey(input.credential, "Custom Anthropic-compatible provider"), "anthropic-version": "2023-06-01" }, body); + const parsed = parseAnthropicContent(response.data); + return { ...parsed, rawResponseJson: JSON.stringify(response.data) }; +} + // ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ---- export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise { diff --git a/AgentCompany-New/src/shared/provider-catalog.ts b/AgentCompany-New/src/shared/provider-catalog.ts index 1671fa33..694f5bef 100644 --- a/AgentCompany-New/src/shared/provider-catalog.ts +++ b/AgentCompany-New/src/shared/provider-catalog.ts @@ -128,6 +128,24 @@ const RAW_CATALOG: Omit[] = [ signInUrl: null, credentialHelp: "Provide GCP project id, region, and a service-account JSON key with aiplatform.user role.", }, + { + key: "custom-openai", + label: "Custom OpenAI-compatible", + authType: PROVIDER_AUTH_TYPE.CLOUD, + defaultModel: null, + capabilityTags: ["custom", "openai-compatible"], + signInUrl: null, + credentialHelp: "Set the base URL (ending in /v1) and an API key for any OpenAI Chat Completions–compatible endpoint, then enter the model id.", + }, + { + key: "custom-anthropic", + label: "Custom Anthropic-compatible", + authType: PROVIDER_AUTH_TYPE.CLOUD, + defaultModel: null, + capabilityTags: ["custom", "anthropic-compatible"], + signInUrl: null, + credentialHelp: "Set the base URL and an API key for any Anthropic Messages–compatible endpoint, then enter the model id.", + }, ]; export const PROVIDER_CATALOG: ProviderCatalogEntry[] = RAW_CATALOG.map((entry) => ({ diff --git a/AgentCompany-New/tests/contract/provider-contracts.test.ts b/AgentCompany-New/tests/contract/provider-contracts.test.ts index 77c065af..955413a5 100644 --- a/AgentCompany-New/tests/contract/provider-contracts.test.ts +++ b/AgentCompany-New/tests/contract/provider-contracts.test.ts @@ -35,6 +35,8 @@ const PROVIDER_SECRET_SAMPLES: Record = { region: "us-central1", secret: JSON.stringify({ client_email: "agent@acme.iam.gserviceaccount.com", private_key: "key" }), }), + "custom-openai": JSON.stringify({ baseUrl: "https://api.example.com/v1", secret: "sk-custom" }), + "custom-anthropic": JSON.stringify({ baseUrl: "https://llm.example.com", secret: "sk-custom" }), }; function createConnection(): DatabaseConnection { diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index fbc897af..bde38573 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37]); connection.close(); }); diff --git a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts index 3e0e07ac..6dbc0033 100644 --- a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts +++ b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts @@ -126,7 +126,7 @@ describe("antigravity (gemini) encoding", () => { describe("tool-turn router", () => { test("supports every agent-capable provider and rejects cloud-only ones", () => { - for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "gpt-account", "github-copilot", "google-antigravity", "claude-pro"]) { + for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "custom-openai", "custom-anthropic"]) { expect(supportsToolTurn(key)).toBe(true); expect(typeof selectToolTurn(key)).toBe("function"); }