refactor(providers): move defaults and error policy into plugins

This commit is contained in:
Peter Steinberger
2026-04-04 07:42:09 +01:00
parent e34f42559f
commit b167ad052c
17 changed files with 542 additions and 226 deletions

View File

@@ -46,6 +46,11 @@ function createGuardrailWrapStreamFn(
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
const BEDROCK_CONTEXT_OVERFLOW_PATTERNS = [
/ValidationException.*(?:input is too long|max input token|input token.*exceed)/i,
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
] as const;
export async function registerAmazonBedrockPlugin(api: OpenClawPluginApi): Promise<void> {
const guardrail = (api.pluginConfig as Record<string, unknown> | undefined)?.guardrail as
@@ -86,6 +91,17 @@ export async function registerAmazonBedrockPlugin(api: OpenClawPluginApi): Promi
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
buildReplayPolicy: ({ modelId }) => buildAnthropicReplayPolicyForModel(modelId),
wrapStreamFn,
matchesContextOverflowError: ({ errorMessage }) =>
BEDROCK_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage)),
classifyFailoverReason: ({ errorMessage }) => {
if (/ThrottlingException|Too many concurrent requests/i.test(errorMessage)) {
return "rate_limit";
}
if (/ModelNotReadyException/i.test(errorMessage)) {
return "overloaded";
}
return undefined;
},
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
});

View File

@@ -0,0 +1,225 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
const ANTHROPIC_PROVIDER_API = "anthropic-messages";
function resolveAnthropicDefaultAuthMode(
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
): "api_key" | "oauth" | null {
const profiles = config.auth?.profiles ?? {};
const anthropicProfiles = Object.entries(profiles).filter(
([, profile]) => profile?.provider === "anthropic",
);
const order = config.auth?.order?.anthropic ?? [];
for (const profileId of order) {
const entry = profiles[profileId];
if (!entry || entry.provider !== "anthropic") {
continue;
}
if (entry.mode === "api_key") {
return "api_key";
}
if (entry.mode === "oauth" || entry.mode === "token") {
return "oauth";
}
}
const hasApiKey = anthropicProfiles.some(([, profile]) => profile?.mode === "api_key");
const hasOauth = anthropicProfiles.some(
([, profile]) => profile?.mode === "oauth" || profile?.mode === "token",
);
if (hasApiKey && !hasOauth) {
return "api_key";
}
if (hasOauth && !hasApiKey) {
return "oauth";
}
if (env.ANTHROPIC_OAUTH_TOKEN?.trim()) {
return "oauth";
}
if (env.ANTHROPIC_API_KEY?.trim()) {
return "api_key";
}
return null;
}
function resolveModelPrimaryValue(
value: string | { primary?: string; fallbacks?: string[] } | undefined,
): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
const primary = value?.primary;
if (typeof primary !== "string") {
return undefined;
}
const trimmed = primary.trim();
return trimmed || undefined;
}
function resolveAnthropicPrimaryModelRef(raw?: string): string | null {
if (!raw) {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const aliasKey = trimmed.toLowerCase();
if (aliasKey === "opus") {
return "anthropic/claude-opus-4-6";
}
if (aliasKey === "sonnet") {
return "anthropic/claude-sonnet-4-6";
}
return trimmed;
}
function parseProviderModelRef(
raw: string,
defaultProvider: string,
): { provider: string; model: string } | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0) {
return { provider: defaultProvider, model: trimmed };
}
const provider = trimmed.slice(0, slashIndex).trim();
const model = trimmed.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return {
provider: normalizeProviderId(provider),
model,
};
}
function isAnthropicCacheRetentionTarget(
parsed: { provider: string; model: string } | null | undefined,
) {
return Boolean(
parsed &&
(parsed.provider === "anthropic" ||
(parsed.provider === "amazon-bedrock" &&
parsed.model.toLowerCase().includes("anthropic.claude"))),
);
}
export function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
providerConfig: T,
): T {
if (
providerConfig.api ||
!Array.isArray(providerConfig.models) ||
providerConfig.models.length === 0
) {
return providerConfig;
}
return { ...providerConfig, api: ANTHROPIC_PROVIDER_API };
}
export function applyAnthropicConfigDefaults(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): OpenClawConfig {
const defaults = params.config.agents?.defaults;
if (!defaults) {
return params.config;
}
const authMode = resolveAnthropicDefaultAuthMode(params.config, params.env);
if (!authMode) {
return params.config;
}
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = {
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h",
};
mutated = true;
}
if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = {
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m",
};
mutated = true;
}
if (authMode === "api_key") {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
for (const [key, entry] of Object.entries(nextModels)) {
const parsed = parseProviderModelRef(key, "anthropic");
if (!isAnthropicCacheRetentionTarget(parsed)) {
continue;
}
const current = entry ?? {};
const paramsValue = (current as { params?: Record<string, unknown> }).params ?? {};
if (typeof paramsValue.cacheRetention === "string") {
continue;
}
nextModels[key] = {
...(current as Record<string, unknown>),
params: { ...paramsValue, cacheRetention: "short" },
};
modelsMutated = true;
}
const primary = resolveAnthropicPrimaryModelRef(
resolveModelPrimaryValue(
defaults.model as string | { primary?: string; fallbacks?: string[] } | undefined,
),
);
if (primary) {
const parsedPrimary = parseProviderModelRef(primary, "anthropic");
if (isAnthropicCacheRetentionTarget(parsedPrimary)) {
const key = `${parsedPrimary.provider}/${parsedPrimary.model}`;
const entry = nextModels[key];
const current = entry ?? {};
const paramsValue = (current as { params?: Record<string, unknown> }).params ?? {};
if (typeof paramsValue.cacheRetention !== "string") {
nextModels[key] = {
...(current as Record<string, unknown>),
params: { ...paramsValue, cacheRetention: "short" },
};
modelsMutated = true;
}
}
}
if (modelsMutated) {
nextDefaults.models = nextModels;
mutated = true;
}
}
if (!mutated) {
return params.config;
}
return {
...params.config,
agents: {
...params.config.agents,
defaults: nextDefaults,
},
};
}

