From 5697a7277a250b95475b040a34c996f7bd5d5474 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 13 Sep 2026 21:59:55 -0700 Subject: [PATCH] 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. --- .../sdk-agent-harness/selection-policy.md | 4 + ...st.ts => provider-route-selection.test.ts} | 86 ++++++++++++++++++- src/agents/harness/support.ts | 24 ++---- 3 files changed, 94 insertions(+), 20 deletions(-) rename src/agents/harness/{subscription-route-selection.test.ts => provider-route-selection.test.ts} (63%) diff --git a/docs/plugins/sdk-agent-harness/selection-policy.md b/docs/plugins/sdk-agent-harness/selection-policy.md index 28b2e55b66fa..45bce8275c7b 100644 --- a/docs/plugins/sdk-agent-harness/selection-policy.md +++ b/docs/plugins/sdk-agent-harness/selection-policy.md @@ -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 diff --git a/src/agents/harness/subscription-route-selection.test.ts b/src/agents/harness/provider-route-selection.test.ts similarity index 63% rename from src/agents/harness/subscription-route-selection.test.ts rename to src/agents/harness/provider-route-selection.test.ts index 1c39fd2f872a..a0daed11e21a 100644 --- a/src/agents/harness/subscription-route-selection.test.ts +++ b/src/agents/harness/provider-route-selection.test.ts @@ -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", diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts index bc7691cdd986..5bad07f97c18 100644 --- a/src/agents/harness/support.ts +++ b/src/agents/harness/support.ts @@ -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); -}