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.
This commit is contained in:
1 parent
937fdfa23f
commit
6c68836455
12 files changed
+170
-4
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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<ProviderAuthProfile, { type: "openai_compat" }> {
|
||||
if (profile.type !== "openai_compat") throw new Error("OpenAI-compatible profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function requireAnthropicCompatProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "anthropic_compat" }> {
|
||||
if (profile.type !== "anthropic_compat") throw new Error("Anthropic-compatible profile is required.");
|
||||
return profile;
|
||||
}
|
||||
|
||||
export 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;
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<OAuthCre
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCloudCredential(providerKey: string, secret: string): TurnCredential {
|
||||
switch (providerKey) {
|
||||
case "custom-openai": {
|
||||
const profile = requireOpenAiCompatProfile(parseOpenAiCompatProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
||||
}
|
||||
case "custom-anthropic": {
|
||||
const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
||||
}
|
||||
default:
|
||||
throw new Error(`${providerKey} does not support agent execution.`);
|
||||
}
|
||||
}
|
||||
|
||||
function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential {
|
||||
const enterpriseUrl = creds.enterpriseUrl;
|
||||
return {
|
||||
@@ -74,6 +89,9 @@ export async function resolveTurnCredential(deps: {
|
||||
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.API_KEY) {
|
||||
return { apiKey: requireApiKeyProfile(parseApiKey(secret), deps.provider.provider_key).apiKey };
|
||||
}
|
||||
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.CLOUD) {
|
||||
return resolveCloudCredential(deps.provider.provider_key, secret);
|
||||
}
|
||||
const creds = parseStoredOAuth(secret);
|
||||
if (Date.now() < creds.expires) return toTurnCredential(creds);
|
||||
if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider.");
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
runClaudeProTurn,
|
||||
runCodexTurn,
|
||||
runCopilotTurn,
|
||||
runCustomAnthropicTurn,
|
||||
runCustomOpenAiTurn,
|
||||
runOpenAiResponsesTurn,
|
||||
} from "./turns";
|
||||
|
||||
@@ -22,6 +24,8 @@ const TURNS: Record<string, ToolTurnFn> = {
|
||||
"github-copilot": runCopilotTurn,
|
||||
"google-antigravity": runAntigravityTurn,
|
||||
"claude-pro": runClaudeProTurn,
|
||||
"custom-openai": runCustomOpenAiTurn,
|
||||
"custom-anthropic": runCustomAnthropicTurn,
|
||||
};
|
||||
|
||||
export function supportsToolTurn(providerKey: string): boolean {
|
||||
|
||||
@@ -154,6 +154,38 @@ export async function runCopilotTurn(input: ProviderToolTurnInput): Promise<Agen
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Custom OpenAI-compatible endpoint (base URL + key) ----
|
||||
|
||||
export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
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<AgentTurnResult> {
|
||||
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<AgentTurnResult> {
|
||||
|
||||
@@ -128,6 +128,24 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models">[] = [
|
||||
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) => ({
|
||||
|
||||
@@ -35,6 +35,8 @@ const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user