View File

@@ -35,4 +35,51 @@ describe("anthropic provider replay hooks", () => {
dropThinkingBlocks: true,
});
});
it("defaults provider api through plugin config normalization", () => {
const provider = registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.normalizeConfig?.({
provider: "anthropic",
providerConfig: {
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
},
} as never),
).toMatchObject({
api: "anthropic-messages",
});
});
it("applies Anthropic pruning defaults through plugin hooks", () => {
const provider = registerSingleProviderPlugin(anthropicPlugin);
const next = provider.applyConfigDefaults?.({
provider: "anthropic",
env: {},
config: {
auth: {
profiles: {
"anthropic:api": { provider: "anthropic", mode: "api_key" },
},
},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
},
},
},
} as never);
expect(next?.agents?.defaults?.contextPruning).toMatchObject({
mode: "cache-ttl",
ttl: "1h",
});
expect(next?.agents?.defaults?.heartbeat).toMatchObject({
every: "30m",
});
expect(
next?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.params?.cacheRetention,
).toBe("short");
});
});

View File

@@ -30,6 +30,10 @@ import { composeProviderStreamWrappers } from "openclaw/plugin-sdk/provider-stre
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import { buildAnthropicCliMigrationResult, hasClaudeCliAuth } from "./cli-migration.js";
import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfig,
} from "./config-defaults.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildAnthropicReplayPolicy } from "./replay-policy.js";
import {
@@ -445,6 +449,8 @@ export async function registerAnthropicPlugin(api: OpenClawPluginApi): Promise<v
},
}),
],
normalizeConfig: ({ providerConfig }) => normalizeAnthropicProviderConfig(providerConfig),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
buildReplayPolicy: (ctx) => buildAnthropicReplayPolicy(ctx),
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),

View File

