mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: align prompt failover fallback gating (#1136) (thanks @cheeeee)
This commit is contained in:
@@ -10,6 +10,9 @@ Docs: https://docs.clawd.bot
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
|
||||
### Fixes
|
||||
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
import { hasModelFallbackCandidates, runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||
return {
|
||||
@@ -310,3 +310,73 @@ describe("runWithModelFallback", () => {
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasModelFallbackCandidates", () => {
|
||||
it("returns false when only the primary candidate is available", () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the configured primary differs from the requested model", () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openrouter",
|
||||
model: "meta-llama/llama-3.3-70b:free",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit empty fallbacksOverride", () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when fallbacksOverride provides extra candidates", () => {
|
||||
const cfg = makeCfg();
|
||||
|
||||
expect(
|
||||
hasModelFallbackCandidates({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
fallbacksOverride: ["openai/gpt-4.1"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,16 @@ function resolveFallbackCandidates(params: {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function hasModelFallbackCandidates(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
}): boolean {
|
||||
return resolveFallbackCandidates(params).length > 1;
|
||||
}
|
||||
|
||||
export async function runWithModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import { resolveAgentModelFallbacksOverride, resolveSessionAgentId } from "../agent-scope.js";
|
||||
import {
|
||||
markAuthProfileFailure,
|
||||
markAuthProfileGood,
|
||||
@@ -16,12 +17,13 @@ import {
|
||||
resolveContextWindowInfo,
|
||||
} from "../context-window-guard.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||
import { FailoverError, coerceToFailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../model-auth.js";
|
||||
import { hasModelFallbackCandidates } from "../model-fallback.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isFailoverAssistantError,
|
||||
isFailoverErrorMessage,
|
||||
isRateLimitAssistantError,
|
||||
isTimeoutErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
@@ -88,6 +89,20 @@ export async function runEmbeddedPiAgent(
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const sessionAgentId = resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const fallbacksOverride = resolveAgentModelFallbacksOverride(
|
||||
params.config ?? {},
|
||||
sessionAgentId,
|
||||
);
|
||||
const hasModelFallbacks = hasModelFallbackCandidates({
|
||||
cfg: params.config,
|
||||
provider,
|
||||
model: modelId,
|
||||
fallbacksOverride,
|
||||
});
|
||||
|
||||
const ctxInfo = resolveContextWindowInfo({
|
||||
cfg: params.config,
|
||||
@@ -290,7 +305,14 @@ export async function runEmbeddedPiAgent(
|
||||
},
|
||||
};
|
||||
}
|
||||
const promptFailoverReason = classifyFailoverReason(errorText);
|
||||
const promptFailoverError =
|
||||
coerceToFailoverError(promptError, {
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: lastProfileId,
|
||||
}) ?? null;
|
||||
const promptFailoverReason =
|
||||
promptFailoverError?.reason ?? classifyFailoverReason(errorText);
|
||||
if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) {
|
||||
await markAuthProfileFailure({
|
||||
store: authStore,
|
||||
@@ -301,7 +323,7 @@ export async function runEmbeddedPiAgent(
|
||||
});
|
||||
}
|
||||
if (
|
||||
isFailoverErrorMessage(errorText) &&
|
||||
promptFailoverReason &&
|
||||
promptFailoverReason !== "timeout" &&
|
||||
(await advanceAuthProfile())
|
||||
) {
|
||||
@@ -318,17 +340,14 @@ export async function runEmbeddedPiAgent(
|
||||
thinkLevel = fallbackThinking;
|
||||
continue;
|
||||
}
|
||||
// FIX: Throw FailoverError for prompt errors when fallbacks configured
|
||||
// This enables model fallback for quota/rate limit errors during prompt submission
|
||||
const promptFallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||
if (promptFailoverReason && hasModelFallbacks) {
|
||||
if (promptFailoverError) throw promptFailoverError;
|
||||
throw new FailoverError(errorText, {
|
||||
reason: promptFailoverReason ?? "unknown",
|
||||
reason: promptFailoverReason,
|
||||
provider,
|
||||
model: modelId,
|
||||
profileId: lastProfileId,
|
||||
status: resolveFailoverStatus(promptFailoverReason ?? "unknown"),
|
||||
status: resolveFailoverStatus(promptFailoverReason),
|
||||
});
|
||||
}
|
||||
throw promptError;
|
||||
@@ -346,8 +365,6 @@ export async function runEmbeddedPiAgent(
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||
@@ -385,7 +402,7 @@ export async function runEmbeddedPiAgent(
|
||||
const rotated = await advanceAuthProfile();
|
||||
if (rotated) continue;
|
||||
|
||||
if (fallbackConfigured) {
|
||||
if (hasModelFallbacks) {
|
||||
// Prefer formatted error message (user-friendly) over raw errorMessage
|
||||
const message =
|
||||
(lastAssistant
|
||||
|
||||
@@ -45,13 +45,21 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
hasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
createReplyDispatcherWithTyping,
|
||||
dispatchReplyWithBufferedBlockDispatcher: async (params) => {
|
||||
await dispatchReplyWithBufferedBlockDispatcher(
|
||||
params as Parameters<typeof dispatchReplyWithBufferedBlockDispatcher>[0],
|
||||
);
|
||||
},
|
||||
createReplyDispatcherWithTyping: (...args) =>
|
||||
createReplyDispatcherWithTyping(
|
||||
...(args as Parameters<typeof createReplyDispatcherWithTyping>),
|
||||
),
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute,
|
||||
resolveAgentRoute: (params) =>
|
||||
resolveAgentRoute(params as Parameters<typeof resolveAgentRoute>[0]),
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply,
|
||||
@@ -60,19 +68,61 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia,
|
||||
saveMediaBuffer,
|
||||
saveMediaBuffer: (buffer, contentType, direction, maxBytes) =>
|
||||
saveMediaBuffer(
|
||||
Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
|
||||
contentType,
|
||||
direction,
|
||||
maxBytes,
|
||||
),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy: resolveChannelGroupPolicy,
|
||||
resolveRequireMention: resolveChannelGroupRequireMention,
|
||||
resolveGroupPolicy: (cfg, channel, accountId, groupId) =>
|
||||
resolveChannelGroupPolicy({
|
||||
cfg,
|
||||
channel: channel as Parameters<typeof resolveChannelGroupPolicy>[0]["channel"],
|
||||
accountId,
|
||||
groupId,
|
||||
}),
|
||||
resolveRequireMention: (cfg, channel, accountId, groupId, override) =>
|
||||
resolveChannelGroupRequireMention({
|
||||
cfg,
|
||||
channel: channel as Parameters<typeof resolveChannelGroupRequireMention>[0]["channel"],
|
||||
accountId,
|
||||
groupId,
|
||||
requireMentionOverride: override,
|
||||
}),
|
||||
},
|
||||
debounce: {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
createInboundDebouncer: (opts) => {
|
||||
const keys = new Set<string>();
|
||||
const debouncer = createInboundDebouncer({
|
||||
debounceMs: opts.debounceMs,
|
||||
buildKey: (item) => {
|
||||
const key = opts.buildKey(item);
|
||||
if (key) keys.add(key);
|
||||
return key;
|
||||
},
|
||||
shouldDebounce: opts.shouldDebounce,
|
||||
onFlush: opts.onFlush,
|
||||
onError: opts.onError ? (err) => opts.onError?.(err) : undefined,
|
||||
});
|
||||
return {
|
||||
push: (value) => {
|
||||
void debouncer.enqueue(value);
|
||||
},
|
||||
flush: async () => {
|
||||
const pending = Array.from(keys);
|
||||
keys.clear();
|
||||
await Promise.all(pending.map((key) => debouncer.flushKey(key)));
|
||||
},
|
||||
};
|
||||
},
|
||||
resolveInboundDebounceMs: (cfg, channel) => resolveInboundDebounceMs({ cfg, channel }),
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
@@ -81,7 +131,10 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
logging: {
|
||||
shouldLogVerbose,
|
||||
getChildLogger: (bindings, opts) => {
|
||||
const logger = getChildLogger(bindings, opts);
|
||||
const logger = getChildLogger(
|
||||
bindings,
|
||||
opts as Parameters<typeof getChildLogger>[1],
|
||||
);
|
||||
return {
|
||||
debug: (message) => logger.debug?.(message),
|
||||
info: (message) => logger.info(message),
|
||||
@@ -91,7 +144,7 @@ export function createPluginRuntime(): PluginRuntime {
|
||||
},
|
||||
},
|
||||
state: {
|
||||
resolveStateDir,
|
||||
resolveStateDir: () => resolveStateDir(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user