feat(anthropic): gate live model discovery on contract coverage (#113757)

* feat(anthropic): gate live model discovery on contract coverage

Live catalog discovery cloned a template for any newly discovered Claude id, so
a future model generation would be selectable while request shaping treated it
as pre-4.6 and 400d. Add an opt-in acceptUnknownModel gate to the shared live
catalog seam and have the Anthropic plugin accept a discovered model only when
Anthropic's advertised capabilities agree with the contracts we would apply.
Manifest-published ids bypass the gate and keep their metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plugins): document the live-discovery admission hook

Record acceptUnknownModel in the provider-plugin SDK reference: when to use
it, that manifest-published ids bypass it, and the fail-closed guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason (Json)
2026-07-25 11:27:58 -06:00
committed by GitHub
parent ce7ec43c45
commit 8028a3a5b2
9 changed files with 249 additions and 3 deletions
+1
View File
@@ -7868,6 +7868,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Usage and cost tracking
- H2: Getting started
- H2: Claude sessions across computers
- H2: Live model discovery
- H2: Thinking defaults (Claude Opus 5, Sonnet 5, Mythos 5, Fable 5, 4.8, and 4.6)
- H2: Safety refusal fallback (Claude Opus 5 and Fable 5)
- H3: Why this exists
+1
View File
@@ -237,6 +237,7 @@ catalog, API-key auth, and dynamic model resolution.
| Network limits | Fetches use OpenClaw's SSRF guard, one 5-second timeout budget across pagination, a 4 MiB response limit per page, and a 50-page limit. Cross-origin pagination links are rejected; credentials are removed after a cross-origin redirect. |
| Cache | Successful, non-empty catalogs are cached for 60 seconds by provider, endpoint, and resolved credential. Empty or unusable results are not cached. |
| Filtering | Exact live IDs keep their trusted static metadata. New rows are projected conservatively as text/chat models. Disabled, archived, deprecated, explicitly non-chat, embedding, reranking, moderation, speech, image-only, and video-only rows are excluded. Use `readRows` only to select rows from a nonstandard response envelope; provider-specific model semantics still belong in a custom catalog. |
| Admission | Optional. Set `acceptUnknownModel: ({ id, record }) => boolean` when your request shaping is model-version specific, so discovery cannot publish a model you cannot yet build a valid request for. It is called only for IDs your static catalog does not already publish; known IDs bypass it and keep their published metadata. Return `false` to drop the row. Providers that omit it keep the previous behavior unchanged. Prefer comparing the vendor's advertised capabilities against your own contract checks over a hand-maintained model list, and fail closed when the row carries no capability data. |
| Failure | Live discovery is advisory. Auth, network, timeout, pagination, parsing, empty-catalog, and filtering failures return the provider-owned static seed instead of removing the provider. |
For a non-Bearer or nonstandard list endpoint, pass options instead of
+14
View File
@@ -281,6 +281,20 @@ remain read-only even on a continuation-enabled node.
See [Nodes: Claude sessions and transcripts](/nodes#claude-sessions-and-transcripts)
for the node command and security boundary.
## Live model discovery
With an Anthropic API key configured, OpenClaw refreshes the Claude catalog from
Anthropic's models endpoint, so newly published snapshots of supported model
families appear without an OpenClaw release. Models the shipped catalog already
describes always keep their published metadata and pricing.
A newly discovered model is only offered when Anthropic's advertised
capabilities match the request shaping OpenClaw would apply to it. A brand-new
model generation therefore stays hidden until OpenClaw adds support for it,
rather than appearing in the picker and failing every request. Discovery is
advisory: without an API key, or if the endpoint is unreachable, the shipped
catalog is used unchanged.
## Thinking defaults (Claude Opus 5, Sonnet 5, Mythos 5, Fable 5, 4.8, and 4.6)
Bare family aliases are rolling: `opus` tracks the current supported Claude
@@ -0,0 +1,93 @@
// Anthropic tests cover the live-discovery contract gate.
import { describe, expect, it } from "vitest";
import { acceptsAnthropicLiveModelContract } from "./live-model-contract-gate.js";
/** Capability tree shaped like Anthropic's `/v1/models` response. */
function capabilities(params: {
adaptive: boolean;
xhigh: boolean;
max: boolean;
}): Record<string, unknown> {
return {
capabilities: {
image_input: { supported: true },
thinking: {
supported: true,
types: {
enabled: { supported: !params.adaptive },
adaptive: { supported: params.adaptive },
},
},
effort: {
low: { supported: params.max },
high: { supported: params.max },
xhigh: { supported: params.xhigh },
max: { supported: params.max },
},
},
};
}
const MODERN = { adaptive: true, xhigh: true, max: true };
const LEGACY = { adaptive: false, xhigh: false, max: false };
describe("acceptsAnthropicLiveModelContract", () => {
it("accepts current models whose advertised capabilities match our contracts", () => {
// Adaptive + full effort range: what the contracts already shape correctly.
for (const id of ["claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-opus-4-8"]) {
expect(acceptsAnthropicLiveModelContract({ id, record: capabilities(MODERN) }), id).toBe(
true,
);
}
});
it("accepts legacy models that correctly advertise no adaptive thinking", () => {
// The gate must not confuse "old model we shape with budget_tokens" for
// "model we do not understand" — both miss the modern predicates.
for (const id of ["claude-haiku-4-5", "claude-sonnet-4-5", "claude-3-opus-20240229"]) {
expect(acceptsAnthropicLiveModelContract({ id, record: capabilities(LEGACY) }), id).toBe(
true,
);
}
});
it("rejects an unreleased model generation our contracts do not recognize", () => {
// The regression this gate exists to prevent: a future Claude family that
// needs adaptive shaping but matches no version predicate, so it would be
// sent manual budget_tokens plus sampling params and 400 on every request.
for (const id of ["claude-opus-6", "claude-sonnet-6", "claude-zephyr-1"]) {
expect(acceptsAnthropicLiveModelContract({ id, record: capabilities(MODERN) }), id).toBe(
false,
);
}
});
it("rejects a known model whose advertised capabilities drift from our contracts", () => {
// If Anthropic changes a shipped model's capabilities, our shaping is stale
// for it; hide it rather than keep sending the old request shape.
expect(
acceptsAnthropicLiveModelContract({
id: "claude-opus-5",
record: capabilities({ adaptive: false, xhigh: true, max: true }),
}),
).toBe(false);
expect(
acceptsAnthropicLiveModelContract({
id: "claude-haiku-4-5",
record: capabilities(MODERN),
}),
).toBe(false);
});
it("fails closed when the capability tree is missing or unreadable", () => {
for (const record of [
{},
{ capabilities: null },
{ capabilities: "yes" },
{ capabilities: {} },
{ capabilities: { thinking: { types: { adaptive: {} } } } },
] as Record<string, unknown>[]) {
expect(acceptsAnthropicLiveModelContract({ id: "claude-opus-5", record })).toBe(false);
}
});
});
@@ -0,0 +1,64 @@
/**
* Contract gate for live-discovered Anthropic models the manifest does not
* publish. Claude request shaping is selected by model-version predicates, so a
* model those predicates do not recognize is shaped like a pre-4.6 model:
* manual `budget_tokens` thinking plus caller sampling parameters. Current
* Claude models reject both, so surfacing an unrecognized model would offer a
* selectable entry whose every request 400s.
*
* Rather than hand-maintaining a family allowlist that rots on each launch,
* accept a discovered model only when Anthropic's advertised capabilities agree
* with what our contracts would apply. Disagreement means our shaping is stale
* for that model, so it stays hidden until the contracts are updated.
*/
import {
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
function readCapabilityFlag(root: unknown, path: readonly string[]): boolean | undefined {
let current: unknown = root;
for (const key of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[key];
}
return typeof current === "boolean" ? current : undefined;
}
/**
* Anthropic publishes a capability tree on `/v1/models`. Each entry pairs a
* capability we can read from that tree with the contract predicate that must
* agree, so a mismatch on any axis rejects the model.
*/
const CLAUDE_CONTRACT_CAPABILITY_CHECKS = [
{
path: ["thinking", "types", "adaptive", "supported"],
matches: supportsClaudeAdaptiveThinking,
},
{ path: ["effort", "xhigh", "supported"], matches: supportsClaudeNativeXhighEffort },
{ path: ["effort", "max", "supported"], matches: supportsClaudeNativeMaxEffort },
] as const;
/**
* Return whether a live-discovered Claude model can be shaped by the current
* contracts. Fails closed: a model without a readable capability tree is
* rejected, because we cannot prove our shaping matches it.
*/
export function acceptsAnthropicLiveModelContract(params: {
id: string;
record: Record<string, unknown>;
}): boolean {
const capabilities = params.record.capabilities;
if (!isRecord(capabilities)) {
return false;
}
const ref = { id: params.id };
return CLAUDE_CONTRACT_CAPABILITY_CHECKS.every((check) => {
const advertised = readCapabilityFlag(capabilities, check.path);
return advertised !== undefined && advertised === check.matches(ref);
});
}
+2
View File
@@ -58,6 +58,7 @@ import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfigForProvider,
} from "./config-defaults.js";
import { acceptsAnthropicLiveModelContract } from "./live-model-contract-gate.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js";
@@ -950,6 +951,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
...(key ? { "x-api-key": key } : {}),
};
},
acceptUnknownModel: acceptsAnthropicLiveModelContract,
},
}),
},
@@ -0,0 +1,54 @@
// Plugin SDK tests cover the live-discovery unknown-model gate.
import { describe, expect, it } from "vitest";
import { buildOpenAICompatibleLiveModels } from "./provider-catalog-live-normalize.internal.js";
import type { ModelProviderConfig } from "./provider-model-shared.js";
const fallback = {
baseUrl: "https://api.example.com",
api: "anthropic-messages",
models: [
{
id: "known-model",
name: "Known",
reasoning: true,
input: ["text"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
},
],
} as unknown as ModelProviderConfig;
const rows = [
{ id: "known-model", object: "model" },
{ id: "brand-new-model", object: "model" },
];
describe("live discovery unknown-model gate", () => {
it("publishes discovered models unchanged when no gate is supplied", () => {
const ids = buildOpenAICompatibleLiveModels(rows, fallback).map((m) => m.id);
expect(ids).toContain("known-model");
expect(ids).toContain("brand-new-model");
});
it("drops only the ids the manifest does not publish when the gate rejects", () => {
const seen: string[] = [];
const models = buildOpenAICompatibleLiveModels(rows, fallback, ({ id }) => {
seen.push(id);
return false;
});
// The manifest-published row bypasses the gate entirely and survives.
expect(models.map((m) => m.id)).toEqual(["known-model"]);
expect(seen).toEqual(["brand-new-model"]);
});
it("keeps the manifest entry verbatim for published ids", () => {
const [model] = buildOpenAICompatibleLiveModels(rows, fallback, () => false);
expect(model).toEqual(fallback.models[0]);
});
it("admits unknown ids the gate accepts", () => {
const ids = buildOpenAICompatibleLiveModels(rows, fallback, () => true).map((m) => m.id);
expect(ids).toEqual(["brand-new-model", "known-model"]);
});
});
@@ -172,6 +172,7 @@ function inferLiveModelReasoning(modelId: string): boolean {
function buildOpenAICompatibleLiveModel(
row: unknown,
fallback: ModelProviderConfig,
acceptUnknownModel?: (params: { id: string; record: Record<string, unknown> }) => boolean,
): ModelDefinitionConfig | undefined {
const record = readLiveModelCatalogRecord(row);
const id = readLiveModelString(record, ["id", "model", "model_name", "modelName"]);
@@ -202,6 +203,13 @@ function buildOpenAICompatibleLiveModel(
if (exact) {
return exact;
}
// Manifest-published ids returned above are known-good. Everything past this
// point is a model the manifest has never described, so an opted-in provider
// gate decides whether its request shaping is understood well enough to
// surface it at all.
if (acceptUnknownModel && !acceptUnknownModel({ id, record })) {
return undefined;
}
const template = findLiveModelTemplate(id, fallback.models);
const inputModalities = readLiveModelStringArray(
[record, architecture, capabilities, modelInfo],
@@ -276,9 +284,10 @@ function buildOpenAICompatibleLiveModel(
export function buildOpenAICompatibleLiveModels(
rows: readonly unknown[],
fallback: ModelProviderConfig,
acceptUnknownModel?: (params: { id: string; record: Record<string, unknown> }) => boolean,
): ModelDefinitionConfig[] {
const models = rows
.map((row) => buildOpenAICompatibleLiveModel(row, fallback))
.map((row) => buildOpenAICompatibleLiveModel(row, fallback, acceptUnknownModel))
.filter((model): model is ModelDefinitionConfig => Boolean(model));
return [...new Map(models.map((model) => [model.id, model])).values()].toSorted((a, b) =>
a.id.localeCompare(b.id),
@@ -93,6 +93,13 @@ export type OpenAICompatibleModelDiscoveryOptions = {
readRows?: FetchLiveProviderModelRowsParams["readRows"];
/** Provider-specific authorization headers for non-Bearer model-list APIs. */
buildRequestHeaders?: FetchLiveProviderModelRowsParams["buildRequestHeaders"];
/**
* Gate for discovered ids the manifest does not already publish. Providers
* whose request shaping is model-version specific use this to drop models
* they cannot yet shape, so discovery never surfaces a selectable model that
* would build an invalid request. Manifest-published ids bypass it.
*/
acceptUnknownModel?: (params: { id: string; record: Record<string, unknown> }) => boolean;
};
export type BuildOpenAICompatibleProviderCatalogParams = {
@@ -491,6 +498,7 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
...params.providerConfig,
...(params.apiKey ? { apiKey: params.apiKey } : {}),
};
const acceptUnknownModel = params.modelDiscovery?.acceptUnknownModel;
const endpoint = params.modelDiscovery?.endpointUrl
? resolveFixedLiveModelDiscoveryEndpoint(fallback.baseUrl, params.modelDiscovery.endpointUrl)
: resolveLiveModelDiscoveryEndpoint(
@@ -513,9 +521,9 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
readRows: params.modelDiscovery?.readRows,
buildRequestHeaders: params.modelDiscovery?.buildRequestHeaders,
shouldCacheRows: (modelRows) =>
buildOpenAICompatibleLiveModels(modelRows, fallback).length > 0,
buildOpenAICompatibleLiveModels(modelRows, fallback, acceptUnknownModel).length > 0,
});
const models = buildOpenAICompatibleLiveModels(rows, fallback);
const models = buildOpenAICompatibleLiveModels(rows, fallback, acceptUnknownModel);
if (models.length > 0) {
return { ...fallback, models };
}