@@ -246,6 +246,8 @@ export default definePluginEntry({
return null;
},
},
classifyFailoverReason: ({ errorMessage }) =>
/\bworkers?_ai\b.*\b(?:rate|limit|quota)\b/i.test(errorMessage) ? "rate_limit" : undefined,
});
},
});

View File

@@ -34,5 +34,7 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildDeepSeekProvider,
},
matchesContextOverflowError: ({ errorMessage }) =>
/\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage),
},
});

View File

@@ -89,6 +89,8 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildMistralProvider,
allowExplicitBaseUrl: true,
},
matchesContextOverflowError: ({ errorMessage }) =>
/\bmistral\b.*(?:input.*too long|token limit.*exceeded)/i.test(errorMessage),
normalizeResolvedModel: ({ model }) => applyMistralModelCompat(model),
contributeResolvedModelCompat: ({ modelId, model }) =>
shouldContributeMistralCompat({ modelId, model }) ? MISTRAL_MODEL_COMPAT_PATCH : undefined,

View File

@@ -168,6 +168,9 @@ export default definePluginEntry({
client,
};
},
matchesContextOverflowError: ({ errorMessage }) =>
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
resolveSyntheticAuth: ({ providerConfig }) => {
const hasApiConfig =
Boolean(providerConfig?.api?.trim()) ||

View File

@@ -259,6 +259,8 @@ export function buildOpenAIProvider(): ProviderPlugin {
normalizeProviderId(ctx.provider) === PROVIDER_ID
? wrapOpenAIProviderStream(ctx)
: wrapAzureOpenAIProviderStream(ctx),
matchesContextOverflowError: ({ errorMessage }) =>
/content_filter.*(?:prompt|input).*(?:too long|exceed)/i.test(errorMessage),
resolveTransportTurnState: (ctx) => resolveOpenAITransportTurnState(ctx),
resolveWebSocketSessionPolicy: (ctx) => resolveOpenAIWebSocketSessionPolicy(ctx),
resolveReasoningOutputMode: () => "native",

View File

@@ -30,5 +30,9 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildTogetherProvider,
},
classifyFailoverReason: ({ errorMessage }) =>
/\bconcurrency limit\b.*\b(?:breached|reached)\b/i.test(errorMessage)
? "rate_limit"
: undefined,
},
});

View File

@@ -59,6 +59,12 @@ describe("classifyProviderSpecificError", () => {
expect(classifyProviderSpecificError("concurrency limit reached")).toBe("rate_limit");
});
it("classifies Cloudflare Workers AI quota errors as rate_limit", () => {
expect(classifyProviderSpecificError("workers_ai gateway error: quota limit exceeded")).toBe(
"rate_limit",
);
});
it("does not match generic 'model is not ready' without Bedrock prefix", () => {
expect(classifyProviderSpecificError("model is not ready")).toBeNull();
});

View File

