mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 15:12:23 +03:00
Compare commits
1 Commits
fix/9123-s
...
fix/10986-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9493a53edd |
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).
|
||||
@@ -455,9 +455,10 @@
|
||||
"src/sse/handlers/chatHelpers.ts": 1019,
|
||||
"src/shared/middleware/chatBodyAdmission.ts": 1005,
|
||||
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
|
||||
"open-sse/executors/commandCode.ts": 1038,
|
||||
"open-sse/executors/commandCode.ts": 1059,
|
||||
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
|
||||
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts."
|
||||
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
|
||||
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)."
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
|
||||
@@ -738,6 +738,7 @@ function createStreamResponse(
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let sentRole = false;
|
||||
let sentContent = false;
|
||||
let closed = false;
|
||||
const state: AggregateState = {
|
||||
content: "",
|
||||
@@ -772,7 +773,10 @@ function createStreamResponse(
|
||||
switch (event.type) {
|
||||
case "text-delta": {
|
||||
const text = stringValue(event.text) || "";
|
||||
if (text) controller.enqueue(sse(chatCompletionChunk(id, model, { content: text })));
|
||||
if (text) {
|
||||
sentContent = true;
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { content: text })));
|
||||
}
|
||||
state.content += text;
|
||||
break;
|
||||
}
|
||||
@@ -810,6 +814,12 @@ function createStreamResponse(
|
||||
break;
|
||||
case "finish": {
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
// If the model only produced reasoning-delta events (no text-delta), the
|
||||
// client-visible stream would otherwise end with no content. Emit one
|
||||
// content delta carrying the accumulated reasoning text (#10986).
|
||||
if (!sentContent && state.reasoning && state.toolCalls.length === 0) {
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning })));
|
||||
}
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||
// Emit a standards-compliant usage-only chunk (choices: []) before
|
||||
// [DONE] when upstream reported usage. stream.ts's extractUsage
|
||||
@@ -857,6 +867,9 @@ function createStreamResponse(
|
||||
if (!closed) {
|
||||
if (!sentRole)
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
|
||||
if (!sentContent && state.reasoning && state.toolCalls.length === 0) {
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { content: state.reasoning })));
|
||||
}
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
@@ -947,6 +960,14 @@ async function createJsonResponse(
|
||||
}
|
||||
|
||||
const message: JsonRecord = { role: "assistant", content: state.content };
|
||||
// Some Command Code models emit the whole answer as reasoning-delta events and
|
||||
// never a text-delta. When that leaves content empty, surface the reasoning text
|
||||
// as content too (#10986) so OpenAI-compatible clients get a usable answer. Keep
|
||||
// reasoning_content populated as well for reasoning-aware clients, and do not
|
||||
// override content when real text OR tool calls are present.
|
||||
if (!state.content && state.reasoning && state.toolCalls.length === 0) {
|
||||
message.content = state.reasoning;
|
||||
}
|
||||
if (state.reasoning) message.reasoning_content = state.reasoning;
|
||||
if (state.toolCalls.length > 0) message.tool_calls = state.toolCalls;
|
||||
|
||||
|
||||
@@ -56,18 +56,8 @@ export function arePrivateProviderUrlsAllowed() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard mode for the provider OUTBOUND path (search-provider connection validation, image
|
||||
* generation, remote image fetch, model discovery — anything that does not go through the
|
||||
* chat validation path). Precedence — mirrors `getProviderValidationGuard()` (#9123):
|
||||
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
|
||||
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
|
||||
* 3. otherwise → "public-only" (strict).
|
||||
*/
|
||||
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
|
||||
if (arePrivateProviderUrlsAllowed()) return "none";
|
||||
if (areLocalProviderUrlsAllowed()) return "block-metadata";
|
||||
return "public-only";
|
||||
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -333,6 +333,68 @@ test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON
|
||||
});
|
||||
});
|
||||
|
||||
test("Command Code reasoning-only output falls back to reasoning as content (non-stream)", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
commandCodeStream(
|
||||
[
|
||||
{ type: "reasoning-delta", text: "The user wants 79874+93658. " },
|
||||
{ type: "reasoning-delta", text: "That equals 173532." },
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
totalUsage: { inputTokens: 20, outputTokens: 64, outputTokenDetails: { reasoningTokens: 61 } },
|
||||
},
|
||||
],
|
||||
{ sse: true }
|
||||
);
|
||||
|
||||
const { response } = await getExecutor("command-code").execute({
|
||||
model: "meta/muse-spark-1.2-contributor",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: { messages: [{ role: "user", content: "Calculate 79874+93658, and reply with the result only." }] },
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
const message = json.choices[0].message;
|
||||
// Regression #10986: when the model emits only reasoning-delta events (never a
|
||||
// text-delta), content must fall back to the reasoning text instead of "" (which
|
||||
// OpenAI-compatible clients treat as null/no answer).
|
||||
assert.equal(message.content, "The user wants 79874+93658. That equals 173532.");
|
||||
// reasoning_content must STAY populated for reasoning-aware clients.
|
||||
assert.equal(message.reasoning_content, "The user wants 79874+93658. That equals 173532.");
|
||||
});
|
||||
|
||||
test("Command Code reasoning-only output emits a content delta chunk when streaming", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
commandCodeStream(
|
||||
[
|
||||
{ type: "reasoning-delta", text: "The result is 173532." },
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
],
|
||||
{ sse: true }
|
||||
);
|
||||
|
||||
const { response } = await getExecutor("command-code").execute({
|
||||
model: "meta/muse-spark-1.2-contributor",
|
||||
stream: true,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: { messages: [{ role: "user", content: "Calcular 79874+93658" }] },
|
||||
});
|
||||
|
||||
const sse = await response.text();
|
||||
assert.match(sse, /data: \[DONE\]/);
|
||||
const chunks = parseSsePayloads(sse);
|
||||
assert.equal(chunks[0].choices[0].delta.role, "assistant");
|
||||
// Regression #10986: the reasoning-only stream must emit a content delta when it
|
||||
// otherwise ends with no content. reasoning_content stays present too.
|
||||
const contentDelta = chunks.find((c) => c.choices[0].delta.content !== undefined);
|
||||
assert.equal(contentDelta.choices[0].delta.content, "The result is 173532.");
|
||||
const reasoningDelta = chunks.find((c) => c.choices[0].delta.reasoning_content !== undefined);
|
||||
assert.equal(reasoningDelta.choices[0].delta.reasoning_content, "The result is 173532.");
|
||||
assert.equal(chunks.at(-1).choices[0].finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("Command Code executor surfaces upstream and streamed errors", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response("bad key", { status: 401, statusText: "Unauthorized" });
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS",
|
||||
"OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS",
|
||||
"OUTBOUND_SSRF_GUARD_ENABLED",
|
||||
];
|
||||
|
||||
function withEnv(overrides: Record<string, string | undefined>, fn: () => void) {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
Object.assign(process.env, overrides);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("#9123: setting ONLY OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=true should relax the guard used for search-provider outbound calls (currently does not)", async () => {
|
||||
const { areLocalProviderUrlsAllowed, getProviderOutboundGuard } = await import(
|
||||
"../../src/shared/network/outboundUrlGuardPolicy.ts"
|
||||
);
|
||||
|
||||
withEnv({ OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS: "true" }, () => {
|
||||
assert.equal(areLocalProviderUrlsAllowed(), true);
|
||||
assert.notEqual(
|
||||
getProviderOutboundGuard(),
|
||||
"public-only",
|
||||
"BUG #9123: OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS should relax getProviderOutboundGuard() " +
|
||||
"(used for search-provider outbound calls) the same way it already relaxes " +
|
||||
"getProviderValidationGuard() for regular chat providers — it currently has no effect."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("#9123 control: OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true DOES relax the same guard", async () => {
|
||||
const { getProviderOutboundGuard } = await import(
|
||||
"../../src/shared/network/outboundUrlGuardPolicy.ts"
|
||||
);
|
||||
withEnv({ OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS: "true" }, () => {
|
||||
assert.equal(getProviderOutboundGuard(), "none");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user