fix(agents): use exact model rows for harness support (#147849)

Keep API, endpoint, and request override facts tied to the selected configured model. Reuse the existing configured-row resolver and remove duplicate prefix stripping and normalized-row merging from harness support.

Preserve literal legacy selection, legacy-only fallback, and same-spelling duplicate semantics. Registered-harness regressions fail in both conflicting row orders before the fix; 203 focused tests, the changed-file gate, and independent review pass.

Related: #130706, #143822.
This commit is contained in:
Peter Steinberger 2026-09-13 21:59:55 -07:00 • committed by GitHub
parent d791bdb81f
commit 5697a7277a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 20 deletions

View file

@ -20,6 +20,10 @@ OpenClaw chooses a harness after provider/model resolution:
route. Provider/model prefixes alone never select a harness.
4. If no registered harness matches, OpenClaw uses its embedded runtime.
For configured models, transport details and request overrides come from the
same selected model row. An exact model ID takes precedence over a legacy
provider-prefixed row, including when the exact row inherits provider defaults.
Plugin harness failures surface as run failures. In `auto` mode, embedded
fallback only applies when no registered plugin harness supports the resolved
provider/model. Once a plugin harness has claimed a run, OpenClaw does not

View file

@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import {
@ -77,7 +78,90 @@ function authStore(subscription: boolean): AuthProfileStore {
};
}
describe("registered OpenAI subscription route selection", () => {
describe("registered provider route selection", () => {
it.each([
{
label: "exact row after legacy",
rows: ["legacy", "exact"],
modelId: "Model",
harness: "custom-responses",
},
{
label: "exact row before legacy",
rows: ["exact", "legacy"],
modelId: "Model",
harness: "custom-responses",
},
{ label: "legacy-only fallback", rows: ["legacy"], modelId: "Model", harness: "openclaw" },
{
label: "literal legacy selection",
rows: ["exact", "legacy"],
modelId: "custom/Model",
harness: "openclaw",
},
{
label: "same-spelling duplicates",
rows: ["exact", "duplicate"],
modelId: "Model",
harness: "custom-responses",
},
] as const)("uses the selected model transport for $label", (fixture) => {
registerAgentHarness({
id: "custom-responses",
label: "Custom Responses harness",
supports: ({ modelProvider }) =>
modelProvider?.api === "openai-responses" &&
modelProvider.baseUrl === "https://responses.example/v1" &&
modelProvider.requestTransportOverrides === "none"
? { supported: true, priority: 100 }
: { supported: false, reason: "Selected transport is not supported" },
async runAttempt() {
throw new Error("Selection proof does not execute inference");
},
});
const exact: ModelDefinitionConfig = {
id: "Model",
name: "Configured model",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 64_000,
maxTokens: 1024,
};
const rows: Record<"exact" | "legacy" | "duplicate", ModelDefinitionConfig> = {
exact,
legacy: {
...exact,
id: "custom/Model",
api: "openai-completions",
baseUrl: "https://legacy.example/v1",
headers: { "x-model-route": "legacy" },
},
duplicate: { ...exact, api: "openai-completions" },
};
const cfg: OpenClawConfig = {
agents: { entries: { assistant: {} } },
models: {
providers: {
custom: {
api: "openai-responses",
baseUrl: "https://responses.example/v1",
models: fixture.rows.map((row) => rows[row]),
},
},
},
};
expect(
selectAgentHarness({
provider: "custom",
modelId: fixture.modelId,
agentId: "assistant",
config: cfg,
}).id,
).toBe(fixture.harness);
});
it.each([
{
label: "explicit Codex primary",

View file

@ -1,8 +1,7 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalString as readStringParam } from "@openclaw/normalization-core/string-coerce";
import {
resolveMergedModelProviderConfig,
resolveMergedModelProviderModels,
findConfiguredProviderModel,
createModelProviderRouteOverrideResolver,
} from "../../config/model-provider-config.js";
import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/runtime-source-projection.js";
@ -119,13 +118,11 @@ export function buildAgentHarnessSupportContext(
const authoredConfig = params.config
? projectConfigOntoRuntimeSourceSnapshot(params.config)
: undefined;
const modelId = params.modelId ? normalizeModelId(params.provider, params.modelId) : undefined;
const modelId = params.modelId?.trim();
const modelConfig = modelId
? resolveMergedModelProviderModels({
models: providerConfig?.models,
normalizeModelId: (configuredModelId) =>
normalizeModelId(params.provider, configuredModelId),
}).get(modelId)
? findConfiguredProviderModel(providerConfig, params.provider, modelId, (configuredModelId) =>
canonicalizeProviderModelId(params.provider, configuredModelId),
)
: undefined;
const agentId = resolveAgentRuntimePolicyAgentId(params);
const hasConfiguredProviderRequestParams = hasAuthoredProviderRequestParams({
@ -306,14 +303,3 @@ function isSupportedHarness(entry: {
} {
return entry.support.supported;
}
function normalizeModelId(provider: string, modelId: string): string {
const trimmed = modelId.trim();
const slashIndex = trimmed.indexOf("/");
const unqualified =
slashIndex > 0 &&
normalizeProviderId(trimmed.slice(0, slashIndex)) === normalizeProviderId(provider)
? trimmed.slice(slashIndex + 1).trim()
: trimmed;
return canonicalizeProviderModelId(provider, unqualified);
}