@@ -1,11 +1,15 @@
/**
* Provider-specific error patterns that improve failover classification accuracy.
* Provider-owned error-pattern dispatch plus legacy fallback patterns.
*
* Many providers return errors in non-standard formats. Without these patterns,
* errors get misclassified (e.g., a context overflow classified as "format"),
* causing the failover engine to choose wrong recovery strategies.
* Most provider-specific failover classification now lives on provider-plugin
* hooks. This module keeps only fallback patterns for providers that do not
* yet ship a dedicated provider plugin hook surface.
*/
import {
classifyProviderFailoverReasonWithPlugin,
matchesProviderContextOverflowWithPlugin,
} from "../../plugins/provider-runtime.js";
import type { FailoverReason } from "./types.js";
type ProviderErrorPattern = {
@@ -21,30 +25,9 @@ type ProviderErrorPattern = {
* to catch provider-specific wording that the generic regex misses.
*/
export const PROVIDER_CONTEXT_OVERFLOW_PATTERNS: readonly RegExp[] = [
// AWS Bedrock
/ValidationException.*(?:input is too long|max input token|input token.*exceed)/i,
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
// Azure OpenAI (sometimes wraps OpenAI errors differently)
/content_filter.*(?:prompt|input).*(?:too long|exceed)/i,
// Ollama / local models
/\bollama\b.*(?:context length|too many tokens|context window)/i,
/\btruncating input\b.*\btoo long\b/i,
// Mistral
/\bmistral\b.*(?:input.*too long|token limit.*exceeded)/i,
// Cohere
// Cohere does not currently ship a bundled provider hook.
/\btotal tokens?.*exceeds? (?:the )?(?:model(?:'s)? )?(?:max|maximum|limit)/i,
// DeepSeek
/\bdeepseek\b.*(?:input.*too long|context.*exceed)/i,
// Google Vertex / Gemini: INVALID_ARGUMENT with token-related messages is context overflow.
/INVALID_ARGUMENT.*(?:exceeds? the (?:maximum|max)|input.*too (?:long|large))/i,
// Generic "input too long" pattern that isn't covered by existing checks
/\binput (?:is )?too long for (?:the )?model\b/i,
];
@@ -55,38 +38,11 @@ export const PROVIDER_CONTEXT_OVERFLOW_PATTERNS: readonly RegExp[] = [
* produce wrong results for specific providers.
*/
export const PROVIDER_SPECIFIC_PATTERNS: readonly ProviderErrorPattern[] = [
// AWS Bedrock: ThrottlingException is rate limit
{
test: /ThrottlingException|Too many concurrent requests/i,
reason: "rate_limit",
},
// AWS Bedrock: ModelNotReadyException (require class prefix to avoid false positives)
{
test: /ModelNotReadyException/i,
reason: "overloaded",
},
// Azure: content_policy_violation should not trigger failover
// (it's a content moderation rejection, not a transient error)
// Groq: model_deactivated is permanent
// Groq does not currently ship a bundled provider hook.
{
test: /model(?:_is)?_deactivated|model has been deactivated/i,
reason: "model_not_found",
},
// Together AI / Fireworks: specific rate limit messages
{
test: /\bconcurrency limit\b.*\breached\b/i,
reason: "rate_limit",
},
// Cloudflare Workers AI
{
test: /\bworkers?_ai\b.*\b(?:rate|limit|quota)\b/i,
reason: "rate_limit",
},
];
/**
@@ -94,7 +50,11 @@ export const PROVIDER_SPECIFIC_PATTERNS: readonly ProviderErrorPattern[] = [
* Called from `isContextOverflowError()` to catch provider-specific wording.
*/
export function matchesProviderContextOverflow(errorMessage: string): boolean {
return PROVIDER_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage));
return (
matchesProviderContextOverflowWithPlugin({
context: { errorMessage },
}) || PROVIDER_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(errorMessage))
);
}
/**
@@ -102,6 +62,12 @@ export function matchesProviderContextOverflow(errorMessage: string): boolean {
* Returns null if no provider-specific pattern matches (fall through to generic classification).
*/
export function classifyProviderSpecificError(errorMessage: string): FailoverReason | null {
const pluginReason = classifyProviderFailoverReasonWithPlugin({
context: { errorMessage },
});
if (pluginReason) {
return pluginReason;
}
for (const pattern of PROVIDER_SPECIFIC_PATTERNS) {
if (pattern.test.test(errorMessage)) {
return pattern.reason;

View File

@@ -1,7 +1,8 @@
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import { normalizeProviderId, parseModelRef } from "../agents/model-selection.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { normalizeProviderSpecificConfig } from "../agents/models-config.providers.policy.js";
import { applyProviderConfigDefaultsWithPlugin } from "../plugins/provider-runtime.js";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { resolveAgentModelPrimaryValue } from "./model-input.js";
import {
LEGACY_TALK_PROVIDER_ID,
normalizeTalkConfig,
@@ -16,8 +17,6 @@ type WarnState = { warned: boolean };
let defaultWarnState: WarnState = { warned: false };
type AnthropicAuthDefaultsMode = "api_key" | "oauth";
const DEFAULT_MODEL_ALIASES: Readonly<Record<string, string>> = {
// Anthropic (pi-ai catalog uses "latest" ids without date suffix)
opus: "anthropic/claude-opus-4-6",
@@ -54,16 +53,6 @@ const MISTRAL_SAFE_MAX_TOKENS_BY_MODEL = {
type ModelDefinitionLike = Partial<ModelDefinitionConfig> &
Pick<ModelDefinitionConfig, "id" | "name">;
function resolveDefaultProviderApi(
providerId: string,
providerApi: ModelDefinitionConfig["api"] | undefined,
): ModelDefinitionConfig["api"] | undefined {
if (providerApi) {
return providerApi;
}
return normalizeProviderId(providerId) === "anthropic" ? "anthropic-messages" : undefined;
}
function isPositiveNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}
@@ -98,58 +87,6 @@ export function resolveNormalizedProviderModelMaxTokens(params: {
return Math.min(safeMaxTokens, params.contextWindow);
}
function resolveAnthropicDefaultAuthMode(cfg: OpenClawConfig): AnthropicAuthDefaultsMode | null {
const profiles = cfg.auth?.profiles ?? {};
const anthropicProfiles = Object.entries(profiles).filter(
([, profile]) => profile?.provider === "anthropic",
);
const order = cfg.auth?.order?.anthropic ?? [];
for (const profileId of order) {
const entry = profiles[profileId];
if (!entry || entry.provider !== "anthropic") {
continue;
}
if (entry.mode === "api_key") {
return "api_key";
}
if (entry.mode === "oauth" || entry.mode === "token") {
return "oauth";
}
}
const hasApiKey = anthropicProfiles.some(([, profile]) => profile?.mode === "api_key");
const hasOauth = anthropicProfiles.some(
([, profile]) => profile?.mode === "oauth" || profile?.mode === "token",
);
if (hasApiKey && !hasOauth) {
return "api_key";
}
if (hasOauth && !hasApiKey) {
return "oauth";
}
if (process.env.ANTHROPIC_OAUTH_TOKEN?.trim()) {
return "oauth";
}
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return "api_key";
}
return null;
}
function resolvePrimaryModelRef(raw?: string): string | null {
if (!raw || typeof raw !== "string") {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const aliasKey = trimmed.toLowerCase();
return DEFAULT_MODEL_ALIASES[aliasKey] ?? trimmed;
}
export type SessionDefaultsOptions = {
warn?: (message: string) => void;
warnState?: WarnState;
@@ -242,15 +179,19 @@ export function applyModelDefaults(cfg: OpenClawConfig): OpenClawConfig {
if (providerConfig) {
const nextProviders = { ...providerConfig };
for (const [providerId, provider] of Object.entries(providerConfig)) {
const models = provider.models;
const normalizedProvider = normalizeProviderSpecificConfig(providerId, provider);
const models = normalizedProvider.models;
if (!Array.isArray(models) || models.length === 0) {
if (normalizedProvider !== provider) {
nextProviders[providerId] = normalizedProvider;
mutated = true;
}
continue;
}
const providerApi = resolveDefaultProviderApi(providerId, provider.api);
let nextProvider = provider;
if (providerApi && provider.api !== providerApi) {
const providerApi = normalizedProvider.api;
let nextProvider = normalizedProvider;
if (nextProvider !== provider) {
mutated = true;
nextProvider = { ...nextProvider, api: providerApi };
}
let providerMutated = false;
const nextModels = models.map((model) => {
@@ -434,105 +375,16 @@ export function applyLoggingDefaults(cfg: OpenClawConfig): OpenClawConfig {
}
export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig {
const defaults = cfg.agents?.defaults;
if (!defaults) {
return cfg;
}
const authMode = resolveAnthropicDefaultAuthMode(cfg);
if (!authMode) {
return cfg;
}
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = {
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h",
};
mutated = true;
}
if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = {
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m",
};
mutated = true;
}
if (authMode === "api_key") {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
const isAnthropicCacheRetentionTarget = (
parsed: { provider: string; model: string } | null | undefined,
): parsed is { provider: string; model: string } =>
Boolean(
parsed &&
(parsed.provider === "anthropic" ||
(parsed.provider === "amazon-bedrock" &&
parsed.model.toLowerCase().includes("anthropic.claude"))),
);
for (const [key, entry] of Object.entries(nextModels)) {
const parsed = parseModelRef(key, "anthropic");
if (!isAnthropicCacheRetentionTarget(parsed)) {
continue;
}
const current = entry ?? {};
const params = (current as { params?: Record<string, unknown> }).params ?? {};
if (typeof params.cacheRetention === "string") {
continue;
}
nextModels[key] = {
...(current as Record<string, unknown>),
params: { ...params, cacheRetention: "short" },
};
modelsMutated = true;
}
const primary = resolvePrimaryModelRef(
resolveAgentModelPrimaryValue(defaults.model) ?? undefined,
);
if (primary) {
const parsedPrimary = parseModelRef(primary, "anthropic");
if (isAnthropicCacheRetentionTarget(parsedPrimary)) {
const key = `${parsedPrimary.provider}/${parsedPrimary.model}`;
const entry = nextModels[key];
const current = entry ?? {};
const params = (current as { params?: Record<string, unknown> }).params ?? {};
if (typeof params.cacheRetention !== "string") {
nextModels[key] = {
...(current as Record<string, unknown>),
params: { ...params, cacheRetention: "short" },
};
modelsMutated = true;
}
}
}
if (modelsMutated) {
nextDefaults.models = nextModels;
mutated = true;
}
}
if (!mutated) {
return cfg;
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: nextDefaults,
},
};
return (
applyProviderConfigDefaultsWithPlugin({
provider: "anthropic",
context: {
provider: "anthropic",
config: cfg,
env: process.env,
},
}) ?? cfg
);
}
export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {

View File

@@ -18,6 +18,7 @@ import type {
ProviderAuthMethod,
ProviderAuthMethodNonInteractiveContext,
ProviderAuthResult,
ProviderApplyConfigDefaultsContext,
ProviderBuildMissingAuthMessageContext,
ProviderBuildUnknownModelHintContext,
ProviderBuiltInModelSuppressionContext,
@@ -28,6 +29,7 @@ import type {
ProviderDeferSyntheticProfileAuthContext,
ProviderDefaultThinkingPolicyContext,
ProviderDiscoveryContext,
ProviderFailoverErrorContext,
ProviderFetchUsageSnapshotContext,
ProviderModernModelPolicyContext,
ProviderNormalizeConfigContext,
@@ -77,6 +79,7 @@ export type {
ProviderCatalogResult,
ProviderDeferSyntheticProfileAuthContext,
ProviderAugmentModelCatalogContext,
ProviderApplyConfigDefaultsContext,
ProviderBuiltInModelSuppressionContext,
ProviderBuiltInModelSuppressionResult,
ProviderBuildMissingAuthMessageContext,
@@ -84,6 +87,7 @@ export type {
ProviderCacheTtlEligibilityContext,
ProviderDefaultThinkingPolicyContext,
ProviderFetchUsageSnapshotContext,
ProviderFailoverErrorContext,
ProviderModernModelPolicyContext,
ProviderNormalizeConfigContext,
ProviderNormalizeToolSchemasContext,

View File

@@ -34,7 +34,10 @@ let buildProviderAuthDoctorHintWithPlugin: typeof import("./provider-runtime.js"
let buildProviderMissingAuthMessageWithPlugin: typeof import("./provider-runtime.js").buildProviderMissingAuthMessageWithPlugin;
let buildProviderUnknownModelHintWithPlugin: typeof import("./provider-runtime.js").buildProviderUnknownModelHintWithPlugin;
let applyProviderNativeStreamingUsageCompatWithPlugin: typeof import("./provider-runtime.js").applyProviderNativeStreamingUsageCompatWithPlugin;
let applyProviderConfigDefaultsWithPlugin: typeof import("./provider-runtime.js").applyProviderConfigDefaultsWithPlugin;
let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin;
let classifyProviderFailoverReasonWithPlugin: typeof import("./provider-runtime.js").classifyProviderFailoverReasonWithPlugin;
let matchesProviderContextOverflowWithPlugin: typeof import("./provider-runtime.js").matchesProviderContextOverflowWithPlugin;
let normalizeProviderConfigWithPlugin: typeof import("./provider-runtime.js").normalizeProviderConfigWithPlugin;
let normalizeProviderModelIdWithPlugin: typeof import("./provider-runtime.js").normalizeProviderModelIdWithPlugin;
let applyProviderResolvedModelCompatWithPlugins: typeof import("./provider-runtime.js").applyProviderResolvedModelCompatWithPlugins;
@@ -253,9 +256,12 @@ describe("provider-runtime", () => {
buildProviderMissingAuthMessageWithPlugin,
buildProviderUnknownModelHintWithPlugin,
applyProviderNativeStreamingUsageCompatWithPlugin,
applyProviderConfigDefaultsWithPlugin,
applyProviderResolvedModelCompatWithPlugins,
applyProviderResolvedTransportWithPlugin,
classifyProviderFailoverReasonWithPlugin,
formatProviderAuthProfileApiKeyWithPlugin,
matchesProviderContextOverflowWithPlugin,
normalizeProviderConfigWithPlugin,
normalizeProviderModelIdWithPlugin,
normalizeProviderTransportWithPlugin,
@@ -383,6 +389,78 @@ describe("provider-runtime", () => {
});
});
it("resolves provider config defaults through owner plugins", () => {
resolveOwningPluginIdsForProviderMock.mockReturnValue(["anthropic"]);
resolvePluginProvidersMock.mockReturnValue([
{
id: "anthropic",
label: "Anthropic",
auth: [],
applyConfigDefaults: ({ config }) => ({
...config,
agents: {
defaults: {
heartbeat: { every: "1h" },
},
},
}),
},
]);
expect(
applyProviderConfigDefaultsWithPlugin({
provider: "anthropic",
context: {
provider: "anthropic",
env: {},
config: {},
},
}),
).toMatchObject({
agents: {
defaults: {
heartbeat: {
every: "1h",
},
},
},
});
});
it("resolves failover classification through hook-only aliases", () => {
resolvePluginProvidersMock.mockReturnValue([
{
id: "openai",
label: "OpenAI",
hookAliases: ["azure-openai-responses"],
auth: [],
matchesContextOverflowError: ({ errorMessage }) =>
/\bcontent_filter\b.*\btoo long\b/i.test(errorMessage),
classifyFailoverReason: ({ errorMessage }) =>
/\bquota exceeded\b/i.test(errorMessage) ? "rate_limit" : undefined,
},
]);
expect(
matchesProviderContextOverflowWithPlugin({
provider: "azure-openai-responses",
context: {
provider: "azure-openai-responses",
errorMessage: "content_filter prompt too long",
},
}),
).toBe(true);
expect(
classifyProviderFailoverReasonWithPlugin({
provider: "azure-openai-responses",
context: {
provider: "azure-openai-responses",
errorMessage: "quota exceeded",
},
}),
).toBe("rate_limit");
});
it("resolves stream wrapper hooks through hook-only aliases without provider ownership", () => {
const wrappedStreamFn = vi.fn();
resolvePluginProvidersMock.mockReturnValue([

View File

@@ -21,6 +21,7 @@ import type {
ProviderCreateStreamFnContext,
ProviderDefaultThinkingPolicyContext,
ProviderFetchUsageSnapshotContext,
ProviderFailoverErrorContext,
ProviderNormalizeToolSchemasContext,
ProviderNormalizeConfigContext,
ProviderNormalizeModelIdContext,
@@ -34,6 +35,7 @@ import type {
ProviderPrepareExtraParamsContext,
ProviderPrepareDynamicModelContext,
ProviderPrepareRuntimeAuthContext,
ProviderApplyConfigDefaultsContext,
ProviderResolveConfigApiKeyContext,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
@@ -593,6 +595,47 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: {
return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context);
}
export function matchesProviderContextOverflowWithPlugin(params: {
provider?: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderFailoverErrorContext;
}): boolean {
const plugins = params.provider
? [resolveProviderHookPlugin({ ...params, provider: params.provider })].filter(
(plugin): plugin is ProviderPlugin => Boolean(plugin),
)
: resolveProviderPluginsForHooks(params);
for (const plugin of plugins) {
if (plugin.matchesContextOverflowError?.(params.context)) {
return true;
}
}
return false;
}
export function classifyProviderFailoverReasonWithPlugin(params: {
provider?: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderFailoverErrorContext;
}) {
const plugins = params.provider
? [resolveProviderHookPlugin({ ...params, provider: params.provider })].filter(
(plugin): plugin is ProviderPlugin => Boolean(plugin),
)
: resolveProviderPluginsForHooks(params);
for (const plugin of plugins) {
const reason = plugin.classifyFailoverReason?.(params.context);
if (reason) {
return reason;
}
}
return undefined;
}
export function formatProviderAuthProfileApiKeyWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
@@ -663,6 +706,16 @@ export function resolveProviderDefaultThinkingLevel(params: {
return resolveProviderRuntimePlugin(params)?.resolveDefaultThinkingLevel?.(params.context);
}
export function applyProviderConfigDefaultsWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderApplyConfigDefaultsContext;
}) {
return resolveProviderRuntimePlugin(params)?.applyConfigDefaults?.(params.context) ?? undefined;
}
export function resolveProviderModernModelRef(params: {
provider: string;
config?: OpenClawConfig;

View File

@@ -1,9 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { Command } from "commander";
import type { IncomingMessage, ServerResponse } from "node:http";
import type {
ApiKeyCredential,
AuthProfileCredential,
@@ -11,6 +11,7 @@ import type {
AuthProfileStore,
} from "../agents/auth-profiles/types.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js";
import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
@@ -748,6 +749,30 @@ export type ProviderResolveWebSocketSessionPolicyContext = {
sessionId?: string;
};
/**
* Provider-owned failover error classification input.
*
* Use this when provider-specific transport or API errors need classification
* hints that generic string matching cannot express safely.
*/
export type ProviderFailoverErrorContext = {
provider?: string;
modelId?: string;
errorMessage: string;
};
/**
* Provider-owned config-default application input.
*
* Use this when a provider needs to add global config defaults that depend on
* provider auth mode or provider-specific model families.
*/
export type ProviderApplyConfigDefaultsContext = {
provider: string;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
};
/**
* Generic embedding provider shape returned by provider plugins.
*
@@ -1288,6 +1313,20 @@ export type ProviderPlugin = {
fetchUsageSnapshot?: (
ctx: ProviderFetchUsageSnapshotContext,
) => Promise<ProviderUsageSnapshot | null | undefined> | ProviderUsageSnapshot | null | undefined;
/**
* Provider-owned failover context-overflow matcher.
*
* Return true when the provider recognizes the raw error as a context-window
* overflow shape that generic heuristics would miss.
*/
matchesContextOverflowError?: (ctx: ProviderFailoverErrorContext) => boolean | undefined;
/**
* Provider-owned failover error classification.
*
* Return a failover reason when the provider recognizes a provider-specific
* raw error shape. Return undefined to fall back to generic classification.
*/
classifyFailoverReason?: (ctx: ProviderFailoverErrorContext) => FailoverReason | null | undefined;
/**
* Provider-owned cache TTL eligibility.
*
@@ -1359,6 +1398,15 @@ export type ProviderPlugin = {
resolveDefaultThinkingLevel?: (
ctx: ProviderDefaultThinkingPolicyContext,
) => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | null | undefined;
/**
* Provider-owned global config defaults.
*
* Use this when config materialization needs provider-specific defaults that
* depend on auth mode, env, or provider model-family semantics.
*/
applyConfigDefaults?: (
ctx: ProviderApplyConfigDefaultsContext,
) => OpenClawConfig | null | undefined;
/**
* Provider-owned "modern model" matcher used by live profile/smoke filters.
*