mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
revert(providers): remove ClawRouter provider
This commit is contained in:
@@ -381,11 +381,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/anthropic/**"
|
||||
"extensions: clawrouter":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/clawrouter/**"
|
||||
- "docs/providers/clawrouter.md"
|
||||
"extensions: cloudflare-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -1418,7 +1418,6 @@
|
||||
"providers/cerebras",
|
||||
"providers/chutes",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/clawrouter",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/comfy",
|
||||
"providers/deepgram",
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
91 plugins
|
||||
90 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -75,8 +75,6 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[chutes](/plugins/reference/chutes)** (`@openclaw/chutes-provider`) - included in OpenClaw. Adds Chutes model provider support to OpenClaw.
|
||||
|
||||
- **[clawrouter](/plugins/reference/clawrouter)** (`@openclaw/clawrouter-provider`) - included in OpenClaw. Adds ClawRouter model provider support to OpenClaw.
|
||||
|
||||
- **[clickclack](/plugins/reference/clickclack)** (`@openclaw/clickclack`) - included in OpenClaw. Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
|
||||
|
||||
- **[cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway)** (`@openclaw/cloudflare-ai-gateway-provider`) - included in OpenClaw. Adds Cloudflare AI Gateway model provider support to OpenClaw.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
summary: "Adds ClawRouter model provider support to OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the clawrouter plugin
|
||||
title: "ClawRouter plugin"
|
||||
---
|
||||
|
||||
# ClawRouter plugin
|
||||
|
||||
Adds ClawRouter model provider support to OpenClaw.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/clawrouter-provider`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
providers: clawrouter
|
||||
|
||||
## Related docs
|
||||
|
||||
- [clawrouter](/providers/clawrouter)
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
summary: "Use one managed ClawRouter key to access approved providers in OpenClaw"
|
||||
title: "ClawRouter"
|
||||
read_when:
|
||||
- You have a ClawRouter proxy key
|
||||
- Your team centrally manages provider access and grants
|
||||
- You want OpenClaw to discover only the models you can use
|
||||
---
|
||||
|
||||
ClawRouter gives OpenClaw one managed credential for approved model providers.
|
||||
The upstream API keys, OAuth grants, and subscription credentials stay in
|
||||
ClawRouter.
|
||||
|
||||
## Setup
|
||||
|
||||
Authenticate with the proxy key issued by your ClawRouter administrator:
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice clawrouter-api-key
|
||||
```
|
||||
|
||||
Or provide it through the environment:
|
||||
|
||||
```bash
|
||||
export CLAWROUTER_API_KEY="clawrouter-live-..."
|
||||
```
|
||||
|
||||
OpenClaw asks `https://clawrouter.openclaw.ai/v1/catalog` for the models granted
|
||||
to that key and caches the credential-scoped result for a short period. Model
|
||||
references keep the ClawRouter provider prefix:
|
||||
|
||||
```bash
|
||||
openclaw models list --provider clawrouter
|
||||
openclaw models set clawrouter/openai/gpt-5.5-mini
|
||||
```
|
||||
|
||||
ClawRouter publishes the real transport for each model. OpenClaw uses the
|
||||
unified OpenAI route when available and the provider-native Anthropic or Gemini
|
||||
route when required.
|
||||
|
||||
## Custom deployment
|
||||
|
||||
For a self-hosted ClawRouter, configure its API base URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
clawrouter: {
|
||||
baseUrl: "https://clawrouter.example/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The proxy key still determines which providers and models are visible and
|
||||
usable. Grant changes appear after the short discovery cache expires.
|
||||
@@ -33,7 +33,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
|
||||
- [Cerebras](/providers/cerebras)
|
||||
- [Chutes](/providers/chutes)
|
||||
- [ClawRouter](/providers/clawrouter)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [ComfyUI](/providers/comfy)
|
||||
- [DeepSeek](/providers/deepseek)
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const providerAuthRuntimeMocks = vi.hoisted(() => ({
|
||||
resolveApiKeyForProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks);
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
const LIVE_CATALOG = {
|
||||
providers: [
|
||||
{
|
||||
id: "openai",
|
||||
displayName: "OpenAI",
|
||||
openaiCompatible: true,
|
||||
nativeBaseUrl: "/v1/native/openai",
|
||||
routes: [
|
||||
{
|
||||
path: "/v1/responses",
|
||||
methods: ["POST"],
|
||||
requestFormat: "openai.responses",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "openai/gpt-5.5-mini",
|
||||
upstream: "gpt-5.5-mini",
|
||||
capabilities: ["llm.responses"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("clawrouter provider plugin", () => {
|
||||
beforeEach(() => {
|
||||
clearLiveCatalogCacheForTests();
|
||||
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("registers managed proxy-key auth and transport 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),
|
||||
buildReplayPolicy: expect.any(Function),
|
||||
normalizeResolvedModel: expect.any(Function),
|
||||
sanitizeReplayHistory: expect.any(Function),
|
||||
wrapSimpleCompletionStreamFn: expect.any(Function),
|
||||
wrapStreamFn: expect.any(Function),
|
||||
});
|
||||
expect(provider?.auth[0]).toMatchObject({
|
||||
id: "api-key",
|
||||
label: "ClawRouter proxy key",
|
||||
kind: "api_key",
|
||||
});
|
||||
expect(provider?.wrapSimpleCompletionStreamFn).toBe(provider?.wrapStreamFn);
|
||||
});
|
||||
|
||||
it("attaches the resolved proxy key only when dispatching a request", () => {
|
||||
const provider = capturePluginRegistration(plugin).providers[0];
|
||||
const calls: Array<Parameters<StreamFn>[0]> = [];
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
calls.push(model);
|
||||
return {} as ReturnType<StreamFn>;
|
||||
};
|
||||
const wrapped = provider?.wrapStreamFn?.({
|
||||
provider: "clawrouter",
|
||||
modelId: "anthropic/default",
|
||||
streamFn: baseStreamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
provider: "clawrouter",
|
||||
api: "anthropic-messages",
|
||||
id: "anthropic/default",
|
||||
headers: { "X-Request-ID": "request-1" },
|
||||
params: {
|
||||
clawrouterRoute: {
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://clawrouter.example/v1/native/anthropic",
|
||||
upstreamModel: "claude-sonnet-4-5-20250929",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
{ apiKey: "runtime-proxy-key" } as never,
|
||||
);
|
||||
void wrapped?.(
|
||||
{
|
||||
provider: "clawrouter",
|
||||
api: "anthropic-messages",
|
||||
id: "anthropic/default",
|
||||
params: {
|
||||
clawrouterRoute: {
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://clawrouter.example/v1/native/anthropic",
|
||||
upstreamModel: "claude-sonnet-4-5-20250929",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
{ apiKey: "CLAWROUTER_API_KEY" } as never,
|
||||
);
|
||||
|
||||
expect(calls[0]?.headers).toEqual({
|
||||
"X-Request-ID": "request-1",
|
||||
Authorization: "Bearer runtime-proxy-key",
|
||||
});
|
||||
expect(calls[0]?.id).toBe("claude-sonnet-4-5-20250929");
|
||||
expect(calls[0]?.params).toBeUndefined();
|
||||
expect(calls[1]?.headers).toBeUndefined();
|
||||
expect(calls[1]?.id).toBe("claude-sonnet-4-5-20250929");
|
||||
expect(calls[1]?.params).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves managed secret refs before credential-scoped discovery", async () => {
|
||||
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
|
||||
apiKey: "resolved-proxy-key",
|
||||
mode: "api-key",
|
||||
source: "models.json secretref",
|
||||
});
|
||||
const fetchMock = vi.fn(async () => Response.json(LIVE_CATALOG));
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const provider = capturePluginRegistration(plugin).providers[0];
|
||||
|
||||
const result = await provider?.catalog?.run({
|
||||
config: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
env: {},
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "secretref-managed",
|
||||
discoveryApiKey: undefined,
|
||||
mode: "api_key",
|
||||
source: "profile",
|
||||
profileId: "clawrouter-profile",
|
||||
}),
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "secretref-managed",
|
||||
discoveryApiKey: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result || !("provider" in result)) {
|
||||
throw new Error("expected ClawRouter catalog provider result");
|
||||
}
|
||||
expect(result.provider.apiKey).toBe("secretref-managed");
|
||||
expect(result.provider.models.map((model) => model.id)).toEqual(["openai/gpt-5.5-mini"]);
|
||||
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
|
||||
provider: "clawrouter",
|
||||
cfg: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
profileId: "clawrouter-profile",
|
||||
lockedProfile: true,
|
||||
});
|
||||
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
|
||||
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe(
|
||||
"Bearer resolved-proxy-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",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps replay handling aligned with each discovered transport", () => {
|
||||
const provider = capturePluginRegistration(plugin).providers[0];
|
||||
const buildReplayPolicy = provider?.buildReplayPolicy;
|
||||
|
||||
expect(
|
||||
buildReplayPolicy?.({
|
||||
provider: "clawrouter",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "anthropic/default",
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
preserveNativeAnthropicToolUseIds: true,
|
||||
preserveSignatures: true,
|
||||
validateAnthropicTurns: true,
|
||||
});
|
||||
expect(
|
||||
buildReplayPolicy?.({
|
||||
provider: "clawrouter",
|
||||
modelApi: "google-generative-ai",
|
||||
modelId: "google/gemini-default",
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
validateGeminiTurns: true,
|
||||
});
|
||||
expect(
|
||||
buildReplayPolicy?.({
|
||||
provider: "clawrouter",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "openai/gpt-5.5-mini",
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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 {
|
||||
buildGoogleGeminiReplayPolicy,
|
||||
buildNativeAnthropicReplayPolicyForModel,
|
||||
buildPassthroughGeminiSanitizingReplayPolicy,
|
||||
resolveTaggedReasoningOutputMode,
|
||||
sanitizeGoogleGeminiReplayHistory,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildClawRouterProviderConfig,
|
||||
normalizeClawRouterApiBaseUrl,
|
||||
normalizeClawRouterResolvedModel,
|
||||
} from "./provider-catalog.js";
|
||||
import { wrapClawRouterProviderStream } from "./stream.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.resolveProviderAuth(PROVIDER_ID);
|
||||
let discoveryApiKey = auth.discoveryApiKey;
|
||||
if (!discoveryApiKey) {
|
||||
try {
|
||||
const { resolveApiKeyForProvider } =
|
||||
await import("openclaw/plugin-sdk/provider-auth-runtime");
|
||||
discoveryApiKey = (
|
||||
await resolveApiKeyForProvider({
|
||||
provider: PROVIDER_ID,
|
||||
cfg: ctx.config,
|
||||
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
|
||||
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
|
||||
...(auth.profileId ? { profileId: auth.profileId, lockedProfile: true } : {}),
|
||||
})
|
||||
)?.apiKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const apiKey = auth.apiKey ?? discoveryApiKey;
|
||||
if (!apiKey || !discoveryApiKey) {
|
||||
return null;
|
||||
}
|
||||
const configuredBaseUrl = ctx.config.models?.providers?.[PROVIDER_ID]?.baseUrl;
|
||||
try {
|
||||
return {
|
||||
provider: await buildClawRouterProviderConfig({
|
||||
apiKey,
|
||||
discoveryApiKey,
|
||||
baseUrl: configuredBaseUrl,
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl);
|
||||
return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model),
|
||||
wrapSimpleCompletionStreamFn: wrapClawRouterProviderStream,
|
||||
wrapStreamFn: wrapClawRouterProviderStream,
|
||||
buildReplayPolicy: ({ modelApi, modelId }) => {
|
||||
if (modelApi === "anthropic-messages") {
|
||||
return buildNativeAnthropicReplayPolicyForModel(modelId);
|
||||
}
|
||||
if (modelApi === "google-generative-ai") {
|
||||
return buildGoogleGeminiReplayPolicy();
|
||||
}
|
||||
if (modelApi === "openai-completions" || modelApi === "openai-responses") {
|
||||
return buildPassthroughGeminiSanitizingReplayPolicy(modelId);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
sanitizeReplayHistory: (ctx) =>
|
||||
ctx.modelApi === "google-generative-ai"
|
||||
? sanitizeGoogleGeminiReplayHistory(ctx)
|
||||
: undefined,
|
||||
resolveReasoningOutputMode: (ctx) =>
|
||||
ctx.modelApi === "google-generative-ai" ? resolveTaggedReasoningOutputMode() : undefined,
|
||||
isModernModelRef: () => true,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/clawrouter-provider",
|
||||
"version": "2026.6.9",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClawRouter provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
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,
|
||||
normalizeClawRouterResolvedModel,
|
||||
prepareClawRouterRequestModel,
|
||||
} 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",
|
||||
},
|
||||
{
|
||||
path: "/v1beta/models/${model}:streamGenerateContent",
|
||||
methods: ["POST"],
|
||||
requestFormat: "google.generate_content",
|
||||
responseFormat: "google.generate_content",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: "google/gemini-default",
|
||||
upstream: "gemini",
|
||||
capabilities: ["llm.generate", "llm.stream"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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(catalog: unknown = CATALOG): {
|
||||
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();
|
||||
});
|
||||
|
||||
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",
|
||||
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,
|
||||
baseUrl: provider.baseUrl,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel);
|
||||
expect(normalized).toMatchObject({
|
||||
id: "anthropic/default",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://clawrouter.example/v1/native/anthropic",
|
||||
});
|
||||
expect(prepareClawRouterRequestModel(normalized as ProviderRuntimeModel)).toMatchObject({
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
params: undefined,
|
||||
});
|
||||
const gemini = provider.models.find((model) => model.id === "google/gemini-default");
|
||||
const normalizedGemini = normalizeClawRouterResolvedModel({
|
||||
...gemini,
|
||||
baseUrl: provider.baseUrl,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel);
|
||||
expect(normalizedGemini).toMatchObject({
|
||||
id: "google/gemini-default",
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://clawrouter.example/v1/native/google-gemini/v1beta",
|
||||
});
|
||||
expect(prepareClawRouterRequestModel(normalizedGemini as ProviderRuntimeModel)).toMatchObject({
|
||||
id: "gemini",
|
||||
params: undefined,
|
||||
});
|
||||
expect(JSON.stringify(provider.models)).not.toContain("clawrouter-test-key");
|
||||
});
|
||||
|
||||
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("does not advertise Gemini models without an explicit streaming route", async () => {
|
||||
const generateOnlyCatalog = structuredClone(CATALOG);
|
||||
generateOnlyCatalog.providers[2].routes = generateOnlyCatalog.providers[2].routes.filter(
|
||||
(route) => !route.path.includes(":streamGenerateContent"),
|
||||
);
|
||||
generateOnlyCatalog.providers[2].models[0].capabilities = ["llm.generate"];
|
||||
const { fetchGuard } = buildFetchGuard(generateOnlyCatalog);
|
||||
|
||||
const provider = await buildClawRouterProviderConfig({
|
||||
apiKey: "clawrouter-test-key",
|
||||
baseUrl: "https://clawrouter.example",
|
||||
fetchGuard,
|
||||
});
|
||||
|
||||
expect(provider.models.map((model) => model.id)).not.toContain("google/gemini-default");
|
||||
});
|
||||
|
||||
it("keeps credential-scoped route metadata isolated on each catalog result", async () => {
|
||||
const firstCatalog = structuredClone(CATALOG);
|
||||
firstCatalog.providers[1].models[0].upstream = "first-upstream";
|
||||
const first = buildFetchGuard(firstCatalog);
|
||||
const firstProvider = await buildClawRouterProviderConfig({
|
||||
apiKey: "first-key",
|
||||
baseUrl: "https://clawrouter.example",
|
||||
fetchGuard: first.fetchGuard,
|
||||
});
|
||||
const firstAnthropic = firstProvider.models.find((model) => model.id === "anthropic/default");
|
||||
|
||||
const secondCatalog = structuredClone(CATALOG);
|
||||
secondCatalog.providers[1].models[0].upstream = "second-upstream";
|
||||
const second = buildFetchGuard(secondCatalog);
|
||||
const secondProvider = await buildClawRouterProviderConfig({
|
||||
apiKey: "second-key",
|
||||
baseUrl: "https://clawrouter.example",
|
||||
fetchGuard: second.fetchGuard,
|
||||
});
|
||||
const secondAnthropic = secondProvider.models.find((model) => model.id === "anthropic/default");
|
||||
|
||||
expect(
|
||||
prepareClawRouterRequestModel(
|
||||
normalizeClawRouterResolvedModel({
|
||||
...firstAnthropic,
|
||||
baseUrl: firstProvider.baseUrl,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel) as ProviderRuntimeModel,
|
||||
).id,
|
||||
).toBe("first-upstream");
|
||||
expect(
|
||||
prepareClawRouterRequestModel(
|
||||
normalizeClawRouterResolvedModel({
|
||||
...secondAnthropic,
|
||||
baseUrl: secondProvider.baseUrl,
|
||||
provider: "clawrouter",
|
||||
} as ProviderRuntimeModel) as ProviderRuntimeModel,
|
||||
).id,
|
||||
).toBe("second-upstream");
|
||||
});
|
||||
});
|
||||
@@ -1,328 +0,0 @@
|
||||
// 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 ROUTE_METADATA_KEY = "clawrouterRoute";
|
||||
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;
|
||||
};
|
||||
|
||||
type RouteMetadata = {
|
||||
api: NonNullable<ModelDefinitionConfig["api"]>;
|
||||
baseUrl: string;
|
||||
upstreamModel?: string;
|
||||
};
|
||||
|
||||
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 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: NonNullable<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.stream") &&
|
||||
provider.routes.find(
|
||||
(route) =>
|
||||
route.methods.includes("POST") &&
|
||||
route.requestFormat === "google.generate_content" &&
|
||||
route.path.includes(":streamGenerateContent"),
|
||||
);
|
||||
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,
|
||||
params: {
|
||||
[ROUTE_METADATA_KEY]: {
|
||||
api,
|
||||
baseUrl,
|
||||
...(upstreamModel ? { upstreamModel } : {}),
|
||||
} satisfies RouteMetadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiscoveredModels(
|
||||
rootUrl: string,
|
||||
providers: CatalogProvider[],
|
||||
): ModelDefinitionConfig[] {
|
||||
const models = new Map<string, ModelDefinitionConfig>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
return [...models.values()].toSorted((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,
|
||||
models: buildDiscoveredModels(rootUrl, providers),
|
||||
};
|
||||
}
|
||||
|
||||
function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadata | undefined {
|
||||
const row = readRecord(params?.[ROUTE_METADATA_KEY]);
|
||||
const baseUrl = readString(row?.baseUrl);
|
||||
const api = readString(row?.api);
|
||||
if (
|
||||
!baseUrl ||
|
||||
(api !== "openai-responses" &&
|
||||
api !== "openai-completions" &&
|
||||
api !== "anthropic-messages" &&
|
||||
api !== "google-generative-ai")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
api,
|
||||
baseUrl,
|
||||
...(readString(row?.upstreamModel) ? { upstreamModel: readString(row?.upstreamModel) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stripRouteMetadata(
|
||||
params: ProviderRuntimeModel["params"],
|
||||
): ProviderRuntimeModel["params"] {
|
||||
if (!params || !(ROUTE_METADATA_KEY in params)) {
|
||||
return params;
|
||||
}
|
||||
const { [ROUTE_METADATA_KEY]: _routeMetadata, ...remaining } = params;
|
||||
return Object.keys(remaining).length > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
export function normalizeClawRouterResolvedModel(
|
||||
model: ProviderRuntimeModel,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
const route = readRouteMetadata(model.params);
|
||||
if (!route) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
api: route.api,
|
||||
baseUrl: route.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareClawRouterRequestModel(model: ProviderRuntimeModel): ProviderRuntimeModel {
|
||||
const route = readRouteMetadata(model.params);
|
||||
if (!route) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
params: stripRouteMetadata(model.params),
|
||||
...(route.upstreamModel && route.upstreamModel !== model.id ? { id: route.upstreamModel } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { prepareClawRouterRequestModel } from "./provider-catalog.js";
|
||||
|
||||
const ENV_API_KEY_MARKER = "CLAWROUTER_API_KEY";
|
||||
|
||||
function withBearerAuthorization(
|
||||
headers: Record<string, string> | undefined,
|
||||
apiKey: string,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
if (name.toLowerCase() !== "authorization") {
|
||||
next[name] = value;
|
||||
}
|
||||
}
|
||||
next.Authorization = `Bearer ${apiKey}`;
|
||||
return next;
|
||||
}
|
||||
|
||||
function createClawRouterStreamWrapper(underlying: StreamFn | undefined): StreamFn | undefined {
|
||||
if (!underlying) {
|
||||
return undefined;
|
||||
}
|
||||
return (model, context, options) => {
|
||||
const apiKey = options?.apiKey?.trim();
|
||||
const preparedModel = prepareClawRouterRequestModel(model);
|
||||
if (!apiKey || apiKey === ENV_API_KEY_MARKER) {
|
||||
return underlying(preparedModel, context, options);
|
||||
}
|
||||
return underlying(
|
||||
{
|
||||
...preparedModel,
|
||||
headers: withBearerAuthorization(preparedModel.headers, apiKey),
|
||||
},
|
||||
context,
|
||||
options,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapClawRouterProviderStream(
|
||||
ctx: ProviderWrapStreamFnContext,
|
||||
): StreamFn | undefined {
|
||||
return createClawRouterStreamWrapper(ctx.streamFn);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Generated
-6
@@ -491,12 +491,6 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/clawrouter:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/clickclack:
|
||||
dependencies:
|
||||
ws:
|
||||
|
||||
@@ -109,7 +109,6 @@ function humanizeId(value) {
|
||||
["byteplus", "BytePlus"],
|
||||
["codex", "Codex"],
|
||||
["cli", "CLI"],
|
||||
["clawrouter", "ClawRouter"],
|
||||
["comfy", "ComfyUI"],
|
||||
["dashscope", "DashScope"],
|
||||
["deepgram", "Deepgram"],
|
||||
|
||||
@@ -1198,7 +1198,6 @@ describe("runBtwSideQuestion", () => {
|
||||
expect(result).toEqual({ text: "Ollama Cloud answer." });
|
||||
const registerParams = expectRecordFields(mockArg(registerProviderStreamForModelMock, 0, 0), {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
applyProviderWrapper: true,
|
||||
});
|
||||
expectRecordFields(registerParams.model, {
|
||||
provider: "ollama",
|
||||
|
||||
@@ -719,7 +719,6 @@ export async function runBtwSideQuestion(
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
env: process.env,
|
||||
applyProviderWrapper: true,
|
||||
});
|
||||
const streamFn = resolveEmbeddedAgentStreamFn({
|
||||
currentStreamFn: streamSimple,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Model } from "../llm/types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureCustomApiRegistered: vi.fn(),
|
||||
createBoundaryAwareStreamFnForModel: vi.fn(),
|
||||
createTransportAwareStreamFnForModel: vi.fn(),
|
||||
resolveProviderStreamFn: vi.fn(),
|
||||
wrapProviderStreamFn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderStreamFn: mocks.resolveProviderStreamFn,
|
||||
wrapProviderStreamFn: mocks.wrapProviderStreamFn,
|
||||
}));
|
||||
vi.mock("./custom-api-registry.js", () => ({
|
||||
ensureCustomApiRegistered: mocks.ensureCustomApiRegistered,
|
||||
}));
|
||||
vi.mock("./provider-transport-stream.js", () => ({
|
||||
createBoundaryAwareStreamFnForModel: mocks.createBoundaryAwareStreamFnForModel,
|
||||
createTransportAwareStreamFnForModel: mocks.createTransportAwareStreamFnForModel,
|
||||
}));
|
||||
|
||||
import { registerProviderStreamForModel } from "./provider-stream.js";
|
||||
|
||||
const MODEL = {
|
||||
id: "anthropic/default",
|
||||
name: "Anthropic default",
|
||||
api: "anthropic-messages",
|
||||
provider: "clawrouter",
|
||||
baseUrl: "https://clawrouter.example/v1/native/anthropic",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_768,
|
||||
} satisfies Model<"anthropic-messages">;
|
||||
|
||||
describe("registerProviderStreamForModel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("applies an opted-in provider wrapper after selecting the boundary transport", () => {
|
||||
const baseStream = vi.fn();
|
||||
const wrappedStream = vi.fn();
|
||||
mocks.createBoundaryAwareStreamFnForModel.mockReturnValue(baseStream);
|
||||
mocks.wrapProviderStreamFn.mockReturnValue(wrappedStream);
|
||||
|
||||
expect(
|
||||
registerProviderStreamForModel({
|
||||
model: MODEL,
|
||||
cfg: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
applyProviderWrapper: true,
|
||||
}),
|
||||
).toBe(wrappedStream);
|
||||
expect(mocks.createBoundaryAwareStreamFnForModel).toHaveBeenCalledWith(MODEL, {
|
||||
cfg: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
env: undefined,
|
||||
});
|
||||
expect(mocks.wrapProviderStreamFn).toHaveBeenCalledWith({
|
||||
provider: "clawrouter",
|
||||
config: { models: {} },
|
||||
workspaceDir: "/workspace",
|
||||
env: undefined,
|
||||
context: {
|
||||
config: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
provider: "clawrouter",
|
||||
modelId: "anthropic/default",
|
||||
model: MODEL,
|
||||
streamFn: baseStream,
|
||||
},
|
||||
});
|
||||
expect(mocks.ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
"anthropic-messages",
|
||||
wrappedStream,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves existing callers unwrapped by default", () => {
|
||||
const baseStream = vi.fn();
|
||||
mocks.resolveProviderStreamFn.mockReturnValue(baseStream);
|
||||
|
||||
expect(registerProviderStreamForModel({ model: MODEL })).toBe(baseStream);
|
||||
expect(mocks.wrapProviderStreamFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,9 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { Api, Model } from "../llm/types.js";
|
||||
import { resolveProviderStreamFn, wrapProviderStreamFn } from "../plugins/provider-runtime.js";
|
||||
import { resolveProviderStreamFn } from "../plugins/provider-runtime.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import {
|
||||
createBoundaryAwareStreamFnForModel,
|
||||
createTransportAwareStreamFnForModel,
|
||||
} from "./provider-transport-stream.js";
|
||||
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
|
||||
import type { StreamFn } from "./runtime/index.js";
|
||||
|
||||
/** Resolves and registers the stream function for a provider-backed model. */
|
||||
@@ -21,15 +18,8 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
allowRuntimePluginLoad?: boolean;
|
||||
applyProviderWrapper?: boolean;
|
||||
}): StreamFn | undefined {
|
||||
const transportContext = {
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
};
|
||||
const baseStreamFn =
|
||||
const streamFn =
|
||||
resolveProviderStreamFn({
|
||||
provider: params.model.provider,
|
||||
config: params.cfg,
|
||||
@@ -45,30 +35,15 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
model: params.model,
|
||||
},
|
||||
}) ??
|
||||
createTransportAwareStreamFnForModel(params.model, transportContext) ??
|
||||
(params.applyProviderWrapper
|
||||
? createBoundaryAwareStreamFnForModel(params.model, transportContext)
|
||||
: undefined);
|
||||
if (!baseStreamFn) {
|
||||
createTransportAwareStreamFnForModel(params.model, {
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
if (!streamFn) {
|
||||
return undefined;
|
||||
}
|
||||
const streamFn = params.applyProviderWrapper
|
||||
? (wrapProviderStreamFn({
|
||||
provider: params.model.provider,
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
context: {
|
||||
config: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
provider: params.model.provider,
|
||||
modelId: params.model.id,
|
||||
model: params.model,
|
||||
streamFn: baseStreamFn,
|
||||
},
|
||||
}) ?? baseStreamFn)
|
||||
: baseStreamFn;
|
||||
// Register custom APIs only after a concrete stream exists, so later callers
|
||||
// can route by model.api without reloading provider runtime hooks.
|
||||
ensureCustomApiRegistered(params.model.api, streamFn);
|
||||
|
||||
@@ -433,7 +433,6 @@ 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