diff --git a/extensions/amazon-bedrock/register.runtime.ts b/extensions/amazon-bedrock/register.runtime.ts index fbc552f9ae2..d6e72731249 100644 --- a/extensions/amazon-bedrock/register.runtime.ts +++ b/extensions/amazon-bedrock/register.runtime.ts @@ -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 { const guardrail = (api.pluginConfig as Record | 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, }); diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts new file mode 100644 index 00000000000..b84c04f3eb1 --- /dev/null +++ b/extensions/anthropic/config-defaults.ts @@ -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( + 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 }).params ?? {}; + if (typeof paramsValue.cacheRetention === "string") { + continue; + } + nextModels[key] = { + ...(current as Record), + 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 }).params ?? {}; + if (typeof paramsValue.cacheRetention !== "string") { + nextModels[key] = { + ...(current as Record), + 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, + }, + }; +} diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 94e777fbe46..339aef8ec35 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -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"); + }); }); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 964ba629289..c9f0f380d0a 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -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 normalizeAnthropicProviderConfig(providerConfig), + applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }), resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx), buildReplayPolicy: (ctx) => buildAnthropicReplayPolicy(ctx), isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId), diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 11a2dce9274..15f758c0740 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -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, }); }, }); diff --git a/extensions/deepseek/index.ts b/extensions/deepseek/index.ts index cc8086e23c4..f071942df99 100644 --- a/extensions/deepseek/index.ts +++ b/extensions/deepseek/index.ts @@ -34,5 +34,7 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildDeepSeekProvider, }, + matchesContextOverflowError: ({ errorMessage }) => + /\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage), }, }); diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index acec875b306..402e1592e4a 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -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, diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index a7e40f47ef7..7c4de35cbb6 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -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()) || diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index ebce103429c..7f4af5f87e4 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -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", diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 42ecad1a3a2..92dca5f5dda 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -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, }, }); diff --git a/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts b/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts index ca4db4dfd19..5848ed244b1 100644 --- a/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts +++ b/src/agents/pi-embedded-helpers/provider-error-patterns.test.ts @@ -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(); }); diff --git a/src/agents/pi-embedded-helpers/provider-error-patterns.ts b/src/agents/pi-embedded-helpers/provider-error-patterns.ts index 391d727c717..233a34a5bbe 100644 --- a/src/agents/pi-embedded-helpers/provider-error-patterns.ts +++ b/src/agents/pi-embedded-helpers/provider-error-patterns.ts @@ -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; diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 21ad26a2156..2b36d325353 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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> = { // 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 & Pick; -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 }).params ?? {}; - if (typeof params.cacheRetention === "string") { - continue; - } - nextModels[key] = { - ...(current as Record), - 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 }).params ?? {}; - if (typeof params.cacheRetention !== "string") { - nextModels[key] = { - ...(current as Record), - 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 { diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 5f86d1ca98a..43be5b139f5 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -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, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 1b9ec00d63f..19801c9f07c 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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([ diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e51ed7432c6..77c5aa8bdcf 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 8b760cd753b..2a67e341013 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; + /** + * 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. *