Files
OmniRoute/tests/unit/chatcore-unsupported-params-strip.test.ts
Markus Hartung 6e1e5c9a45 fix(sse): tool-incapable provider handling (AI Horde + Responses content-collapse scoping) (#8212)
* fix(sse): collapse single-text-part Responses-API content to a plain string

Every /v1/responses request — even the simplest single-string input —
got 500'd by AI Horde's Aphrodite-backed facade. Root cause:
normalizeResponsesInputForChat() always wraps a plain string input as
`content: [{ type: "input_text", text: value }]` (a one-element array),
and openaiResponsesToOpenAIRequest() mapped that straight through to
`content: [{ type: "text", text: value }]` on the Chat Completions side
— an array. That's spec-valid (OpenAI's own API accepts both shapes),
but strict/naive OpenAI-compatible backends like AI Horde's only
implement the plain-string form and reject the array form outright.

A single-text-part array and a plain string are semantically
identical, so collapse is safe. Real multi-part messages (text+image,
text+file) are left untouched.

Regression test: tests/unit/openai-responses-single-text-content-string.test.ts
(RED before the fix — every collapsed-content assertion failed with an
object instead of a string; GREEN after).

Also adds a deeper AI Horde load-test suite (sequential/concurrent/
cross-model/sustained-throughput/new-capable-model-candidates) that
surfaced this bug via real live traffic after Behemoth-X-123B was
temporarily added to the "default" combo for evaluation.

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): unsupportedParams provider-level fallback for aihorde's live-discovered models

Real OpenClaw traffic against the newly-added Behemoth-X-123B combo
target kept 500ing on every attempt even after the Responses-API
content-array fix landed. The pipeline artifact showed why: `tools`
was still present, unstripped, in the request actually sent to AI
Horde's Aphrodite backend.

Root cause: `unsupportedParams: ["tools", "tool_choice",
"parallel_tool_calls"]` was only declared on the 3 models statically
listed in the aihorde registry entry (Cydonia-24B, Skyfall-31B,
google/gemma-4-31b). AI Horde uses `passthroughModels: true` — its
live worker roster changes constantly — so Behemoth-X-123B, like every
other dynamically-discovered aihorde model, had no model-specific
unsupportedParams entry, and getUnsupportedParams() returned [] for
it. But "the workers run raw text-completion backends" (no tool
calling) is true of every model AI Horde serves, not just the 3
catalogued ones.

Adds a provider-level `unsupportedParams` fallback on RegistryEntry,
checked by getUnsupportedParams() after the per-model lookup misses.
Set on the aihorde entry so it covers its entire live-discovered
roster, present and future, without needing a static per-model catalog
entry for each one.

Regression test: tests/unit/aihorde-tools-unsupported-provider-fallback.test.ts
(RED before the fix — Behemoth-X and deepseek-v4-flash both returned
[] instead of the stripped param list; GREEN after, with a control
case confirming the fallback doesn't leak to unrelated providers).

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): flatten leftover tool-call history when stripping unsupported tools

Third bug in the same AI Horde/Behemoth-X saga: even after tools/
tool_choice were correctly stripped from the live request (previous
fix), real combo traffic still 500'd. The conversation history itself
carried a prior turn's role:"assistant" tool_calls and role:"tool"
result messages, left over from before the combo failed over from a
tool-capable model (Gemini) to a non-tool-capable one (AI Horde). Its
raw completion backend doesn't understand those message shapes at all,
independent of whether live `tools` is present — confirmed by
reproducing with a role:"tool" message and NO tools param at all.

flattenToolHistory() (open-sse/utils/flattenToolHistory.ts) already
existed for exactly this, fully unit-tested — it just had zero call
sites anywhere in the request pipeline. Extracts the unsupported-params
strip into a small testable module
(open-sse/handlers/chatCore/unsupportedParamsStrip.ts, following the
existing chatCore god-file decomposition pattern e.g.
executorClientHeaders.ts) that now also flattens tool-call history
whenever "tools" was among the stripped params.

