mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(system-agent): keep inference available across routes (#120712)
* fix(system-agent): keep inference available across routes Accept provider-owned equivalent response model identities and route every new OpenClaw chat through the configured/authenticated inference fallback ladder. Malformed replies may fall through while provider/model and execution-owner uncertainty remain fail-closed. Fixes #120711 * fix(system-agent): keep malformed fallback route-scoped Continue to later configured routes from the same provider after empty or malformed model output. Timeout and unavailable results remain provider-wide, while owner and identity uncertainty remain fail-closed. Addresses ClawSweeper P1 on #120712.
This commit is contained in:
committed by
GitHub
parent
51944498eb
commit
fb911a33ac
@@ -1,6 +1,7 @@
|
||||
// Openai tests cover provider policy api plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isResponseModelEquivalent,
|
||||
normalizeModelCatalogId,
|
||||
resolveModelRoutes,
|
||||
resolveThinkingProfile,
|
||||
@@ -15,6 +16,22 @@ describe("OpenAI provider policy artifact", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["openai", "gpt-5.6", "gpt-5.6-sol", true],
|
||||
["openai", "gpt-5.6", "gpt-5.6-terra", false],
|
||||
["openai", "gpt-5.6", "gpt-5.6-luna", false],
|
||||
["openai", "gpt-5.6-sol", "gpt-5.6", false],
|
||||
["openai", "gpt-5.5", "gpt-5.6-sol", false],
|
||||
["anthropic", "gpt-5.6", "gpt-5.6-sol", false],
|
||||
])(
|
||||
"declares response-model equivalence for %s/%s -> %s as %s",
|
||||
(provider, requestedModelId, responseModelId, expected) => {
|
||||
expect(isResponseModelEquivalent({ provider, requestedModelId, responseModelId })).toBe(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes the legacy Codex model alias at the provider boundary", () => {
|
||||
expect(normalizeModelCatalogId({ provider: " OpenAI ", modelId: "openai/GPT-5.4-CODEX" })).toBe(
|
||||
"gpt-5.4",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProviderModelRouteResolution,
|
||||
ProviderModelRouteSource,
|
||||
ProviderNormalizeModelCatalogIdContext,
|
||||
ProviderResponseModelEquivalenceContext,
|
||||
ProviderResolveModelRoutesContext,
|
||||
} from "openclaw/plugin-sdk/provider-model-types";
|
||||
import {
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
isOpenAIPlatformOnlyRouteModelId,
|
||||
isOpenAISubscriptionOnlyRouteModelId,
|
||||
normalizeOpenAIModelRouteId,
|
||||
OPENAI_GPT_56_MODEL_ID,
|
||||
OPENAI_GPT_56_SOL_MODEL_ID,
|
||||
} from "./model-route-contract.js";
|
||||
import { resolveUnifiedOpenAIThinkingProfile } from "./thinking-policy.js";
|
||||
|
||||
@@ -55,6 +58,14 @@ export function normalizeModelCatalogId(params: ProviderNormalizeModelCatalogIdC
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isResponseModelEquivalent(params: ProviderResponseModelEquivalenceContext) {
|
||||
return (
|
||||
params.provider.trim().toLowerCase() === OPENAI_PROVIDER_ID &&
|
||||
params.requestedModelId === OPENAI_GPT_56_MODEL_ID &&
|
||||
params.responseModelId === OPENAI_GPT_56_SOL_MODEL_ID
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves authored OpenAI provider config without activating the runtime plugin. */
|
||||
export function resolveAuthoredOpenAIProviderConfig(params: {
|
||||
provider: string;
|
||||
|
||||
@@ -7,7 +7,9 @@ import { resetCommandQueueStateForTest } from "../../process/command-queue.test-
|
||||
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./types.js";
|
||||
|
||||
const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }));
|
||||
const inferenceFallbackMocks = vi.hoisted(() => ({
|
||||
verifySystemAgentInferenceWithFallback: vi.fn(),
|
||||
}));
|
||||
const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
appendTranscriptReset: vi.fn(),
|
||||
appendTranscriptTurn: vi.fn(),
|
||||
@@ -21,8 +23,9 @@ const greetingMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const onboardingWelcomeMocks = vi.hoisted(() => ({ buildOnboardingWelcome: vi.fn() }));
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
verifySetupInference: setupInferenceMocks.verifySetupInference,
|
||||
vi.mock("../../system-agent/inference-fallback.js", () => ({
|
||||
verifySystemAgentInferenceWithFallback:
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback,
|
||||
}));
|
||||
vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
appendTranscriptReset: transcriptStoreMocks.appendTranscriptReset,
|
||||
@@ -121,7 +124,10 @@ const quickActions = {
|
||||
|
||||
beforeEach(() => {
|
||||
createdEngines.length = 0;
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValue({ ok: true, binding: {} });
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValue({
|
||||
ok: true,
|
||||
binding: {},
|
||||
});
|
||||
greetingMocks.loadSystemAgentGreetingFacts.mockReturnValue({
|
||||
updateAvailable: null,
|
||||
channelHealth: { available: true, degraded: [] },
|
||||
|
||||
@@ -15,7 +15,9 @@ import { withTempDir } from "../../test-helpers/temp-dir.js";
|
||||
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./types.js";
|
||||
|
||||
const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }));
|
||||
const inferenceFallbackMocks = vi.hoisted(() => ({
|
||||
verifySystemAgentInferenceWithFallback: vi.fn(),
|
||||
}));
|
||||
const greetingMocks = vi.hoisted(() => ({
|
||||
acknowledgeSystemAgentGreetingDelivery: vi.fn(),
|
||||
buildSystemAgentGreetingQuestion: vi.fn(() => undefined),
|
||||
@@ -28,8 +30,9 @@ const greetingMocks = vi.hoisted(() => ({
|
||||
resolveSystemAgentGreeting: vi.fn(async () => ({ text: "welcome text", source: "template" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
verifySetupInference: setupInferenceMocks.verifySetupInference,
|
||||
vi.mock("../../system-agent/inference-fallback.js", () => ({
|
||||
verifySystemAgentInferenceWithFallback:
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback,
|
||||
}));
|
||||
// The transcript store is deliberately NOT mocked: the boundary under test is
|
||||
// what survives in the durable store. Only the caretaker greeting is stubbed so
|
||||
@@ -55,7 +58,7 @@ const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
beforeEach(async () => {
|
||||
const fixture = await createSystemAgentVerifiedInferenceTestFixture(verifiedConfig);
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValue({
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValue({
|
||||
ok: true,
|
||||
modelRef: "openai/gpt-5.5",
|
||||
latencyMs: 10,
|
||||
@@ -65,7 +68,7 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setupInferenceMocks.verifySetupInference.mockReset();
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockReset();
|
||||
closeOpenClawStateDatabase();
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
@@ -137,7 +140,7 @@ describe("openclaw.chat reset boundary", () => {
|
||||
[
|
||||
"inference verification fails",
|
||||
() => {
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValueOnce({
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "no configured model",
|
||||
@@ -180,7 +183,7 @@ describe("openclaw.chat reset boundary", () => {
|
||||
for (const turn of PRE_RESET_TURNS) {
|
||||
appendTranscriptTurn(turn);
|
||||
}
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValueOnce({
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "no configured model",
|
||||
|
||||
@@ -7,8 +7,7 @@ import { SystemAgentWizardAnswerError } from "../../system-agent/chat-engine.js"
|
||||
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
|
||||
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
|
||||
|
||||
const setupInferenceMocks = vi.hoisted(() => ({ verifySetupInference: vi.fn() }));
|
||||
const delegatedInferenceMocks = vi.hoisted(() => ({
|
||||
const inferenceFallbackMocks = vi.hoisted(() => ({
|
||||
verifySystemAgentInferenceWithFallback: vi.fn(),
|
||||
}));
|
||||
const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
@@ -17,12 +16,9 @@ const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
readTranscriptTail: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
verifySetupInference: setupInferenceMocks.verifySetupInference,
|
||||
}));
|
||||
vi.mock("../../system-agent/inference-fallback.js", () => ({
|
||||
verifySystemAgentInferenceWithFallback:
|
||||
delegatedInferenceMocks.verifySystemAgentInferenceWithFallback,
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback,
|
||||
}));
|
||||
vi.mock("../../system-agent/transcript-store.js", () => transcriptStoreMocks);
|
||||
// Ownership tests exercise fresh-session creation; keep the caretaker greeting
|
||||
@@ -142,8 +138,7 @@ async function callChat(
|
||||
|
||||
beforeEach(() => {
|
||||
createdEngines.length = 0;
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValue({ ok: true, binding: {} });
|
||||
delegatedInferenceMocks.verifySystemAgentInferenceWithFallback.mockResolvedValue({
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValue({
|
||||
ok: true,
|
||||
binding: {},
|
||||
});
|
||||
@@ -234,7 +229,7 @@ describe("openclaw.chat session ownership", () => {
|
||||
});
|
||||
expect(expire).not.toHaveBeenCalled();
|
||||
expect(engine.dispose).not.toHaveBeenCalled();
|
||||
expect(setupInferenceMocks.verifySetupInference).not.toHaveBeenCalled();
|
||||
expect(inferenceFallbackMocks.verifySystemAgentInferenceWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the same authenticated principal resume after reconnecting", async () => {
|
||||
@@ -300,6 +295,10 @@ describe("openclaw.chat session ownership", () => {
|
||||
{ sessionId: "delegated", delegation },
|
||||
makeClient({ connId: "conn-owner", deviceId: "device-owner" }),
|
||||
);
|
||||
expect(inferenceFallbackMocks.verifySystemAgentInferenceWithFallback).toHaveBeenCalledWith({
|
||||
requestingAgentId: "main",
|
||||
runtime: expect.anything(),
|
||||
});
|
||||
const handle = expectDefined(createdEngines[0], "created delegated engine").handle;
|
||||
|
||||
const resumed = await callChat(
|
||||
@@ -314,6 +313,7 @@ describe("openclaw.chat session ownership", () => {
|
||||
|
||||
expect(resumed.ok).toBe(true);
|
||||
expect(handle).toHaveBeenCalledWith("continue");
|
||||
expect(inferenceFallbackMocks.verifySystemAgentInferenceWithFallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects delegated reuse of a non-delegated session", async () => {
|
||||
@@ -367,7 +367,7 @@ describe("openclaw.chat session responses", () => {
|
||||
details: { code: "system_agent_session_invalidated" },
|
||||
},
|
||||
});
|
||||
expect(setupInferenceMocks.verifySetupInference).not.toHaveBeenCalled();
|
||||
expect(inferenceFallbackMocks.verifySystemAgentInferenceWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a structured answer when the active session has no hosted wizard", async () => {
|
||||
|
||||
@@ -39,10 +39,10 @@ import type { GatewayClient, GatewayRequestContext } from "./types.js";
|
||||
|
||||
const setupInferenceMocks = vi.hoisted(() => ({
|
||||
activateSetupInference: vi.fn(),
|
||||
detectSetupInference: vi.fn(),
|
||||
resolvePersistentApplyInference: vi.fn(),
|
||||
verifySetupInference: vi.fn(),
|
||||
}));
|
||||
const inferenceFallbackMocks = vi.hoisted(() => ({ verify: vi.fn() }));
|
||||
const setupInferenceDetectionMocks = vi.hoisted(() => ({
|
||||
detectSetupInferenceIsolated: vi.fn(),
|
||||
}));
|
||||
@@ -71,10 +71,12 @@ const onboardingWelcomeMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../../system-agent/setup-inference.js", () => ({
|
||||
activateSetupInference: setupInferenceMocks.activateSetupInference,
|
||||
detectSetupInference: setupInferenceMocks.detectSetupInference,
|
||||
resolvePersistentApplyInference: setupInferenceMocks.resolvePersistentApplyInference,
|
||||
verifySetupInference: setupInferenceMocks.verifySetupInference,
|
||||
}));
|
||||
vi.mock("../../system-agent/inference-fallback.js", () => ({
|
||||
verifySystemAgentInferenceWithFallback: inferenceFallbackMocks.verify,
|
||||
}));
|
||||
vi.mock("../../system-agent/setup-inference-detection.js", () => ({
|
||||
detectSetupInferenceIsolated: setupInferenceDetectionMocks.detectSetupInferenceIsolated,
|
||||
}));
|
||||
@@ -238,6 +240,12 @@ beforeEach(() => {
|
||||
latencyMs: 10,
|
||||
binding: verifiedInference,
|
||||
});
|
||||
inferenceFallbackMocks.verify.mockResolvedValue({
|
||||
ok: true,
|
||||
modelRef: "openai/gpt-5.5",
|
||||
latencyMs: 10,
|
||||
binding: verifiedInference,
|
||||
});
|
||||
setupInferenceMocks.resolvePersistentApplyInference.mockResolvedValue(
|
||||
requireVerifiedInferenceFixture().configuredRoute,
|
||||
);
|
||||
@@ -467,7 +475,7 @@ describe("openclaw.setup", () => {
|
||||
|
||||
describe("openclaw.chat", () => {
|
||||
it("refuses to create a session before inference is available", async () => {
|
||||
setupInferenceMocks.verifySetupInference.mockResolvedValueOnce({
|
||||
inferenceFallbackMocks.verify.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "no configured model",
|
||||
@@ -487,13 +495,16 @@ describe("openclaw.chat", () => {
|
||||
},
|
||||
});
|
||||
expect(sessions.size).toBe(0);
|
||||
expect(inferenceFallbackMocks.verify).toHaveBeenCalledWith({
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces concurrent initialization for the same session", async () => {
|
||||
stubEngineOverview();
|
||||
const started = createDeferred();
|
||||
const release = createDeferred();
|
||||
setupInferenceMocks.verifySetupInference.mockImplementation(async () => {
|
||||
inferenceFallbackMocks.verify.mockImplementation(async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return {
|
||||
@@ -513,7 +524,7 @@ describe("openclaw.chat", () => {
|
||||
release.resolve();
|
||||
const [firstCall, secondCall] = await Promise.all([first, second]);
|
||||
|
||||
expect(setupInferenceMocks.verifySetupInference).toHaveBeenCalledOnce();
|
||||
expect(inferenceFallbackMocks.verify).toHaveBeenCalledOnce();
|
||||
expect(sessions.size).toBe(1);
|
||||
expect([firstCall.ok, secondCall.ok]).toEqual([true, true]);
|
||||
});
|
||||
@@ -928,7 +939,7 @@ describe("openclaw.chat", () => {
|
||||
expect(createSafeGatewayRestartPreflight().counts.queueSize).toBe(0);
|
||||
});
|
||||
|
||||
it("drops a failed session and requires fresh inference on retry", async () => {
|
||||
it("reuses a live session, then requires fresh fallback verification after failure", async () => {
|
||||
stubEngineOverview();
|
||||
const engine = makeVerifiedEngine();
|
||||
vi.spyOn(engine, "handle").mockRejectedValue(
|
||||
@@ -950,12 +961,12 @@ describe("openclaw.chat", () => {
|
||||
});
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(sessions.has("s1")).toBe(false);
|
||||
expect(setupInferenceMocks.verifySetupInference).not.toHaveBeenCalled();
|
||||
expect(inferenceFallbackMocks.verify).not.toHaveBeenCalled();
|
||||
|
||||
const retried = await callChat(context, { sessionId: "s1" });
|
||||
|
||||
expect(retried.ok).toBe(true);
|
||||
expect(setupInferenceMocks.verifySetupInference).toHaveBeenCalledOnce();
|
||||
expect(inferenceFallbackMocks.verify).toHaveBeenCalledOnce();
|
||||
expect(sessions.has("s1")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -578,18 +578,12 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
params.wizardAnswer === undefined &&
|
||||
(params.message === undefined || !params.message.trim());
|
||||
if (!session) {
|
||||
const inference = params.delegation
|
||||
? await import("../../system-agent/inference-fallback.js").then(
|
||||
({ verifySystemAgentInferenceWithFallback }) =>
|
||||
verifySystemAgentInferenceWithFallback({
|
||||
requestingAgentId: params.delegation?.agentId,
|
||||
runtime: defaultRuntime,
|
||||
}),
|
||||
)
|
||||
: await import("../../system-agent/setup-inference.js").then(
|
||||
({ verifySetupInference }) =>
|
||||
verifySetupInference({ runtime: defaultRuntime, bindSession: true }),
|
||||
);
|
||||
const { verifySystemAgentInferenceWithFallback } =
|
||||
await import("../../system-agent/inference-fallback.js");
|
||||
const inference = await verifySystemAgentInferenceWithFallback({
|
||||
...(params.delegation ? { requestingAgentId: params.delegation.agentId } : {}),
|
||||
runtime: defaultRuntime,
|
||||
});
|
||||
if (!inference.ok) {
|
||||
respond(
|
||||
false,
|
||||
|
||||
@@ -69,3 +69,10 @@ export type ProviderNormalizeModelCatalogIdContext = {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
/** Compares reported response identity without rewriting authored route identity. */
|
||||
export type ProviderResponseModelEquivalenceContext = {
|
||||
provider: string;
|
||||
requestedModelId: string;
|
||||
responseModelId: string;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,11 @@ describe("direct provider policy surface", () => {
|
||||
throw new Error("unexpected manifest registry import");
|
||||
});
|
||||
const resolveModelRoutes = vi.fn();
|
||||
const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({ resolveModelRoutes }));
|
||||
const isResponseModelEquivalent = vi.fn();
|
||||
const loadBundledPluginPublicArtifactModuleSync = vi.fn(() => ({
|
||||
resolveModelRoutes,
|
||||
isResponseModelEquivalent,
|
||||
}));
|
||||
|
||||
vi.doMock("./bundled-dir.js", () => ({
|
||||
resolveBundledPluginsDir: () => "/tmp/bundled-plugins",
|
||||
@@ -31,6 +35,7 @@ describe("direct provider policy surface", () => {
|
||||
const surface = resolveDirectBundledProviderPolicySurface("openai");
|
||||
|
||||
expect(surface?.resolveModelRoutes).toBe(resolveModelRoutes);
|
||||
expect(surface?.isResponseModelEquivalent).toBe(isResponseModelEquivalent);
|
||||
expect(loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({
|
||||
dirName: "openai",
|
||||
artifactBasename: "provider-policy-api.js",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type {
|
||||
ProviderModelRouteResolution,
|
||||
ProviderNormalizeModelCatalogIdContext,
|
||||
ProviderResponseModelEquivalenceContext,
|
||||
ProviderResolveModelRoutesContext,
|
||||
} from "../plugin-sdk/provider-model-types.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
@@ -49,6 +50,9 @@ export type ProviderPolicySurface = {
|
||||
normalizeModelCatalogId?: (
|
||||
ctx: ProviderNormalizeModelCatalogIdContext,
|
||||
) => string | null | undefined;
|
||||
isResponseModelEquivalent?: (
|
||||
ctx: ProviderResponseModelEquivalenceContext,
|
||||
) => boolean | null | undefined;
|
||||
};
|
||||
|
||||
/** Provider policy hooks loaded only from bundled plugin public artifacts. */
|
||||
@@ -71,6 +75,7 @@ const PROVIDER_POLICY_HOOK_KEYS = [
|
||||
"resolveThinkingProfile",
|
||||
"resolveModelRoutes",
|
||||
"normalizeModelCatalogId",
|
||||
"isResponseModelEquivalent",
|
||||
] as const satisfies readonly (keyof ProviderPolicySurface)[];
|
||||
|
||||
function extractProviderPolicySurface(mod: Record<string, unknown>): ProviderPolicySurface | null {
|
||||
|
||||
@@ -23,7 +23,7 @@ const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: { model: { primary: "zeta/model" } },
|
||||
list: [
|
||||
{ id: "requester", model: "zeta/model" },
|
||||
{ id: "requester", default: true, model: "zeta/model" },
|
||||
{ id: "beta", model: "beta/model" },
|
||||
{ id: "alpha", model: "alpha/model" },
|
||||
],
|
||||
@@ -31,7 +31,7 @@ const config: OpenClawConfig = {
|
||||
};
|
||||
|
||||
describe("system-agent inference fallback", () => {
|
||||
it("tries requester first, then authenticated providers by provider id", async () => {
|
||||
it("tries the default route first, then authenticated providers by provider id", async () => {
|
||||
const attempts: string[] = [];
|
||||
const verify = vi.fn(async ({ agentId }: { agentId: string }) => {
|
||||
attempts.push(agentId);
|
||||
@@ -41,7 +41,6 @@ describe("system-agent inference fallback", () => {
|
||||
});
|
||||
|
||||
const result = await verifySystemAgentInferenceWithFallback({
|
||||
requestingAgentId: "requester",
|
||||
runtime,
|
||||
deps: {
|
||||
readConfig: async () => config,
|
||||
@@ -196,6 +195,40 @@ describe("system-agent inference fallback", () => {
|
||||
expect(attempts).toEqual(["requester", "alpha-other"]);
|
||||
});
|
||||
|
||||
it("tries another route of the same provider after a malformed response", async () => {
|
||||
const attempts: string[] = [];
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: { model: { primary: "alpha/model" } },
|
||||
list: [
|
||||
{ id: "requester", model: "alpha/model" },
|
||||
{ id: "alpha-other", model: "alpha/model" },
|
||||
{ id: "beta", model: "beta/model" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await verifySystemAgentInferenceWithFallback({
|
||||
requestingAgentId: "requester",
|
||||
runtime,
|
||||
deps: {
|
||||
readConfig: async () => cfg,
|
||||
resolveRoute: async (_cfg, agentId) =>
|
||||
route(agentId, agentId === "beta" ? "beta" : "alpha"),
|
||||
hasAuth: async () => true,
|
||||
verify: async ({ agentId }) => {
|
||||
attempts.push(agentId);
|
||||
return agentId === "alpha-other"
|
||||
? ({ ok: true, modelRef: "alpha/model", latencyMs: 1, binding: {} } as never)
|
||||
: ({ ok: false, status: "format", error: "bad response" } as const);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(attempts).toEqual(["requester", "alpha-other"]);
|
||||
});
|
||||
|
||||
it("retires the whole provider after a provider-wide failure", async () => {
|
||||
const attempts: string[] = [];
|
||||
const cfg: OpenClawConfig = {
|
||||
@@ -227,16 +260,15 @@ describe("system-agent inference fallback", () => {
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// alpha-other is skipped: the requester's alpha route failed provider-wide.
|
||||
expect(attempts).toEqual(["requester", "beta"]);
|
||||
});
|
||||
|
||||
it("does not fail over on bad answers", async () => {
|
||||
it("does not fail over on owner or identity uncertainty", async () => {
|
||||
const verify = vi.fn(
|
||||
async () => ({ ok: false, status: "format", error: "bad answer" }) as const,
|
||||
async () => ({ ok: false, status: "unknown", error: "winner identity uncertain" }) as const,
|
||||
);
|
||||
|
||||
await verifySystemAgentInferenceWithFallback({
|
||||
const result = await verifySystemAgentInferenceWithFallback({
|
||||
requestingAgentId: "requester",
|
||||
runtime,
|
||||
deps: {
|
||||
@@ -248,6 +280,11 @@ describe("system-agent inference fallback", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error: "winner identity uncertain",
|
||||
});
|
||||
expect(verify).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Provider-neutral live inference ladder for delegated OpenClaw sessions.
|
||||
// Provider-neutral live inference ladder for OpenClaw sessions.
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { listAgentIds, tryResolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { hasAvailableAuthForProvider } from "../agents/model-auth.js";
|
||||
@@ -17,14 +17,14 @@ const RETRYABLE_INFERENCE_STATUSES = new Set([
|
||||
"rate_limit",
|
||||
"billing",
|
||||
"timeout",
|
||||
"format",
|
||||
"unavailable",
|
||||
]);
|
||||
|
||||
// auth/billing/rate_limit failures commonly apply to one key/account/project,
|
||||
// so another credential owner of the same provider may still work. Everything
|
||||
// else retryable (timeout, unavailable) is provider-wide, so its whole provider
|
||||
// is skipped for the rest of the ladder.
|
||||
const CREDENTIAL_SCOPED_FAILURE_STATUSES = new Set(["auth", "billing", "rate_limit"]);
|
||||
// Only failures that establish provider-wide unavailability retire every route.
|
||||
// Credential failures may clear with another owner, while format failures can be
|
||||
// model-specific, so both stay scoped to the attempted route.
|
||||
const PROVIDER_WIDE_FAILURE_STATUSES = new Set(["timeout", "unavailable"]);
|
||||
|
||||
type InferenceFallbackDeps = {
|
||||
readConfig?: () => Promise<OpenClawConfig>;
|
||||
@@ -131,13 +131,11 @@ export async function verifySystemAgentInferenceWithFallback(params: {
|
||||
return result;
|
||||
}
|
||||
lastFailure = result;
|
||||
// Bad/empty answers and owner-integrity failures are not availability failover.
|
||||
// Identity or owner-integrity uncertainty stays fail-closed as unknown.
|
||||
if (!RETRYABLE_INFERENCE_STATUSES.has(result.status)) {
|
||||
return result;
|
||||
}
|
||||
// A provider-wide failure applies to all of its routes; a credential-scoped
|
||||
// one may not, so only the former retires the whole provider.
|
||||
if (!CREDENTIAL_SCOPED_FAILURE_STATUSES.has(result.status)) {
|
||||
if (PROVIDER_WIDE_FAILURE_STATUSES.has(result.status)) {
|
||||
failedProviders.add(candidate.provider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,9 +591,9 @@ export async function runSetupInferenceTest(params: {
|
||||
error: "The model started but did not send a reply. Try again or pick another option.",
|
||||
};
|
||||
}
|
||||
const winnerError = extractRunWinnerError(plan, result);
|
||||
const winnerError = await extractRunWinnerError(plan, result);
|
||||
if (winnerError) {
|
||||
return { ok: false, status: "format", error: winnerError };
|
||||
return { ok: false, status: "unknown", error: winnerError };
|
||||
}
|
||||
if (requireExecutionOwner && !successfulAuth) {
|
||||
return {
|
||||
|
||||
@@ -115,17 +115,30 @@ export function extractRunTerminalError(result: RunResult): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
export function extractRunWinnerError(
|
||||
export async function extractRunWinnerError(
|
||||
plan: SetupInferenceTestPlan,
|
||||
result: RunResult,
|
||||
): string | undefined {
|
||||
): Promise<string | undefined> {
|
||||
const winnerProvider = result.meta?.executionTrace?.winnerProvider?.trim();
|
||||
const winnerModel = result.meta?.executionTrace?.winnerModel?.trim();
|
||||
if (!winnerProvider || !winnerModel) {
|
||||
return "The inference run did not report which provider and model produced its reply.";
|
||||
}
|
||||
if (winnerProvider === plan.provider && winnerModel === plan.model) {
|
||||
return undefined;
|
||||
if (winnerProvider === plan.provider) {
|
||||
if (winnerModel === plan.model) {
|
||||
return undefined;
|
||||
}
|
||||
const { resolveDirectBundledProviderPolicySurface } =
|
||||
await import("../plugins/provider-policy-surface.js");
|
||||
if (
|
||||
resolveDirectBundledProviderPolicySurface(plan.provider)?.isResponseModelEquivalent?.({
|
||||
provider: plan.provider,
|
||||
requestedModelId: plan.model,
|
||||
responseModelId: winnerModel,
|
||||
}) === true
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return `The inference run answered through ${winnerProvider}/${winnerModel} instead of the requested ${plan.provider}/${plan.model}. Disable model-routing overrides or choose the working route directly, then retry.`;
|
||||
}
|
||||
|
||||
@@ -445,6 +445,7 @@ type SuccessfulRunParams = {
|
||||
authProfileId?: string;
|
||||
agentHarnessRuntimeOverride?: string;
|
||||
config?: OpenClawConfig;
|
||||
reportedModel?: string;
|
||||
};
|
||||
|
||||
function successfulAgentHarnessBinding(params?: SuccessfulRunParams): AgentExecutionAuthBinding {
|
||||
@@ -491,13 +492,14 @@ function successfulRun(provider: string, model: string, params?: SuccessfulRunPa
|
||||
return {
|
||||
meta: {
|
||||
finalAssistantVisibleText: "OK",
|
||||
executionTrace: { winnerProvider: provider, winnerModel: model },
|
||||
executionTrace: { winnerProvider: provider, winnerModel: params?.reportedModel ?? model },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successfulRunner(provider: string, model: string) {
|
||||
return async (params: SuccessfulRunParams) => successfulRun(provider, model, params);
|
||||
function successfulRunner(provider: string, model: string, reportedModel?: string) {
|
||||
return async (params: SuccessfulRunParams) =>
|
||||
successfulRun(provider, model, { ...params, reportedModel });
|
||||
}
|
||||
|
||||
function openAiOAuthCredential(token: string, lifetimeMs = 3_600_000) {
|
||||
@@ -2026,7 +2028,7 @@ describe("activateSetupInference", () => {
|
||||
expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(concurrentConfig));
|
||||
});
|
||||
|
||||
it("preserves authored provider rows and lifts an onboarding-owned lean setting", async () => {
|
||||
it("accepts OpenAI's gpt-5.6 alias reporting Sol while preserving authored rows", async () => {
|
||||
const sourceConfig = {
|
||||
wizard: { localModelLeanAutoModel: "lmstudio/qwen-local" },
|
||||
agents: {
|
||||
@@ -2067,17 +2069,21 @@ describe("activateSetupInference", () => {
|
||||
},
|
||||
];
|
||||
const configHarness = createConfigTransformHarness(sourceConfig, runtimeConfig);
|
||||
const runEmbeddedAgent = vi.fn(successfulRunner("openai", "gpt-5.6", "gpt-5.6-sol"));
|
||||
|
||||
const result = await activateSetupInference({
|
||||
kind: "openai-api-key",
|
||||
deps: {
|
||||
readConfigFileSnapshot: mockConfigSnapshot(sourceConfig, { runtimeConfig }),
|
||||
runEmbeddedAgent: vi.fn(successfulRunner("openai", "gpt-5.6")) as never,
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
transformConfigWithPendingPluginInstalls: configHarness.transform as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.6" });
|
||||
expect(runEmbeddedAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "openai", model: "gpt-5.6" }),
|
||||
);
|
||||
expect(configHarness.current().models?.providers?.openai?.models).toEqual(
|
||||
sourceConfig.models.providers.openai.models,
|
||||
);
|
||||
@@ -2426,14 +2432,20 @@ describe("activateSetupInference", () => {
|
||||
error: "did not report which provider and model",
|
||||
},
|
||||
{
|
||||
name: "model-routing override",
|
||||
name: "provider-routing override",
|
||||
runResult: successfulRun("openai", "gpt-5.5"),
|
||||
error: "instead of the requested anthropic/claude-opus-5",
|
||||
},
|
||||
])("does not persist inference after a $name", async ({ runResult, error }) => {
|
||||
{
|
||||
name: "same-provider model-routing override",
|
||||
kind: "openai-api-key" as const,
|
||||
runResult: successfulRun("openai", "gpt-5.6-terra"),
|
||||
error: "instead of the requested openai/gpt-5.6",
|
||||
},
|
||||
])("does not persist inference after a $name", async ({ runResult, error, kind }) => {
|
||||
const transformConfig = vi.fn();
|
||||
const result = await activateSetupInference({
|
||||
kind: "anthropic-api-key",
|
||||
kind: kind ?? "anthropic-api-key",
|
||||
deps: {
|
||||
runEmbeddedAgent: vi.fn(async () => runResult) as never,
|
||||
transformConfigWithPendingPluginInstalls: transformConfig as never,
|
||||
@@ -2442,7 +2454,7 @@ describe("activateSetupInference", () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: "format",
|
||||
status: "unknown",
|
||||
error: expect.stringContaining(error),
|
||||
});
|
||||
expect(transformConfig).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user