fix(providers): resolve ClawRouter auth-profile models (#99759)

This commit is contained in:
Peter Steinberger
2026-07-03 19:27:46 -07:00
committed by GitHub
parent 1d6cbbc428
commit 8250b8d02e
4 changed files with 145 additions and 16 deletions

View File

@@ -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.

View File

@@ -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`.
</Step>
<Step title="List granted models">
@@ -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.
</Step>
<Step title="Select a model">
@@ -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

View File

@@ -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);

View File

@@ -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, { baseUrl?: unknown }> };
}): string | undefined {
const value = config.models?.providers?.[PROVIDER_ID]?.baseUrl;
function configuredBaseUrl(
config: { models?: { providers?: Record<string, { baseUrl?: unknown }> } } | 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<ReturnType<typeof buildClawRouterProviderConfig>>,
): Map<string, ProviderRuntimeModel> {
const models = new Map<string, ProviderRuntimeModel>();
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<string, Map<string, ProviderRuntimeModel>>();
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;