Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4988e3a
fix(models): preserve chat-capable image model rows
xz-dev Jul 12, 2026
50df071
fix(quality): read cognitiveComplexity= machine line in validate-rele…
alltomatos Jul 13, 2026
5f7cdcc
fix(sse): recognize xiaomi-tokenplan mimo as a thinking-mode model (p…
diegosouzapw Jul 14, 2026
7717742
docs(changelog): add fragment for #7098 mimo thinking-model fix
diegosouzapw Jul 14, 2026
a0d874a
fix(codex): strip regex lookaround from tool schema patterns (port fr…
diegosouzapw Jul 14, 2026
85219c5
chore(changelog): move #1556 entry to changelog.d fragment
diegosouzapw Jul 14, 2026
49a0b51
fix(relay): bound Bifrost stream lifetime
KooshaPari Jul 14, 2026
66e38e4
refactor(codex): table-drive the regex-strip recursion to keep the co…
diegosouzapw Jul 15, 2026
1a3a82b
Merge remote-tracking branch 'origin/release/v3.8.49' into fix/catalo…
diegosouzapw Jul 15, 2026
2bc05a8
Merge remote-tracking branch 'origin/release/v3.8.49' into babysit-70…
diegosouzapw Jul 15, 2026
37f9d96
Merge branch 'release/v3.8.49' into fix/catalog-preserve-chat-image-m…
diegosouzapw Jul 16, 2026
1a616e9
Merge branch 'release/v3.8.49' into fix/7009-cognitive-count-parse
diegosouzapw Jul 16, 2026
a9b73a6
Merge branch 'release/v3.8.49' into koosha/fix-bifrost-stream-timeout
diegosouzapw Jul 16, 2026
c7cf699
Merge branch 'release/v3.8.49' into fix/port-issue-1321-xiaomi-mimo-r…
diegosouzapw Jul 16, 2026
8ee3452
Merge branch 'release/v3.8.49' into fix/port-issue-1556-openai-regex-…
diegosouzapw Jul 16, 2026
9b5d00a
Merge of #7004
mergify[bot] Jul 16, 2026
91e78f5
Merge of #7042
mergify[bot] Jul 16, 2026
988e0a1
Merge of #7093
mergify[bot] Jul 16, 2026
419ba87
Merge of #7098
mergify[bot] Jul 16, 2026
4694922
Merge of #7100
mergify[bot] Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/1556-openai-regex-lookaround.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(codex):** strip regex `pattern` lookaround (lookahead/lookbehind) from tool JSON Schemas on the Codex/OpenAI native passthrough path — previously only the translated-request path coerced tool schemas, so a `pattern` like `^(?=.*@).+$` reached OpenAI unmodified and was rejected with `regex lookaround is not supported`. (thanks @evinjohnn) (#7100)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** xiaomi-tokenplan `mimo` models (e.g. `mimo-v2.5-pro`) are now recognized as thinking-mode upstreams that require `reasoning_content` echoed back on every assistant turn, fixing a persistent `400 reasoning_content must be passed back` error on multi-turn conversations ([#7098](https://github.com/diegosouzapw/OmniRoute/pull/7098)) — thanks @xxue-z
9 changes: 8 additions & 1 deletion open-sse/executors/codex/tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating).
// Extracted verbatim from codex.ts. Self-contained (console.debug only).

import { stripUnsupportedRegexPatterns } from "../../translator/helpers/schemaCoercion.ts";

// Responses-API hosted tool types that OpenAI/Codex executes server-side.
// These arrive shaped as `{ type, ...params }` with no `function` object and no `name` —
// e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or
Expand Down Expand Up @@ -133,14 +135,19 @@ export function normalizeCodexTools(
? functionObject.strict
: undefined;

// Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround
// (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error.
// Strip those before the schema reaches upstream (9router#1556).
const sanitizedParameters = stripUnsupportedRegexPatterns(parameters);

// Rewrite in-place to Responses format
for (const key of Object.keys(tool)) {
delete tool[key];
}
tool.type = "function";
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
tool.parameters = sanitizedParameters;
if (strict !== undefined) tool.strict = strict;

validToolNames.add(name);
Expand Down
81 changes: 81 additions & 0 deletions open-sse/translator/helpers/schemaCoercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ const NUMERIC_SCHEMA_FIELDS = [
"multipleOf",
] as const;

// Fix (9router#1556): OpenAI/Codex's Responses API rejects JSON Schema `pattern`
// values that use regex lookaround (lookahead/lookbehind) with
// "Invalid JSON schema: regex lookaround is not supported.". IDE/SDK agent
// harnesses commonly emit lookahead patterns (e.g. `^(?=.*@).+$`), so any
// `pattern` field containing `(?=`, `(?!`, `(?<=`, or `(?<!` must be dropped
// before the schema reaches the Codex/OpenAI upstream.
const REGEX_LOOKAROUND_PATTERN = /\(\?<?[=!]/;

function hasUnsupportedRegexLookaround(pattern: unknown): boolean {
return typeof pattern === "string" && REGEX_LOOKAROUND_PATTERN.test(pattern);
}

function isPlainObject(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
Expand Down Expand Up @@ -81,6 +93,11 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
delete result.default;
}

// Fix (9router#1556): drop unsupported regex lookaround from `pattern`.
if (hasUnsupportedRegexLookaround(result.pattern)) {
delete result.pattern;
}

for (const field of NUMERIC_SCHEMA_FIELDS) {
if (field in result) {
result[field] = coerceNumericString(result[field]);
Expand Down Expand Up @@ -142,6 +159,70 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
return result;
}

// Sub-schema maps keyed by property name (each value is itself walked recursively).
const REGEX_STRIP_OBJECT_MAP_FIELDS = [
"properties",
"patternProperties",
"definitions",
"$defs",
] as const;

// Sub-schema lists (each entry is itself walked recursively).
const REGEX_STRIP_ARRAY_MAP_FIELDS = ["prefixItems", "anyOf", "oneOf", "allOf"] as const;

/** Recursively strips unsupported regex lookaround from every value of an object map field. */
function stripRegexFromObjectMap(record: JsonRecord): JsonRecord {
return Object.fromEntries(
Object.entries(record).map(([key, value]) => [key, stripUnsupportedRegexPatterns(value)])
);
}

/**
* Strip regex `pattern` constraints that use lookaround (lookahead/lookbehind),
* which OpenAI/Codex's Responses API rejects outright with a 400
* ("Invalid JSON schema: regex lookaround is not supported."). Walks the same
* JSON Schema shape as `coerceSchemaNumericFields` (properties, items,
* anyOf/oneOf/allOf, $defs/definitions, etc). See 9router#1556.
*/
export function stripUnsupportedRegexPatterns(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map((entry) => stripUnsupportedRegexPatterns(entry));
}
if (!isPlainObject(schema)) return schema;

const result: JsonRecord = { ...schema };

if (hasUnsupportedRegexLookaround(result.pattern)) {
delete result.pattern;
}

for (const field of REGEX_STRIP_OBJECT_MAP_FIELDS) {
if (isPlainObject(result[field])) {
result[field] = stripRegexFromObjectMap(result[field]);
}
}

for (const field of REGEX_STRIP_ARRAY_MAP_FIELDS) {
if (Array.isArray(result[field])) {
result[field] = (result[field] as unknown[]).map((entry) =>
stripUnsupportedRegexPatterns(entry)
);
}
}

if (result.items !== undefined) {
result.items = stripUnsupportedRegexPatterns(result.items);
}
if (result.additionalProperties && typeof result.additionalProperties === "object") {
result.additionalProperties = stripUnsupportedRegexPatterns(result.additionalProperties);
}
if (isPlainObject(result.not)) {
result.not = stripUnsupportedRegexPatterns(result.not);
}

return result;
}

export function sanitizeToolDescription(tool: unknown): unknown {
if (!isPlainObject(tool)) return tool;

Expand Down
4 changes: 3 additions & 1 deletion open-sse/utils/reasoningContentInjector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require
* Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, xiaomi-tokenplan
* mimo, ...) require
* `reasoning_content` to be echoed back on every assistant message in the
* conversation history. Standard OpenAI clients do not preserve that field
* across turns, so we inject a non-empty placeholder before forwarding.
Expand All @@ -26,6 +27,7 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [
/\bkimi\b/i,
/\bk2\b/i, // moonshot kimi k2 family alias
/\bminimax\b/i,
/\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro)
];

export function isThinkingMessageModel(model: string | undefined | null): boolean {
Expand Down
9 changes: 8 additions & 1 deletion scripts/quality/validate-release-green.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,14 @@ export function parseEslintJson(out) {

/** Pull the cognitive-complexity violation count from the gate's output. */
export function parseCognitiveCount(out) {
const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i);
const s = String(out || "");
// `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets, with the
// cyclomatic "N violações" summary emitted FIRST — so a bare `\d+ violações` regex would grab
// the cyclomatic count. Prefer the unambiguous machine-readable `cognitiveComplexity=N` line
// (mirrors the cyclomatic `complexity=N` parse used for cycCurrent below).
const machine = s.match(/(?:^|\n)cognitiveComplexity=(\d+)/);
if (machine) return Number(machine[1]);
const m = s.match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i);
return m ? Number(m[1]) : null;
}

Expand Down
15 changes: 10 additions & 5 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore(
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
// `/v1/models`) return image/diffusion models with no modality info,
// so `endpoints` below would default to ["chat"] and misrepresent
// them as chat-capable. Skip any synced model that is already a
// registered image model for this provider — getAllImageModels()
// below adds the correctly-typed `type: "image"` entry instead.
// them as chat-capable. Skip a registered image model only when its
// synced metadata does not explicitly advertise a chat endpoint.
// Multi-capability models may intentionally share an id between the
// chat and image catalogs; getAllImageModels() adds the image entry.
const explicitlySupportsChat = sm.supportedEndpoints?.some(
(endpoint) => endpoint === "chat" || endpoint === "responses"
);
if (
isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id)
!explicitlySupportsChat &&
(isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id))
) {
continue;
}
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/v1/relay/chat/completions/bifrost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ export async function POST(request: Request) {
"x-relay-client-ip": clientIp,
...getProviderPluginManifestHeader(new URL(request.url).origin),
};
const requestId = request.headers.get("x-request-id");
if (requestId) upstreamHeaders["x-request-id"] = requestId;
if (BIFROST_API_KEY) {
upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`;
}
Expand Down
20 changes: 17 additions & 3 deletions src/app/api/v1/relay/chat/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async function forwardToBifrost(
body: unknown,
token: RelayToken,
config: BifrostRoutingConfig,
backend: ReturnType<typeof resolveRelayRoutingBackend>,
startTime: number,
clientIp: string,
userAgent: string | null
Expand All @@ -77,6 +78,8 @@ async function forwardToBifrost(
"x-relay-client-ip": clientIp,
...getProviderPluginManifestHeader(new URL(request.url).origin),
};
const requestId = request.headers.get("x-request-id");
if (requestId) upstreamHeaders["x-request-id"] = requestId;
if (config.apiKey) {
upstreamHeaders.Authorization = `Bearer ${config.apiKey}`;
}
Expand All @@ -95,7 +98,6 @@ async function forwardToBifrost(
body: JSON.stringify(body),
signal: ac.signal,
});
clearTimeout(tid);

const headers = new Headers(upstream.headers);
headers.set("X-Routed-By", "bifrost");
Expand All @@ -107,14 +109,24 @@ async function forwardToBifrost(

if (wantsStream && upstream.body) {
const stream = finalizeReadableStream(upstream.body, (error) => {
clearTimeout(tid);
const statusCode = timedOut ? 504 : upstream.status;
if (error && backend === "auto") {
recordBifrostFailure(
config.baseUrl,
timedOut
? `Bifrost sidecar stream timed out after ${config.timeoutMs}ms`
: "bifrost-stream-error"
);
}
recordUsage(
token.id,
request,
startTime,
clientIp,
userAgent,
error || upstream.status >= 500 ? "error" : "success",
upstream.status
error || statusCode >= 500 ? "error" : "success",
statusCode
);
});

Expand All @@ -124,6 +136,7 @@ async function forwardToBifrost(
});
}

clearTimeout(tid);
recordUsage(
token.id,
request,
Expand Down Expand Up @@ -313,6 +326,7 @@ export async function POST(request: Request) {
parsedBody,
token,
bifrostConfig,
backend,
startTime,
clientIp,
userAgent
Expand Down
15 changes: 9 additions & 6 deletions tests/unit/api/v1/bifrost-sidecar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,8 @@ test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unse
delete process.env.BIFROST_STREAMING_ENABLED;

// Dynamic import after env is set so the module reads the empty value.
const { POST } = await import(
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
);
const { POST } =
await import("../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts");

const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
Expand Down Expand Up @@ -191,12 +190,14 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
delete process.env.BIFROST_STREAMING_ENABLED;

const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`);
let forwardedRequestId: string | null = null;

globalThis.fetch = async () =>
new Response(
globalThis.fetch = async (_input, init) => {
forwardedRequestId = new Headers(init?.headers).get("x-request-id");
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n"));
controller.enqueue(new TextEncoder().encode('data: {"delta":"hi"}\n\n'));
controller.close();
},
}),
Expand All @@ -205,6 +206,7 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
headers: { "content-type": "text/event-stream" },
}
);
};

