From 7a7a72c6f41f0ce8c6d223240f4c5f763389457f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:20:53 -0300 Subject: [PATCH 1/3] fix(network): enable Happy Eyeballs on direct egress (port from 9router#1237) (#6423) Happy Eyeballs on direct egress (port #1237). Integrated into release/v3.8.46. --- CHANGELOG.md | 1 + open-sse/utils/proxyDispatcher.ts | 15 +++++++++++++++ .../direct-dispatcher-pipelining-4580.test.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6639088c..7848f169f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(network):** enable **RFC 8305 Happy Eyeballs** (`autoSelectFamily`) on the direct-egress undici dispatcher. When DNS returns both IPv6 (AAAA) and IPv4 (A) and the IPv6 route is broken (e.g. a NAT64 `64:ff9b::` prefix without routing), undici tried IPv6 first and hung until `ETIMEDOUT` (then a 502 + account lockout), even though `curl` reached the same host. The direct dispatcher now races both families and uses whichever connects first. Proxy paths pin family via `proxyTls` and are unaffected. Regression guard: `tests/unit/direct-dispatcher-pipelining-4580.test.ts`. (thanks @adentdk) - **fix(combo):** round-robin now advances the rotation pointer past the model that **actually served**, not the eagerly-scheduled one. With `stickyLimit: 1` (true round-robin), when the scheduled model failed and a *different* model served via fallback, the counter had already advanced +1 from the scheduled index — so the next request reused the fallback-served model, degrading round-robin into hot-spotting on whichever model was healthy. The pointer now advances to the served index + 1 (mirroring the sticky-limit>1 path). Session-stickiness (#3825) and distribution are preserved. Regression guard: `tests/unit/combo-rr-fallback-advance-948.test.ts`. (thanks @binsarjr) - **fix(sse):** a non-string `model` field is now rejected with a `400` before the resolver, instead of crashing downstream `.toLowerCase()`/`.split()` calls into an empty-body `500` that escapes the error sanitizer ([#6407](https://github.com/diegosouzapw/OmniRoute/issues/6407)). Regression guard: `tests/unit/chat-non-string-model-6407.test.ts`. (thanks @chirag127) - **fix(api):** unknown `/api/*` routes now return a JSON `404` (instead of the dashboard HTML shell) and scalar chat params (`model`/`temperature`/etc.) are validated **before** the provider lookup so malformed requests fail fast with a clear `400` ([#6424](https://github.com/diegosouzapw/OmniRoute/issues/6424), [#6412](https://github.com/diegosouzapw/OmniRoute/issues/6412)). Regression guards: `tests/unit/api/api-catchall-json-404.test.ts`, `tests/unit/chat-early-schema-validation-6412.test.ts`. (thanks @chirag127) diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index 718319b8e1..2d2e84bf02 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -59,6 +59,21 @@ function getDispatcherOptions() { // keepAliveTimeout UP to undici's default keepAliveMaxTimeout (600 s), // completely overriding the configured 1 s and restoring zombie-socket risk. keepAliveMaxTimeout: timeouts.fetchKeepAliveTimeoutMs, + // 9router#1237: RFC 8305 Happy Eyeballs. undici does not + // enable it by default, so when DNS returns both AAAA (IPv6) and A (IPv4) + // and the IPv6 route is broken (e.g. NAT64 `64:ff9b::` without routing), + // the direct egress connect hangs until ETIMEDOUT — even though `curl` + // (which has Happy Eyeballs) reaches the same host. Race both families and + // use whichever connects first. The proxy path pins family via `proxyTls` + // and ProxyAgent ignores `connect`, so this only affects direct egress. + // undici types `connect` as a union whose TcpNetConnectOpts member nominally + // requires `port`; at runtime undici merges these into net.connect (the origin + // already carries host:port), so the partial pin is valid — cast to suppress + // the spurious missing-`port` error, mirroring the `proxyTls` cast below. + connect: { + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 1000, + } as ProxyAgent.Options["proxyTls"], }; } diff --git a/tests/unit/direct-dispatcher-pipelining-4580.test.ts b/tests/unit/direct-dispatcher-pipelining-4580.test.ts index 3f6ae944df..03bdc6e0b1 100644 --- a/tests/unit/direct-dispatcher-pipelining-4580.test.ts +++ b/tests/unit/direct-dispatcher-pipelining-4580.test.ts @@ -40,6 +40,18 @@ describe("#4580 direct dispatcher options", () => { ); }); + it("enables Happy Eyeballs (autoSelectFamily) on both direct and proxy options (#1237)", () => { + const direct = __getDefaultDispatcherOptionsForTest({}) as { + connect?: { autoSelectFamily?: boolean; autoSelectFamilyAttemptTimeout?: number }; + }; + assert.equal( + direct.connect?.autoSelectFamily, + true, + "direct egress must race IPv4/IPv6 so a broken IPv6 route does not ETIMEDOUT" + ); + assert.equal(typeof direct.connect?.autoSelectFamilyAttemptTimeout, "number"); + }); + it("connection limit honors OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS", () => { assert.equal( getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), From 1636ace600a16166f5bdaf48b3eb0f0b05e6254b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:21:38 -0300 Subject: [PATCH 2/3] fix(executors): strip client context_management on 400 (port from 9router#1468) (#6420) recover from client context_management 400 (port #1468) (net +1/-0, test OK). Integrated into release/v3.8.46. --- CHANGELOG.md | 1 + open-sse/config/providerFieldStrips.ts | 6 ++++++ tests/unit/provider-field-strips.test.ts | 6 ++++++ 3 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7848f169f4..a08b4493dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(executors):** recover from a strict gateway's `context_management: Extra inputs are not permitted` 400. **Claude Code** always sends a top-level `context_management` field; strict anthropic-compatible gateways reject it. The dedicated context-editing 400-fallback only fired when OmniRoute's own `contextEditing` feature was enabled (default off), so a client-sent field passed through untouched and 400'd. `context_management` is now in the generic reactive field-strip list, so it's stripped-and-retried once regardless of the feature flag (with correct request re-signing for claude-compatible relays). Regression guard: `tests/unit/provider-field-strips.test.ts`. (thanks @ohahe52-dot) - **fix(network):** enable **RFC 8305 Happy Eyeballs** (`autoSelectFamily`) on the direct-egress undici dispatcher. When DNS returns both IPv6 (AAAA) and IPv4 (A) and the IPv6 route is broken (e.g. a NAT64 `64:ff9b::` prefix without routing), undici tried IPv6 first and hung until `ETIMEDOUT` (then a 502 + account lockout), even though `curl` reached the same host. The direct dispatcher now races both families and uses whichever connects first. Proxy paths pin family via `proxyTls` and are unaffected. Regression guard: `tests/unit/direct-dispatcher-pipelining-4580.test.ts`. (thanks @adentdk) - **fix(combo):** round-robin now advances the rotation pointer past the model that **actually served**, not the eagerly-scheduled one. With `stickyLimit: 1` (true round-robin), when the scheduled model failed and a *different* model served via fallback, the counter had already advanced +1 from the scheduled index — so the next request reused the fallback-served model, degrading round-robin into hot-spotting on whichever model was healthy. The pointer now advances to the served index + 1 (mirroring the sticky-limit>1 path). Session-stickiness (#3825) and distribution are preserved. Regression guard: `tests/unit/combo-rr-fallback-advance-948.test.ts`. (thanks @binsarjr) - **fix(sse):** a non-string `model` field is now rejected with a `400` before the resolver, instead of crashing downstream `.toLowerCase()`/`.split()` calls into an empty-body `500` that escapes the error sanitizer ([#6407](https://github.com/diegosouzapw/OmniRoute/issues/6407)). Regression guard: `tests/unit/chat-non-string-model-6407.test.ts`. (thanks @chirag127) diff --git a/open-sse/config/providerFieldStrips.ts b/open-sse/config/providerFieldStrips.ts index 0cb5f3f8f5..5a342cc9c1 100644 --- a/open-sse/config/providerFieldStrips.ts +++ b/open-sse/config/providerFieldStrips.ts @@ -1,10 +1,16 @@ // Fields that, when literally named in an upstream 400 body, are safe to strip and // retry once (FCC NIM-style recovery). Mirrors the existing context_management 400 // fallback in base.ts, generalized to these OpenAI-compat / NIM reasoning fields. +// `context_management` (9router#1468): Claude Code sends it top-level; strict +// anthropic-compatible gateways 400 with "context_management: Extra inputs are not +// permitted". The dedicated base.ts fallback only fires when OmniRoute's own +// contextEditing feature is enabled, so a client-sent field passed through +// untouched when the feature is off — this generic strip covers that case. export const KNOWN_OFFENDING_FIELDS: readonly string[] = [ "reasoning_budget", "chat_template", "reasoning_content", + "context_management", ]; /** Return the first known-offending field literally named in a 400 body, or null. */ diff --git a/tests/unit/provider-field-strips.test.ts b/tests/unit/provider-field-strips.test.ts index 7d4b6aa3d5..9e57ca60af 100644 --- a/tests/unit/provider-field-strips.test.ts +++ b/tests/unit/provider-field-strips.test.ts @@ -9,6 +9,12 @@ test("findOffendingField matches known field names in a 400 body", () => { assert.equal(findOffendingField("Invalid argument: reasoning_budget not supported"), "reasoning_budget"); assert.equal(findOffendingField("unexpected field chat_template"), "chat_template"); assert.equal(findOffendingField("reasoning_content is not allowed"), "reasoning_content"); + // #1468: Claude Code's top-level context_management field rejected by strict + // anthropic-compatible gateways → strip + retry regardless of the contextEditing flag. + assert.equal( + findOffendingField("context_management: Extra inputs are not permitted"), + "context_management" + ); assert.equal(findOffendingField("all good"), null); assert.equal(findOffendingField(""), null); }); From f0b085ebcaa37396e4164e049b254cabce846fef Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:22:02 -0300 Subject: [PATCH 3/3] fix(executors): inject reasoning_content for native Kimi provider (port from 9router#1480) (#6419) inject reasoning_content for native Kimi (port #1480) (net +1/-0, test OK). Integrated into release/v3.8.46. --- CHANGELOG.md | 1 + open-sse/executors/default.ts | 21 ++++++ ...imi-native-reasoning-injected-1480.test.ts | 65 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 tests/unit/kimi-native-reasoning-injected-1480.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a08b4493dd..1faa1baa46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(executors):** inject the `reasoning_content` echo for the native **Moonshot Kimi** provider. Kimi (executor `default`) is a thinking-mode upstream that 400s with "reasoning_content must be passed back" when a prior assistant turn lacks it; the placeholder injection was only wired into the OpenCode meta-provider, so direct multi-turn Kimi conversations failed. Scoped to `kimi` (gateway-served models matching the thinking-model name pattern are unaffected). Regression guard: `tests/unit/kimi-native-reasoning-injected-1480.test.ts`. (thanks @2220258345) - **fix(executors):** recover from a strict gateway's `context_management: Extra inputs are not permitted` 400. **Claude Code** always sends a top-level `context_management` field; strict anthropic-compatible gateways reject it. The dedicated context-editing 400-fallback only fired when OmniRoute's own `contextEditing` feature was enabled (default off), so a client-sent field passed through untouched and 400'd. `context_management` is now in the generic reactive field-strip list, so it's stripped-and-retried once regardless of the feature flag (with correct request re-signing for claude-compatible relays). Regression guard: `tests/unit/provider-field-strips.test.ts`. (thanks @ohahe52-dot) - **fix(network):** enable **RFC 8305 Happy Eyeballs** (`autoSelectFamily`) on the direct-egress undici dispatcher. When DNS returns both IPv6 (AAAA) and IPv4 (A) and the IPv6 route is broken (e.g. a NAT64 `64:ff9b::` prefix without routing), undici tried IPv6 first and hung until `ETIMEDOUT` (then a 502 + account lockout), even though `curl` reached the same host. The direct dispatcher now races both families and uses whichever connects first. Proxy paths pin family via `proxyTls` and are unaffected. Regression guard: `tests/unit/direct-dispatcher-pipelining-4580.test.ts`. (thanks @adentdk) - **fix(combo):** round-robin now advances the rotation pointer past the model that **actually served**, not the eagerly-scheduled one. With `stickyLimit: 1` (true round-robin), when the scheduled model failed and a *different* model served via fallback, the counter had already advanced +1 from the scheduled index — so the next request reused the fallback-served model, degrading round-robin into hot-spotting on whichever model was healthy. The pointer now advances to the served index + 1 (mirroring the sticky-limit>1 path). Session-stickiness (#3825) and distribution are preserved. Regression guard: `tests/unit/combo-rr-fallback-advance-948.test.ts`. (thanks @binsarjr) diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index afabd75645..4b844f4c76 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -17,6 +17,10 @@ import { import { isOfficialAnthropicBaseUrl } from "../utils/anthropicHost.ts"; import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { stripUnsupportedParams } from "../translator/paramSupport.ts"; +import { + injectReasoningContentForThinkingModel, + isThinkingMessageModel, +} from "../utils/reasoningContentInjector.ts"; import { detectFormat, getOpenAICompatibleType, @@ -695,6 +699,23 @@ export class DefaultExecutor extends BaseExecutor { this.ensureThinkingBudget(withDefaults as Record, model); } + // 9router#1480: the native Moonshot `kimi` provider (executor "default") + // is a thinking-mode upstream that 400s with "reasoning_content must be + // passed back" when a prior assistant turn lacks it. OpencodeExecutor + // already injects a placeholder for OpenCode-routed thinking models; the + // direct kimi connection hit neither injection path. Scope to `kimi` so + // gateway-served models that merely match the thinking-model name pattern + // (and may reject an extra field) are unaffected. + if (this.provider === "kimi") { + const outboundModel = + typeof (withDefaults as Record)?.model === "string" + ? ((withDefaults as Record).model as string) + : model; + if (isThinkingMessageModel(outboundModel)) { + withDefaults = injectReasoningContentForThinkingModel(withDefaults); + } + } + return withDefaults; } diff --git a/tests/unit/kimi-native-reasoning-injected-1480.test.ts b/tests/unit/kimi-native-reasoning-injected-1480.test.ts new file mode 100644 index 0000000000..1654a8185f --- /dev/null +++ b/tests/unit/kimi-native-reasoning-injected-1480.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +/** + * Regression guard for upstream 9router#1480. + * + * The native Moonshot `kimi` provider (executor "default") is a thinking-mode + * upstream that returns 400 "reasoning_content must be passed back" when a prior + * assistant turn in the history lacks `reasoning_content`. OpencodeExecutor + * already injects a placeholder for OpenCode-routed thinking models, but the + * direct kimi connection went through DefaultExecutor, which did not — so + * multi-turn kimi conversations 400'd. The injection must fire for `kimi`, and + * must NOT fire for unrelated providers that merely serve a matching model name. + */ + +const STREAM = true; +const CREDENTIALS = { apiKey: "k" } as Record; + +function multiTurnBody(model: string) { + return { + model, + stream: STREAM, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "previous answer" }, // no reasoning_content + { role: "user", content: "follow up" }, + ], + }; +} + +test("DefaultExecutor(kimi) injects reasoning_content on assistant turns that lack it", () => { + const out = new DefaultExecutor("kimi").transformRequest( + "kimi-k2.6", + multiTurnBody("kimi-k2.6"), + STREAM, + CREDENTIALS + ) as Record; + const messages = out.messages as Array>; + const assistant = messages.find((m) => m.role === "assistant") as Record; + assert.equal( + typeof assistant.reasoning_content === "string" && + (assistant.reasoning_content as string).length > 0, + true, + "kimi assistant message must carry a non-empty reasoning_content placeholder" + ); +}); + +test("DefaultExecutor(openai) does NOT inject reasoning_content (scoped to kimi)", () => { + // A non-kimi provider must not gain the injection even for a thinking-ish name. + const out = new DefaultExecutor("openai").transformRequest( + "kimi-k2.6", + multiTurnBody("kimi-k2.6"), + STREAM, + CREDENTIALS + ) as Record; + const messages = out.messages as Array>; + const assistant = messages.find((m) => m.role === "assistant") as Record; + assert.equal( + Object.prototype.hasOwnProperty.call(assistant, "reasoning_content"), + false, + "non-kimi providers must not be given a reasoning_content placeholder" + ); +});