mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
merge release + guard changelog (#6418)
This commit is contained in:
@@ -22,6 +22,9 @@
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(executors):** strip the `client_metadata` passthrough field on the **OpenCode** path. OpenCode upstreams (e.g. `kimi-k2.6` via opencode-go) reject it with `400 "Extra inputs are not permitted, field: 'client_metadata'"`; the DefaultExecutor strip only covered cerebras/mistral and `OpencodeExecutor` extends `BaseExecutor` directly, so nothing removed it there. Regression guard: `tests/unit/opencode-strip-client-metadata-1442.test.ts`. (thanks @yanpaing007)
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>)?.model === "string"
|
||||
? ((withDefaults as Record<string, unknown>).model as string)
|
||||
: model;
|
||||
if (isThinkingMessageModel(outboundModel)) {
|
||||
withDefaults = injectReasoningContentForThinkingModel(withDefaults);
|
||||
}
|
||||
}
|
||||
|
||||
return withDefaults;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
65
tests/unit/kimi-native-reasoning-injected-1480.test.ts
Normal file
65
tests/unit/kimi-native-reasoning-injected-1480.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
const messages = out.messages as Array<Record<string, unknown>>;
|
||||
const assistant = messages.find((m) => m.role === "assistant") as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const messages = out.messages as Array<Record<string, unknown>>;
|
||||
const assistant = messages.find((m) => m.role === "assistant") as Record<string, unknown>;
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(assistant, "reasoning_content"),
|
||||
false,
|
||||
"non-kimi providers must not be given a reasoning_content placeholder"
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user