Regression test: tests/unit/chatcore-unsupported-params-strip.test.ts
(RED before the fix — the flattening test failed with the raw
tool_calls array still present; GREEN after). All 434 existing
chatcore-*.test.ts tests still pass.

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): gate tool-history flattening on unsupported, not on stripped-this-request

The previous commit's flattening only fired when "tools" was actually
present-and-stripped on THIS request. A second live reproduction
against AI Horde had no live `tools` param at all — only stale
tool_calls/tool-result messages inherited from before a combo
failover — and still 500'd, because that condition never triggered.

A model that can't do tool calling can't do it whether or not the
current request happens to carry a `tools` array. Gate on the
unsupported-params list itself (unsupported.includes("tools")) instead
of the subset that was actually present-and-deleted this time.

Regression test added to the same file (RED before — the no-live-tools
case left tool_calls/role:"tool" untouched; GREEN after). All 435
chatcore-*.test.ts still pass.

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): skip tool-incapable combo targets, error clearly on direct requests

Two complementary fixes for a model that structurally can't do tool
calling at all (e.g. AI Horde's raw completion backends) rather than
silently degrading — following up on the earlier strip/flatten fix,
which stopped the crashes but let a tool-incapable target still get
selected and return a 200 that narrates a fake tool call in prose
instead of erroring or being skipped.

1. Root cause, combo routing: getResolvedModelCapabilities()'s
   `supportsTools` resolution only checked per-model registry entries,
   synced capabilities, and static specs — none of which exist for a
   dynamically-discovered model (AI Horde's passthroughModels roster
   changes as workers come and go). It fell through to
   heuristicToolCalling(), which optimistically defaults to `true` for
   any unrecognized model (TOOL_CALLING_UNSUPPORTED_PATTERNS is empty).
   Added a provider-level fallback reusing the same unsupportedParams
   signal the request-time strip already relies on. This makes the
   EXISTING filterTargetsByRequestCompatibility (comboStructure.ts) —
   which already correctly excludes non-tool-capable targets when a
   request requires tools — actually work for these models; no combo.ts
   changes were needed, it was only ever fed bad capability data.

2. Direct/pinned requests: filterTargetsByRequestCompatibility only
   protects combo routing. A direct request naming an exact
   tool-incapable model has no other target to fail over to — added
   checkToolCallingRequiredButUnsupported (chatCore/toolCallingRequiredCheck.ts),
   gated on isCombo: false, returning a clear 400 instead of a 200 that
   silently can't do what was asked.

Regression tests (both RED before, GREEN after):
- tests/unit/model-capabilities-provider-unsupported-tools.test.ts
- tests/unit/chatcore-tool-calling-required-check.test.ts
All 463 chatcore-*/model-capabilities-*.test.ts and 31 combo
compatibility-filter tests still pass.

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): correct handleChatCore return shape for the tool-calling-blocked error