const { POST } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
Expand All @@ -227,6 +229,7 @@ test("bifrost route: records relay usage after SSE stream completion", async ()
const res = await POST(req);
assert.equal(res.status, 200);
assert.equal(res.headers.get("X-Routed-By"), "bifrost");
assert.equal(forwardedRequestId, "bifrost-sse-lifecycle-test");
assert.equal(getRelayLogs(relayToken.id, 10).length, 0);

assert.match(await res.text(), /delta/);
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/api/v1/relay-routing-backend.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
getBifrostRoutingConfig,
getRoutingFallbackHeader,
Expand Down Expand Up @@ -152,3 +153,26 @@ test("relay routing backend strict bifrost bypasses manifest eligibility", () =>
{ tryBifrost: true }
);
});

test("automatic relay keeps the Bifrost timeout active until an SSE stream finalizes", () => {
const routeSource = readFileSync(
new URL("../../../../src/app/api/v1/relay/chat/completions/route.ts", import.meta.url),
"utf8"
);
const forwardToBifrost = routeSource.slice(
routeSource.indexOf("async function forwardToBifrost"),
routeSource.indexOf("export async function OPTIONS")
);
const streamBranch = forwardToBifrost.slice(
forwardToBifrost.indexOf("if (wantsStream && upstream.body)"),
forwardToBifrost.indexOf("clearTimeout(tid);\n recordUsage(")
);

assert.match(
streamBranch,
/finalizeReadableStream\(upstream\.body, \(error\) => \{\s*clearTimeout\(tid\)/
);
assert.match(streamBranch, /const statusCode = timedOut \? 504 : upstream\.status/);
assert.match(streamBranch, /error && backend === "auto"/);
assert.match(streamBranch, /recordBifrostFailure\(/);
});
Loading