mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-09 03:22:40 +00:00
feat(providers): add ClawRouter managed proxy
This commit is contained in:
40
extensions/clawrouter/index.test.ts
Normal file
40
extensions/clawrouter/index.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("clawrouter provider plugin", () => {
|
||||
it("registers managed proxy-key auth and dynamic routing hooks", () => {
|
||||
const captured = capturePluginRegistration(plugin);
|
||||
const provider = captured.providers[0];
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: "clawrouter",
|
||||
label: "ClawRouter",
|
||||
docsPath: "/providers/clawrouter",
|
||||
envVars: ["CLAWROUTER_API_KEY"],
|
||||
isModernModelRef: expect.any(Function),
|
||||
normalizeResolvedModel: expect.any(Function),
|
||||
resolveDynamicModel: expect.any(Function),
|
||||
});
|
||||
expect(provider?.auth[0]).toMatchObject({
|
||||
id: "api-key",
|
||||
label: "ClawRouter proxy key",
|
||||
kind: "api_key",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes configured ClawRouter roots to the API base URL", () => {
|
||||
const provider = capturePluginRegistration(plugin).providers[0];
|
||||
const normalized = provider?.normalizeConfig?.({
|
||||
provider: "clawrouter",
|
||||
providerConfig: {
|
||||
baseUrl: "https://clawrouter.example/",
|
||||
models: [],
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(normalized).toMatchObject({
|
||||
baseUrl: "https://clawrouter.example/v1",
|
||||
});
|
||||
});
|
||||
});
|
||||
88
extensions/clawrouter/index.ts
Normal file
88
extensions/clawrouter/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// ClawRouter plugin entrypoint registers credential-scoped model routing.
|
||||
import { definePluginEntry, type ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildClawRouterProviderConfig,
|
||||
normalizeClawRouterApiBaseUrl,
|
||||
normalizeClawRouterResolvedModel,
|
||||
resolveDiscoveredClawRouterModel,
|
||||
} from "./provider-catalog.js";
|
||||
|
||||
const PROVIDER_ID = "clawrouter";
|
||||
const ENV_VAR = "CLAWROUTER_API_KEY";
|
||||
|
||||
function buildApiKeyAuth(): ProviderAuthMethod {
|
||||
return createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "ClawRouter proxy key",
|
||||
hint: "Credential-scoped access to approved providers",
|
||||
optionKey: "clawrouterApiKey",
|
||||
flagName: "--clawrouter-api-key",
|
||||
envVar: ENV_VAR,
|
||||
promptMessage: "Enter ClawRouter proxy key",
|
||||
noteTitle: "ClawRouter",
|
||||
noteMessage: [
|
||||
"Use the proxy key issued by your ClawRouter administrator.",
|
||||
"OpenClaw discovers only the models granted to that key.",
|
||||
].join("\n"),
|
||||
wizard: {
|
||||
choiceId: "clawrouter-api-key",
|
||||
choiceLabel: "ClawRouter proxy key",
|
||||
choiceHint: "Approved providers through one managed key",
|
||||
groupId: PROVIDER_ID,
|
||||
groupLabel: "ClawRouter",
|
||||
groupHint: "Managed provider access",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "ClawRouter Provider",
|
||||
description: "Bundled ClawRouter provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "ClawRouter",
|
||||
docsPath: "/providers/clawrouter",
|
||||
envVars: [ENV_VAR],
|
||||
auth: [buildApiKeyAuth()],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = auth.apiKey ?? auth.discoveryApiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const configuredBaseUrl = ctx.config.models?.providers?.[PROVIDER_ID]?.baseUrl;
|
||||
try {
|
||||
return {
|
||||
provider: await buildClawRouterProviderConfig({
|
||||
apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
baseUrl: configuredBaseUrl,
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl);
|
||||
return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;
|
||||
},
|
||||
resolveDynamicModel: ({ modelId, providerConfig }) =>
|
||||
resolveDiscoveredClawRouterModel({
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
modelId,
|
||||
}),
|
||||
normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model),
|
||||
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
|
||||
isModernModelRef: () => true,
|
||||
});
|
||||
},
|
||||
});
|
||||
37
extensions/clawrouter/openclaw.plugin.json
Normal file
37
extensions/clawrouter/openclaw.plugin.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "clawrouter",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["clawrouter"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "clawrouter",
|
||||
"envVars": ["CLAWROUTER_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "clawrouter",
|
||||
"method": "api-key",
|
||||
"choiceId": "clawrouter-api-key",
|
||||
"choiceLabel": "ClawRouter proxy key",
|
||||
"choiceHint": "Approved providers through one managed key",
|
||||
"groupId": "clawrouter",
|
||||
"groupLabel": "ClawRouter",
|
||||
"groupHint": "Managed provider access",
|
||||
"optionKey": "clawrouterApiKey",
|
||||
"cliFlag": "--clawrouter-api-key",
|
||||
"cliOption": "--clawrouter-api-key <key>",
|
||||
"cliDescription": "ClawRouter proxy key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
extensions/clawrouter/package.json
Normal file
15
extensions/clawrouter/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/clawrouter-provider",
|
||||
"version": "2026.6.2",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClawRouter provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
226
extensions/clawrouter/provider-catalog.test.ts
Normal file
226
extensions/clawrouter/provider-catalog.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
clearLiveCatalogCacheForTests,
|
||||
type LiveModelCatalogFetchGuard,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
|
||||
import {
|
||||
buildClawRouterProviderConfig,
|
||||
clearClawRouterCatalogForTests,
|
||||
normalizeClawRouterResolvedModel,
|
||||
resolveDiscoveredClawRouterModel,
|
||||
} from "./provider-catalog.js";
|
||||
|
||||
const CATALOG = {
|
||||
version: "clawrouter.client-catalog.v1",
|
||||
providers: [
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
openAiCompatible: true,
|
||||
nativeBaseUrl: "/v1/native/openai",
|
||||
routes: [
|
||||
{
|
||||
path: "/v1/responses",
|
||||
methods: ["POST"],
|
||||
requestFormat: "openai.responses",
|
||||
responseFormat: "openai.responses",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "openai/gpt-5.5-mini",
|
||||
upstream: "gpt-5.5-mini",
|
||||
capabilities: ["llm.responses", "llm.chat"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
openAiCompatible: false,
|
||||
nativeBaseUrl: "/v1/native/anthropic",
|
||||
routes: [
|
||||
{
|
||||
path: "/v1/messages",
|
||||
methods: ["POST"],
|
||||
requestFormat: "anthropic.messages",
|
||||
responseFormat: "anthropic.messages",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "anthropic/default",
|
||||
upstream: "claude-sonnet-4-5-20250929",
|
||||
capabilities: ["llm.messages"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "google-gemini",
|
||||
displayName: "Google Gemini",
|
||||
openAiCompatible: false,
|
||||
nativeBaseUrl: "/v1/native/google-gemini",
|
||||
routes: [
|
||||
{
|
||||
path: "/v1beta/models/${model}:generateContent",
|
||||
methods: ["POST"],
|
||||
requestFormat: "google.generate_content",
|
||||
responseFormat: "google.generate_content",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "google/gemini-default",
|
||||
upstream: "gemini",
|
||||
capabilities: ["llm.generate"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cohere",
|
||||
displayName: "Cohere",
|
||||
openAiCompatible: false,
|
||||
nativeBaseUrl: "/v1/native/cohere",
|
||||
routes: [
|
||||
{
|
||||
path: "/v2/chat",
|
||||
methods: ["POST"],
|
||||
requestFormat: "cohere.chat",
|
||||
responseFormat: "cohere.chat",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "cohere/default",
|
||||
upstream: "command-a",
|
||||
capabilities: ["llm.chat"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function buildFetchGuard(): {
|
||||
fetchGuard: LiveModelCatalogFetchGuard;
|
||||
fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard>;
|
||||
} {
|
||||
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
|
||||
response: new Response(JSON.stringify(CATALOG)),
|
||||
finalUrl: "https://clawrouter.example/v1/catalog",
|
||||
release: async () => undefined,
|
||||
}));
|
||||
return { fetchGuard: fetchGuardMock, fetchGuardMock };
|
||||
}
|
||||
|
||||
describe("clawrouter provider catalog", () => {
|
||||
beforeEach(() => {
|
||||
clearLiveCatalogCacheForTests();
|
||||
clearClawRouterCatalogForTests();
|
||||
});
|
||||
|
||||
it("maps credential-scoped catalog rows to their real provider transports", async () => {
|
||||
const { fetchGuard, fetchGuardMock } = buildFetchGuard();
|
||||
const provider = await buildClawRouterProviderConfig({
|
||||
apiKey: "clawrouter-test-key",
|
||||
baseUrl: "https://clawrouter.example/v1",
|
||||
fetchGuard,
|
||||
});
|
||||
|
||||
expect(fetchGuardMock).toHaveBeenCalledOnce();
|
||||
expect(provider).toMatchObject({
|
||||
api: "openai-responses",
|
||||
apiKey: "clawrouter-test-key",
|
||||
authHeader: true,
|
||||
baseUrl: "https://clawrouter.example/v1",
|
||||
});
|
||||
expect(provider.models.map((model) => model.id)).toEqual([
|
||||
"anthropic/default",
|
||||
"google/gemini-default",
|
||||
"openai/gpt-5.5-mini",
|
||||
]);
|
||||
|
||||
expect(provider.models.find((model) => model.id === "openai/gpt-5.5-mini")).toMatchObject({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://clawrouter.example/v1",
|
||||
});
|
||||
expect(provider.models.find((model) => model.id === "anthropic/default")).toMatchObject({
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://clawrouter.example/v1/native/anthropic",
|
||||
});
|
||||
expect(provider.models.find((model) => model.id === "google/gemini-default")).toMatchObject({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://clawrouter.example/v1/native/google-gemini/v1beta",
|
||||
});
|
||||
|
||||
const anthropic = provider.models.find((model) => model.id === "anthropic/default");
|
||||
const normalized = normalizeClawRouterResolvedModel({
|
||||
...anthropic,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel);
|
||||
expect(normalized?.id).toBe("claude-sonnet-4-5-20250929");
|
||||
|
||||
const dynamic = resolveDiscoveredClawRouterModel({
|
||||
baseUrl: provider.baseUrl,
|
||||
modelId: "google/gemini-default",
|
||||
});
|
||||
expect(dynamic).toMatchObject({
|
||||
id: "google/gemini-default",
|
||||
provider: "clawrouter",
|
||||
api: "google-generative-ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("caches the auth-scoped catalog for the discovery TTL", async () => {
|
||||
const { fetchGuard, fetchGuardMock } = buildFetchGuard();
|
||||
const params = {
|
||||
apiKey: "clawrouter-test-key",
|
||||
baseUrl: "https://clawrouter.example",
|
||||
fetchGuard,
|
||||
};
|
||||
|
||||
await buildClawRouterProviderConfig(params);
|
||||
await buildClawRouterProviderConfig(params);
|
||||
|
||||
expect(fetchGuardMock).toHaveBeenCalledOnce();
|
||||
const headers = fetchGuardMock.mock.calls[0]?.[0].init?.headers;
|
||||
expect(headers).toBeInstanceOf(Headers);
|
||||
expect((headers as Headers).get("authorization")).toBe("Bearer clawrouter-test-key");
|
||||
});
|
||||
|
||||
it("replaces stale discovery state when the active catalog changes", async () => {
|
||||
const first = buildFetchGuard();
|
||||
const provider = await buildClawRouterProviderConfig({
|
||||
apiKey: "first-key",
|
||||
baseUrl: "https://first.example",
|
||||
fetchGuard: first.fetchGuard,
|
||||
});
|
||||
const anthropic = provider.models.find((model) => model.id === "anthropic/default");
|
||||
|
||||
const second = buildFetchGuard();
|
||||
await buildClawRouterProviderConfig({
|
||||
apiKey: "second-key",
|
||||
baseUrl: "https://second.example",
|
||||
fetchGuard: second.fetchGuard,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveDiscoveredClawRouterModel({
|
||||
baseUrl: "https://first.example/v1",
|
||||
modelId: "openai/gpt-5.5-mini",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveDiscoveredClawRouterModel({
|
||||
baseUrl: "https://second.example/v1",
|
||||
modelId: "openai/gpt-5.5-mini",
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
normalizeClawRouterResolvedModel({
|
||||
...anthropic,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
306
extensions/clawrouter/provider-catalog.ts
Normal file
306
extensions/clawrouter/provider-catalog.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// ClawRouter provider catalog maps credential-scoped routes to OpenClaw transports.
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
getCachedLiveProviderModelRows,
|
||||
type LiveModelCatalogFetchGuard,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
export const CLAWROUTER_DEFAULT_BASE_URL = "https://clawrouter.openclaw.ai";
|
||||
|
||||
const PROVIDER_ID = "clawrouter";
|
||||
const CATALOG_CACHE_TTL_MS = 60_000;
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const DEFAULT_MAX_TOKENS = 32_768;
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
type CatalogRoute = {
|
||||
path: string;
|
||||
requestFormat: string;
|
||||
methods: string[];
|
||||
};
|
||||
|
||||
type CatalogModel = {
|
||||
id: string;
|
||||
upstream: string;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
type CatalogProvider = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
openAiCompatible: boolean;
|
||||
nativeBaseUrl: string;
|
||||
routes: CatalogRoute[];
|
||||
models: CatalogModel[];
|
||||
};
|
||||
|
||||
type RoutedModel = {
|
||||
definition: ModelDefinitionConfig;
|
||||
upstreamModel?: string;
|
||||
};
|
||||
|
||||
type CatalogSnapshot = {
|
||||
apiBaseUrl: string;
|
||||
modelsByRoute: Map<string, ModelDefinitionConfig>;
|
||||
nativeModelIds: Map<string, string>;
|
||||
};
|
||||
|
||||
let catalogSnapshot: CatalogSnapshot | undefined;
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.map(readString).filter((entry): entry is string => Boolean(entry))
|
||||
: [];
|
||||
}
|
||||
|
||||
function readCatalogRows(body: unknown): readonly unknown[] {
|
||||
const providers = readRecord(body)?.providers;
|
||||
if (!Array.isArray(providers)) {
|
||||
throw new Error("ClawRouter catalog response must contain providers[]");
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
function parseCatalogRoute(value: unknown): CatalogRoute | undefined {
|
||||
const row = readRecord(value);
|
||||
const path = readString(row?.path);
|
||||
const requestFormat = readString(row?.requestFormat);
|
||||
if (!path || !requestFormat) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
path,
|
||||
requestFormat,
|
||||
methods: readStringArray(row?.methods).map((method) => method.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalogModel(value: unknown): CatalogModel | undefined {
|
||||
const row = readRecord(value);
|
||||
const id = readString(row?.id);
|
||||
const upstream = readString(row?.upstream);
|
||||
if (!id || !upstream) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
upstream,
|
||||
capabilities: readStringArray(row?.capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCatalogProvider(value: unknown): CatalogProvider | undefined {
|
||||
const row = readRecord(value);
|
||||
const id = readString(row?.id);
|
||||
const nativeBaseUrl = readString(row?.nativeBaseUrl);
|
||||
if (!id || !nativeBaseUrl || !nativeBaseUrl.startsWith("/v1/native/")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
displayName: readString(row?.displayName) ?? id,
|
||||
openAiCompatible: row?.openAiCompatible === true,
|
||||
nativeBaseUrl,
|
||||
routes: Array.isArray(row?.routes)
|
||||
? row.routes.map(parseCatalogRoute).filter((route): route is CatalogRoute => Boolean(route))
|
||||
: [],
|
||||
models: Array.isArray(row?.models)
|
||||
? row.models.map(parseCatalogModel).filter((model): model is CatalogModel => Boolean(model))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function normalizeClawRouterRootUrl(baseUrl: string | undefined): string {
|
||||
const normalized = trimTrailingSlashes(baseUrl?.trim() || CLAWROUTER_DEFAULT_BASE_URL);
|
||||
return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
|
||||
}
|
||||
|
||||
export function normalizeClawRouterApiBaseUrl(baseUrl: string | undefined): string {
|
||||
return `${normalizeClawRouterRootUrl(baseUrl)}/v1`;
|
||||
}
|
||||
|
||||
function routeKey(baseUrl: string, modelId: string): string {
|
||||
return `${trimTrailingSlashes(baseUrl)}\0${modelId}`;
|
||||
}
|
||||
|
||||
function supportsCapability(model: CatalogModel, ...capabilities: string[]): boolean {
|
||||
return capabilities.some((capability) => model.capabilities.includes(capability));
|
||||
}
|
||||
|
||||
function findNativeRoute(
|
||||
provider: CatalogProvider,
|
||||
requestFormat: string,
|
||||
): CatalogRoute | undefined {
|
||||
return provider.routes.find(
|
||||
(route) => route.methods.includes("POST") && route.requestFormat === requestFormat,
|
||||
);
|
||||
}
|
||||
|
||||
function googleNativeBaseUrl(rootUrl: string, provider: CatalogProvider, route: CatalogRoute) {
|
||||
const modelPathIndex = route.path.indexOf("/models/${model}");
|
||||
if (modelPathIndex <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `${rootUrl}${provider.nativeBaseUrl}${route.path.slice(0, modelPathIndex)}`;
|
||||
}
|
||||
|
||||
function buildRoutedModel(
|
||||
rootUrl: string,
|
||||
provider: CatalogProvider,
|
||||
model: CatalogModel,
|
||||
): RoutedModel | undefined {
|
||||
let api: ModelDefinitionConfig["api"];
|
||||
let baseUrl: string;
|
||||
let upstreamModel: string | undefined;
|
||||
|
||||
if (provider.openAiCompatible && supportsCapability(model, "llm.responses")) {
|
||||
api = "openai-responses";
|
||||
baseUrl = `${rootUrl}/v1`;
|
||||
} else if (provider.openAiCompatible && supportsCapability(model, "llm.chat")) {
|
||||
api = "openai-completions";
|
||||
baseUrl = `${rootUrl}/v1`;
|
||||
} else if (
|
||||
supportsCapability(model, "llm.messages") &&
|
||||
findNativeRoute(provider, "anthropic.messages")
|
||||
) {
|
||||
api = "anthropic-messages";
|
||||
baseUrl = `${rootUrl}${provider.nativeBaseUrl}`;
|
||||
upstreamModel = model.upstream;
|
||||
} else {
|
||||
const googleRoute =
|
||||
supportsCapability(model, "llm.generate", "llm.stream") &&
|
||||
findNativeRoute(provider, "google.generate_content");
|
||||
const googleBaseUrl = googleRoute
|
||||
? googleNativeBaseUrl(rootUrl, provider, googleRoute)
|
||||
: undefined;
|
||||
if (!googleBaseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
api = "google-generative-ai";
|
||||
baseUrl = googleBaseUrl;
|
||||
upstreamModel = model.upstream;
|
||||
}
|
||||
|
||||
return {
|
||||
definition: {
|
||||
id: model.id,
|
||||
name: `${provider.displayName}: ${model.id}`,
|
||||
api,
|
||||
baseUrl,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
upstreamModel,
|
||||
};
|
||||
}
|
||||
|
||||
function updateDiscoveredModels(
|
||||
rootUrl: string,
|
||||
providers: CatalogProvider[],
|
||||
): ModelDefinitionConfig[] {
|
||||
const models = new Map<string, ModelDefinitionConfig>();
|
||||
const modelsByRoute = new Map<string, ModelDefinitionConfig>();
|
||||
const nativeModelIds = new Map<string, string>();
|
||||
for (const provider of providers) {
|
||||
for (const model of provider.models) {
|
||||
const routed = buildRoutedModel(rootUrl, provider, model);
|
||||
if (!routed || models.has(routed.definition.id)) {
|
||||
continue;
|
||||
}
|
||||
models.set(routed.definition.id, routed.definition);
|
||||
const key = routeKey(routed.definition.baseUrl ?? `${rootUrl}/v1`, routed.definition.id);
|
||||
modelsByRoute.set(key, routed.definition);
|
||||
modelsByRoute.set(routeKey(`${rootUrl}/v1`, routed.definition.id), routed.definition);
|
||||
if (routed.upstreamModel) {
|
||||
nativeModelIds.set(key, routed.upstreamModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Discovery owns one active provider config, so replace the whole snapshot.
|
||||
// Keeping older credential-scoped catalogs would leak stale grants and grow forever.
|
||||
catalogSnapshot = {
|
||||
apiBaseUrl: `${rootUrl}/v1`,
|
||||
modelsByRoute,
|
||||
nativeModelIds,
|
||||
};
|
||||
return [...models.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export async function buildClawRouterProviderConfig(params: {
|
||||
apiKey: string;
|
||||
discoveryApiKey?: string;
|
||||
baseUrl?: string;
|
||||
fetchGuard?: LiveModelCatalogFetchGuard;
|
||||
}): Promise<ModelProviderConfig> {
|
||||
const rootUrl = normalizeClawRouterRootUrl(params.baseUrl);
|
||||
const rows = await getCachedLiveProviderModelRows({
|
||||
providerId: PROVIDER_ID,
|
||||
endpoint: `${rootUrl}/v1/catalog`,
|
||||
apiKey: params.apiKey,
|
||||
discoveryApiKey: params.discoveryApiKey,
|
||||
fetchGuard: params.fetchGuard,
|
||||
readRows: readCatalogRows,
|
||||
ttlMs: CATALOG_CACHE_TTL_MS,
|
||||
shouldCacheRows: (providers) => providers.length > 0,
|
||||
auditContext: "clawrouter-model-discovery",
|
||||
});
|
||||
const providers = rows
|
||||
.map(parseCatalogProvider)
|
||||
.filter((provider): provider is CatalogProvider => Boolean(provider));
|
||||
return {
|
||||
baseUrl: `${rootUrl}/v1`,
|
||||
api: "openai-responses",
|
||||
apiKey: params.apiKey,
|
||||
authHeader: true,
|
||||
models: updateDiscoveredModels(rootUrl, providers),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDiscoveredClawRouterModel(params: {
|
||||
baseUrl?: string;
|
||||
modelId: string;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const apiBaseUrl = normalizeClawRouterApiBaseUrl(params.baseUrl);
|
||||
if (catalogSnapshot?.apiBaseUrl !== apiBaseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const model = catalogSnapshot.modelsByRoute.get(routeKey(apiBaseUrl, params.modelId));
|
||||
return model ? { ...model, provider: PROVIDER_ID } : undefined;
|
||||
}
|
||||
|
||||
export function normalizeClawRouterResolvedModel(
|
||||
model: ProviderRuntimeModel,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
const upstreamModel = catalogSnapshot?.nativeModelIds.get(routeKey(model.baseUrl, model.id));
|
||||
return upstreamModel && upstreamModel !== model.id ? { ...model, id: upstreamModel } : undefined;
|
||||
}
|
||||
|
||||
export function clearClawRouterCatalogForTests(): void {
|
||||
catalogSnapshot = undefined;
|
||||
}
|
||||
8
extensions/clawrouter/tsconfig.json
Normal file
8
extensions/clawrouter/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts"],
|
||||
"exclude": ["./**/*.test.ts", "./dist/**", "./node_modules/**"]
|
||||
}
|
||||
@@ -433,6 +433,7 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([
|
||||
"byteplus-plan",
|
||||
"cerebras",
|
||||
"chutes",
|
||||
"clawrouter",
|
||||
"cloudflare-ai-gateway",
|
||||
"codex",
|
||||
"comfy",
|
||||
|
||||
Reference in New Issue
Block a user