diff --git a/CHANGELOG.md b/CHANGELOG.md index 429330040a69..fabbd38ece7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **ClawRouter auth profiles:** resolve credential-scoped catalog models during agent runs when the proxy key is stored in an auth profile, and document plugin and model allowlists. - **Telegram durability:** recover stalled ingress claims, retry restart-dropped media, survive transient polling errors, dead-letter poison updates, preserve forwarded rich text, route plugin callbacks correctly, and fall back safely when rich final replies are rejected. (#97118, #98102, #98735, #98775, #98776, #97174, #98786) Thanks @vincentkoc, @luoyanglang, @DaveArcher18, @obviyus, and @goldmar. - **Agent and context reliability:** preserve runtime overrides and steered subagent tasks, improve harness-aware context estimation and compaction prechecks, time out silent local streams, recover mid-stream failures, and cap Gateway run-cache growth. (#92237, #77539, #97928, #97861, #98525, #95430, #77973) Thanks @sercada, @amittell, @liuhao1024, @yetval, @osolmaz, @lzyyzznl, @vincentkoc, @alexelgier, and @fede-kamel. - **Provider and network safety:** bound oversized or malformed responses across Moonshot, MiniMax, Anthropic OAuth, Discord, Matrix, SMS, browser, update, embeddings, Tlön, and Inworld paths. (#96502, #96322, #96644, #97693, #97662, #97999, #98455, #98508, #98554, #98496, #98660) Thanks @hugenshen, @cursoragent, @lsr911, @solodmd, @Alix-007, @wings1029, @lzyyzznl, @sunlit-deng, @vincentkoc, and @Pandah97. diff --git a/docs/providers/clawrouter.md b/docs/providers/clawrouter.md index d12c90eef9af..9cdc9e03b295 100644 --- a/docs/providers/clawrouter.md +++ b/docs/providers/clawrouter.md @@ -37,11 +37,13 @@ issued ClawRouter credential. ```bash export CLAWROUTER_API_KEY="..." openclaw onboard --auth-choice clawrouter-api-key + openclaw plugins enable clawrouter ``` - The plugin is included with OpenClaw and enabled by default. For a custom - deployment, set `models.providers.clawrouter.baseUrl` to the ClawRouter - origin; the default is `https://clawrouter.openclaw.ai`. + The plugin is bundled with OpenClaw. If your configuration sets + `plugins.allow`, add `clawrouter` to that list before enabling it. For a + custom deployment, set `models.providers.clawrouter.baseUrl` to the + ClawRouter origin; the default is `https://clawrouter.openclaw.ai`. @@ -51,7 +53,8 @@ issued ClawRouter credential. Use the returned model refs exactly as shown. They retain the upstream namespace, such as `clawrouter/openai/...`, `clawrouter/anthropic/...`, or - `clawrouter/google/...`. + `clawrouter/google/...`. If `agents.defaults.models` is an allowlist in your + configuration, add each selected ClawRouter ref to it. @@ -121,13 +124,14 @@ the same ClawRouter policy can change the remaining percentage. ## Troubleshooting -| Symptom | Check | -| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| No ClawRouter models | Confirm the credential is active, its policy grants at least one ready model provider, and `CLAWROUTER_API_KEY` is available to the OpenClaw process. | -| A configured ClawRouter model is missing | Inspect its `/v1/catalog` capability and route format. Unsupported transport contracts are intentionally filtered. | -| `401` or `403` from catalog or usage | Reissue or re-scope the ClawRouter credential; OpenClaw does not fall back to upstream provider keys. | -| Model call fails after discovery | Check the provider connection and upstream health in ClawRouter, then retry after its readiness state recovers. | -| Usage has totals but no percentage | The policy is unmetered; add a monthly budget in ClawRouter to expose a percentage window. | +| Symptom | Check | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| No ClawRouter models | Confirm the plugin is enabled and allowed by `plugins.allow`, then check that the credential is active and grants at least one ready provider. | +| A configured ClawRouter model is missing | Inspect its `/v1/catalog` capability and route format. Unsupported transport contracts are intentionally filtered. | +| `Unknown model: clawrouter/...` | Add the exact catalog ref to `agents.defaults.models` when that configuration map is being used as an allowlist. | +| `401` or `403` from catalog or usage | Reissue or re-scope the ClawRouter credential; OpenClaw does not fall back to upstream provider keys. | +| Model call fails after discovery | Check the provider connection and upstream health in ClawRouter, then retry after its readiness state recovers. | +| Usage has totals but no percentage | The policy is unmetered; add a monthly budget in ClawRouter to expose a percentage window. | ## Security behavior diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index e6f51bb29382..5d42803a316b 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -53,6 +53,8 @@ describe("ClawRouter plugin", () => { inspectToolSchemas: expect.any(Function), normalizeResolvedModel: expect.any(Function), normalizeToolSchemas: expect.any(Function), + prepareDynamicModel: expect.any(Function), + resolveDynamicModel: expect.any(Function), resolveUsageAuth: expect.any(Function), sanitizeReplayHistory: expect.any(Function), wrapSimpleCompletionStreamFn: expect.any(Function), @@ -177,6 +179,63 @@ describe("ClawRouter plugin", () => { ).rejects.toThrow(/401/u); }); + it("resolves configured catalog models through a stored auth profile", async () => { + providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({ + apiKey: "resolved-proxy-key", + mode: "api-key", + source: "auth profile", + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(LIVE_CATALOG)), + ); + const provider = await registerSingleProviderPlugin(plugin); + const context = { + config: { models: {} }, + agentDir: "/agent", + workspaceDir: "/workspace", + provider: "clawrouter", + modelId: "openai/gpt-5.5", + modelRegistry: { find: vi.fn(() => null) }, + authProfileId: "clawrouter-profile", + authProfileMode: "api_key", + }; + + expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined(); + await provider?.prepareDynamicModel?.(context as never); + + expect(provider?.resolveDynamicModel?.(context as never)).toMatchObject({ + id: "openai/gpt-5.5", + provider: "clawrouter", + api: "openai-responses", + baseUrl: "https://clawrouter.openclaw.ai/v1", + params: { + clawrouterRoute: { + api: "openai-responses", + baseUrl: "https://clawrouter.openclaw.ai/v1", + }, + }, + }); + expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({ + provider: "clawrouter", + cfg: { models: {} }, + agentDir: "/agent", + workspaceDir: "/workspace", + profileId: "clawrouter-profile", + lockedProfile: true, + }); + expect( + provider?.resolveDynamicModel?.({ + ...context, + authProfileId: "another-profile", + } as never), + ).toBeUndefined(); + + providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue(undefined); + await provider?.prepareDynamicModel?.(context as never); + expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined(); + }); + it("dispatches replay and tool policies by upstream protocol family", async () => { const provider = await registerSingleProviderPlugin(plugin); diff --git a/extensions/clawrouter/index.ts b/extensions/clawrouter/index.ts index a7d6bcedac3c..2ad9b90371b7 100644 --- a/extensions/clawrouter/index.ts +++ b/extensions/clawrouter/index.ts @@ -1,11 +1,17 @@ // ClawRouter plugin entrypoint registers credential-scoped model routing and quota reporting. -import { definePluginEntry, type ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import { + definePluginEntry, + type ProviderAuthMethod, + type ProviderResolveDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { buildClawRouterProviderConfig, normalizeClawRouterApiBaseUrl, + normalizeClawRouterRootUrl, normalizeClawRouterResolvedModel, } from "./provider-catalog.js"; import { wrapClawRouterProviderStream } from "./stream.js"; @@ -52,13 +58,45 @@ function buildApiKeyAuth(): ProviderAuthMethod { }); } -function configuredBaseUrl(config: { - models?: { providers?: Record }; -}): string | undefined { - const value = config.models?.providers?.[PROVIDER_ID]?.baseUrl; +function configuredBaseUrl( + config: { models?: { providers?: Record } } | null | undefined, +): string | undefined { + const value = config?.models?.providers?.[PROVIDER_ID]?.baseUrl; return typeof value === "string" ? value : undefined; } +function dynamicModelScope(ctx: ProviderResolveDynamicModelContext): string { + return JSON.stringify([ + ctx.agentDir ?? "", + ctx.workspaceDir ?? "", + ctx.authProfileId ?? "", + normalizeClawRouterRootUrl(ctx.providerConfig?.baseUrl ?? configuredBaseUrl(ctx.config)), + ]); +} + +function buildRuntimeModels( + providerConfig: Awaited>, +): Map { + const models = new Map(); + for (const model of providerConfig.models) { + const api = model.api ?? providerConfig.api; + const baseUrl = model.baseUrl ?? providerConfig.baseUrl; + if (!api || !baseUrl) { + continue; + } + models.set(model.id, { + ...model, + api, + baseUrl, + provider: PROVIDER_ID, + input: model.input.filter( + (entry): entry is "text" | "image" => entry === "text" || entry === "image", + ), + }); + } + return models; +} + function resolveToolFamily(modelId: string) { const normalized = modelId.toLowerCase(); if (normalized.startsWith("deepseek/")) { @@ -75,6 +113,8 @@ export default definePluginEntry({ name: "ClawRouter", description: "Managed multi-provider model routing and quotas", register(api) { + const dynamicModels = new Map>(); + api.registerProvider({ id: PROVIDER_ID, label: "ClawRouter", @@ -116,6 +156,31 @@ export default definePluginEntry({ }; }, }, + resolveDynamicModel: (ctx) => dynamicModels.get(dynamicModelScope(ctx))?.get(ctx.modelId), + prepareDynamicModel: async (ctx) => { + const scope = dynamicModelScope(ctx); + dynamicModels.delete(scope); + const { resolveApiKeyForProvider } = + await import("openclaw/plugin-sdk/provider-auth-runtime"); + const apiKey = ( + await resolveApiKeyForProvider({ + provider: PROVIDER_ID, + cfg: ctx.config, + ...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}), + ...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}), + ...(ctx.authProfileId ? { profileId: ctx.authProfileId, lockedProfile: true } : {}), + }) + )?.apiKey; + if (!apiKey) { + return; + } + const providerConfig = await buildClawRouterProviderConfig({ + apiKey, + discoveryApiKey: apiKey, + baseUrl: ctx.providerConfig?.baseUrl ?? configuredBaseUrl(ctx.config), + }); + dynamicModels.set(scope, buildRuntimeModels(providerConfig)); + }, normalizeConfig: ({ providerConfig }) => { const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl); return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;