handleChatCore's documented contract is `{ success, response, status,
error }`, not a raw Response — returning `new Response(...)` directly
(copied from a different early-return whose surrounding context turned
out not to share this function's top-level contract) produced "No
response is returned from route handler ... Expected a Response object
but received 'undefined'" and a bare 500 with an empty body, caught
immediately when verifying the previous commit live.

Uses createErrorResult() (already used by the adjacent
translation-failure branch a few lines up) instead of hand-building the
Response, matching the same pattern already established in this
function for early error returns.

Co-Authored-By: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): scope Responses single-text-content collapse to providers that need it

The single-text-part content array -> plain string collapse (added for AI
Horde's Aphrodite facade, which 500s on the array form) was applied
unconditionally to every provider, silently breaking the standard OpenAI
array-shaped content contract that other providers and existing tests
depend on. Added RegistryEntry.requiresPlainStringContent, gated the
collapse on it (true only for aihorde), and threaded modelInfo.provider
through responsesHandler -> responsesApiHelper -> the translator so the
real /v1/responses call site can identify the provider.

Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>

---------

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
2026-07-23 05:03:53 -03:00

112 lines
4.9 KiB
TypeScript

// tests/unit/chatcore-unsupported-params-strip.test.ts
// Extracted from handleChatCore (chatCore god-file decomposition).
import { test } from "node:test";
import assert from "node:assert/strict";
import { stripUnsupportedParams } from "../../open-sse/handlers/chatCore/unsupportedParamsStrip.ts";
test("strips each unsupported param present on the body", () => {
const body: Record<string, unknown> = { model: "x", tools: [{ x: 1 }], tool_choice: "auto" };
const result = stripUnsupportedParams(body, ["tools", "tool_choice", "parallel_tool_calls"]);
assert.deepEqual(result.strippedParams, ["tools", "tool_choice"]);
assert.equal(Object.hasOwn(body, "tools"), false);
assert.equal(Object.hasOwn(body, "tool_choice"), false);
assert.equal(body.model, "x");
});
test("no-op when the body has none of the unsupported params", () => {
const body: Record<string, unknown> = { model: "x", messages: [] };
const result = stripUnsupportedParams(body, ["tools", "tool_choice"]);
assert.deepEqual(result.strippedParams, []);
assert.deepEqual(body, { model: "x", messages: [] });
});
// Live incident: AI Horde 500'd on real combo traffic even after tools/tool_choice
// were stripped from the live request, because prior-turn tool_calls/tool-result
// messages were still in the history — a raw completion backend chokes on those
// message shapes regardless of whether live `tools` is present.
test("flattens tool_calls/tool-result messages in history when tools was stripped", () => {
const body: Record<string, unknown> = {
model: "aphrodite/TheDrummer/Behemoth-X-123B-v2.1",
tools: [{ type: "function", function: { name: "exec" } }],
messages: [
{ role: "user", content: "run the script" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: "exec", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_1", content: "output here" },
{ role: "user", content: "what happened?" },
],
};
const result = stripUnsupportedParams(body, ["tools", "tool_choice", "parallel_tool_calls"]);
assert.deepEqual(result.strippedParams, ["tools"]);
const messages = body.messages as Array<Record<string, unknown>>;
assert.equal(messages.length, 4);
// No message may keep role:"tool" or an assistant tool_calls array — that's
// exactly the shape AI Horde's backend 500'd on.
for (const m of messages) {
assert.notEqual(m.role, "tool");
assert.equal(m.tool_calls, undefined);
}
assert.equal(messages[0].content, "run the script");
assert.ok(String(messages[1].content).includes("Called tools: exec"));
assert.ok(String(messages[2].content).includes("Tool result: output here"));
assert.equal(messages[3].content, "what happened?");
});
// Live incident, round 2: the FIRST live reproduction happened to include a
// live `tools` array alongside the stale history, which masked this gap. A
// second live request had NO `tools` param at all — just the leftover
// tool_calls/tool-result messages — and still 500'd, because the flattening
// was gated on "tools" having actually been present-and-stripped THIS
// request, not on whether the model supports tool calling at all. A model
// that can't do tool calling can't do it regardless of whether this
// particular request happens to carry a live `tools` array.
test("flattens tool history even when the CURRENT request has no live tools param at all", () => {
const body: Record<string, unknown> = {
model: "aphrodite/TheDrummer/Behemoth-X-123B-v2.1",
// no `tools` key on this request — only stale history from before failover
messages: [
{ role: "user", content: "run the script" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: "exec", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_1", content: "output here" },
{ role: "user", content: "what happened?" },
],
};
stripUnsupportedParams(body, ["tools", "tool_choice", "parallel_tool_calls"]);
const messages = body.messages as Array<Record<string, unknown>>;
for (const m of messages) {
assert.notEqual(m.role, "tool");
assert.equal(m.tool_calls, undefined);
}
});
test("does not touch messages when tools was NOT among the unsupported/stripped params", () => {
const originalMessages = [
{ role: "user", content: "hi" },
{ role: "assistant", content: null, tool_calls: [{ id: "c1", function: { name: "x" } }] },
];
const body: Record<string, unknown> = {
model: "x",
temperature: 2,
messages: originalMessages,
};
stripUnsupportedParams(body, ["temperature"]);
// messages array must be untouched (same reference, tool_calls survives) —
// this model DOES support tools, only temperature was unsupported.
assert.equal(body.messages, originalMessages);
});