From 271bf52da1fa66741fe44de5fae296a854e8abda Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 12 Jul 2026 18:07:20 -0300 Subject: [PATCH 1/7] chore(base): fix 2 mechanical release-tip base-reds (relayProbeStats re-export + OMNI_MAX_CONCURRENT_CONNECTIONS docs) --- .env.example | 46 ++++++++++--------- .../basereds-mechanical-2026-07-12.md | 1 + docs/reference/ENVIRONMENT.md | 1 + src/lib/localDb.ts | 1 + 4 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 changelog.d/maintenance/basereds-mechanical-2026-07-12.md diff --git a/.env.example b/.env.example index d6eb181ffb..60b5074e6e 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,10 @@ PORT=20128 # API_HOST=0.0.0.0 # DASHBOARD_PORT=20128 +# Connection backpressure: cap concurrent in-flight chat connections (503 + Retry-After when full). +# Used by: src/sse/utils/backpressure.ts — disabled when unset/0. +# OMNI_MAX_CONCURRENT_CONNECTIONS=0 + # Port for the real-time WebSocket live monitoring server. # Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts # Default: 20132 @@ -2106,24 +2110,24 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # BIFROST_API_KEY= # BIFROST_STREAMING_ENABLED=true # BIFROST_TIMEOUT_MS=30000 - -# ───────────────────────────────────────────────────────────────────────────── -# Account rotation config (operator-managed; consumed by open-sse/services/rotationConfig.ts) -# Lets a supervising front-end mirror its rotation rules onto the backend's account-fallback -# engine. All optional; defaults preserve the historical behavior. -# ───────────────────────────────────────────────────────────────────────────── -# OMNIROUTE_ROTATION_ENABLED=true -# OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS=0 -# OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET=true -# OMNIROUTE_ROTATE_ON_429=true -# OMNIROUTE_ROTATE_429_THRESHOLD=1 -# OMNIROUTE_ROTATE_429_WINDOW_SECONDS=120 -# OMNIROUTE_ROTATE_ON_500=true -# OMNIROUTE_ROTATE_500_THRESHOLD=1 -# OMNIROUTE_ROTATE_500_WINDOW_SECONDS=120 -# OMNIROUTE_ROTATE_ON_502=true -# OMNIROUTE_ROTATE_502_THRESHOLD=1 -# OMNIROUTE_ROTATE_502_WINDOW_SECONDS=120 -# OMNIROUTE_ROTATE_ON_400=false -# OMNIROUTE_ROTATE_400_THRESHOLD=1 -# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120 + +# ───────────────────────────────────────────────────────────────────────────── +# Account rotation config (operator-managed; consumed by open-sse/services/rotationConfig.ts) +# Lets a supervising front-end mirror its rotation rules onto the backend's account-fallback +# engine. All optional; defaults preserve the historical behavior. +# ───────────────────────────────────────────────────────────────────────────── +# OMNIROUTE_ROTATION_ENABLED=true +# OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS=0 +# OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET=true +# OMNIROUTE_ROTATE_ON_429=true +# OMNIROUTE_ROTATE_429_THRESHOLD=1 +# OMNIROUTE_ROTATE_429_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_500=true +# OMNIROUTE_ROTATE_500_THRESHOLD=1 +# OMNIROUTE_ROTATE_500_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_502=true +# OMNIROUTE_ROTATE_502_THRESHOLD=1 +# OMNIROUTE_ROTATE_502_WINDOW_SECONDS=120 +# OMNIROUTE_ROTATE_ON_400=false +# OMNIROUTE_ROTATE_400_THRESHOLD=1 +# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120 diff --git a/changelog.d/maintenance/basereds-mechanical-2026-07-12.md b/changelog.d/maintenance/basereds-mechanical-2026-07-12.md new file mode 100644 index 0000000000..f1ee83c06f --- /dev/null +++ b/changelog.d/maintenance/basereds-mechanical-2026-07-12.md @@ -0,0 +1 @@ +- chore(base): re-export relayProbeStats from localDb (db-rules gate, #6909 follow-up) and document OMNI_MAX_CONCURRENT_CONNECTIONS in .env.example/ENVIRONMENT.md (env-doc gate, #6590 follow-up) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f6de10034b..bd8fe06946 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -122,6 +122,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | | `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | +| `OMNI_MAX_CONCURRENT_CONNECTIONS` | `0` _(disabled)_ | `src/sse/utils/backpressure.ts` | Caps concurrent in-flight chat connections; requests over the cap get `503` with `Retry-After`. Positive integer enables the guard; unset/`0` disables it. | | `PROD_DASHBOARD_PORT` | `20130` | `docker-compose.prod.yml` | Host-side published port for the Dashboard in Docker production mode. | | `PROD_API_PORT` | `20131` | `docker-compose.prod.yml` | Host-side published port for the API in Docker production mode. | | `OMNIROUTE_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | Takes precedence over `PORT` when running inside Electron or other wrappers. | diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 5cc8b6a76e..46f2eb67cf 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -801,3 +801,4 @@ export { markConnectionRateLimitedUntil, clearConnectionRateLimit } from "./db/p // Provider param filters — denylist/allowlist config per provider/model (#6625) export * from "./db/paramFilters"; export * from "./db/interceptionRules"; // Per-model web-search/web-fetch interception rules (#3384) +export * from "./db/relayProbeStats"; // Relay probe latency/health stats (#6909) From 94328d5cb252370270c931d16c45363e977f9bb8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:07:49 -0300 Subject: [PATCH 2/7] fix(sse): apply commentary-phase drop filter in TRANSLATE mode (#6952) (#6990) The #6199/#6561 commentary-phase filter (shouldDropResponsesCommentaryEvent) was wired only into createSSEStream's PASSTHROUGH branch. The TRANSLATE-mode loop (openai-responses upstream -> another client format, e.g. codex routes streaming into Claude Code) called translateResponse() on every raw chunk without checking phase, so internal commentary-phase scratchpad text leaked into the client-visible content channel as duplicate prose and narrated tool-call arguments. Extends the same stateful filter into TRANSLATE mode via a small factory (createTranslateCommentaryFilter) that owns its own item/index Sets, keeping the wiring in stream.ts (a frozen file) to a single guarded line. Fail->pass evidence: - tests/unit/repro-6952-commentary.test.ts against origin/HEAD (pre-fix): FAILED - "commentary-phase prose must not reach the translated client stream" - Same test against the fix: PASSED (2/2) --- .../fixes/6952-commentary-translate-mode.md | 1 + open-sse/utils/responsesCommentaryDrop.ts | 12 + open-sse/utils/stream.ts | 8 +- tests/unit/repro-6952-commentary.test.ts | 257 ++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6952-commentary-translate-mode.md create mode 100644 tests/unit/repro-6952-commentary.test.ts diff --git a/changelog.d/fixes/6952-commentary-translate-mode.md b/changelog.d/fixes/6952-commentary-translate-mode.md new file mode 100644 index 0000000000..07dd100b32 --- /dev/null +++ b/changelog.d/fixes/6952-commentary-translate-mode.md @@ -0,0 +1 @@ +- fix(sse): drop internal commentary-phase Responses output in TRANSLATE-mode streams, not just PASSTHROUGH — codex/Responses-upstream routes translated into another client format (e.g. Claude Code) no longer leak duplicate prose and narrated tool-call arguments into the client text channel (#6952) diff --git a/open-sse/utils/responsesCommentaryDrop.ts b/open-sse/utils/responsesCommentaryDrop.ts index 35ac68e8ec..4c0f300a15 100644 --- a/open-sse/utils/responsesCommentaryDrop.ts +++ b/open-sse/utils/responsesCommentaryDrop.ts @@ -10,6 +10,7 @@ // config/quality/file-size-baseline.json) so the #6561 fix (clearing the // buffered `event:` line alongside every drop) does not grow that file. import { isResponsesCommentaryMessageItem } from "../handlers/responseSanitizer.ts"; +import { FORMATS } from "../translator/formats.ts"; type JsonRecord = Record; @@ -96,3 +97,14 @@ export function shouldDropResponsesCommentaryEvent( ) ); } + +// #6952 — TRANSLATE-mode chunk loop was missing this filter (only PASSTHROUGH +// had it wired), so commentary-phase text leaked into the client. Own Sets + +// the `targetFormat` gate, closed over so stream.ts (frozen) stays a one-liner. +export function createTranslateCommentaryFilter(targetFormat: string | undefined) { + const commentaryItemIds = new Set(); + const commentaryIndexes = new Set(); + const applies = targetFormat === FORMATS.OPENAI_RESPONSES; + return (parsed: JsonRecord): boolean => + applies && shouldDropResponsesCommentaryEvent(parsed, commentaryItemIds, commentaryIndexes); +} diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index dbe4e027ff..ded2ed700d 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -31,7 +31,10 @@ import { sanitizeStreamingChunk, } from "../handlers/responseSanitizer.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; -import { shouldDropResponsesCommentaryEvent } from "./responsesCommentaryDrop.ts"; +import { + shouldDropResponsesCommentaryEvent, + createTranslateCommentaryFilter, +} from "./responsesCommentaryDrop.ts"; import { buildErrorBody } from "./error.ts"; import { parseTextualToolCallCandidate, isValidToolCallHeaderPrefix } from "./textualToolCall.ts"; import { recordToolLatency } from "../services/toolLatencyTracker.ts"; @@ -707,6 +710,7 @@ export function createSSEStream(options: StreamOptions = {}) { // item id + output_index here and drop every matching follow-up event. const passthroughResponsesCommentaryItemIds = new Set(); const passthroughResponsesCommentaryIndexes = new Set(); + const dropCommentary = createTranslateCommentaryFilter(targetFormat); // #5786 — highest Responses-API `sequence_number` already forwarded on this stream. // The Responses API guarantees a strictly increasing sequence_number, so any event at // or below this watermark is an upstream reconnect/retry replay and must be dropped — @@ -1932,6 +1936,8 @@ export function createSSEStream(options: StreamOptions = {}) { continue; } + if (shouldDropResponsesCommentary && dropCommentary(parsed as JsonRecord)) continue; + providerPayloadCollector.push(parsed); if (parsed && parsed.done) { diff --git a/tests/unit/repro-6952-commentary.test.ts b/tests/unit/repro-6952-commentary.test.ts new file mode 100644 index 0000000000..4e622a1065 --- /dev/null +++ b/tests/unit/repro-6952-commentary.test.ts @@ -0,0 +1,257 @@ +/** + * TDD repro for #6952: commentary-phase output leaks in TRANSLATE mode. + * + * Background: #6199/#6561 added a stateful commentary-phase filter + * (`shouldDropResponsesCommentaryEvent`) but wired it only into + * `createSSEStream`'s PASSTHROUGH branch. The TRANSLATE-mode branch (an + * openai-responses upstream translated into another client format, e.g. + * codex routes streaming into Claude Code) called `translateResponse()` on + * every raw chunk without checking `phase`, so `phase: "commentary"` + * scratchpad text — duplicate prose and narrated tool-call arguments — leaked + * into the client-visible text block, right alongside the real final answer + * and the real function_call. + * + * This test drives `createSSEStream({ mode: "translate", targetFormat: + * "openai-responses", sourceFormat: "claude" })` with a realistic upstream + * sequence: a commentary item (duplicate prose + narrated tool-call JSON), + * a real final-answer item, and a real function_call — then asserts the + * translated Claude-shaped SSE stream contains the final prose exactly once, + * never the commentary text, and still carries the real tool call. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-commentary-6952-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); + +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); + +const textEncoder = new TextEncoder(); + +async function readTransformed(chunks: string[], options: object): Promise { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(textEncoder.encode(chunk)); + } + controller.close(); + }, + }); + return new Response(source.pipeThrough(createSSEStream(options))).text(); +} + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); + +const COMMENTARY_TEXT = "narrating the tool call before actually making it"; +const COMMENTARY_ARGS_JSON = '{"path":"/etc/passwd","reason":"scratchpad narration"}'; +const FINAL_TEXT = "Here is the final answer for the user."; +const TOOL_NAME = "read_file"; +const TOOL_ARGS_JSON = '{"path":"/tmp/real.txt"}'; + +function sse(event: object): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +// Upstream openai-responses sequence: commentary item (prose + narrated tool +// args) -> real final-answer item -> real function_call. +function buildResponsesStream(): string[] { + return [ + sse({ type: "response.created", response: { id: "resp_6952", output: [] } }), + // --- commentary item (internal scratchpad, must never reach the client) --- + sse({ + type: "response.output_item.added", + output_index: 0, + item: { + id: "msg_commentary", + type: "message", + role: "assistant", + phase: "commentary", + content: [], + }, + }), + sse({ + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_commentary", + content_index: 0, + delta: COMMENTARY_TEXT, + }), + sse({ + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_commentary", + content_index: 0, + delta: COMMENTARY_ARGS_JSON, + }), + sse({ + type: "response.output_text.done", + output_index: 0, + item_id: "msg_commentary", + content_index: 0, + text: COMMENTARY_TEXT + COMMENTARY_ARGS_JSON, + }), + sse({ + type: "response.output_item.done", + output_index: 0, + item: { + id: "msg_commentary", + type: "message", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: COMMENTARY_TEXT + COMMENTARY_ARGS_JSON }], + }, + }), + // --- real final-answer item (must always be forwarded, exactly once) --- + sse({ + type: "response.output_item.added", + output_index: 1, + item: { + id: "msg_final", + type: "message", + role: "assistant", + phase: "final", + content: [], + }, + }), + sse({ + type: "response.output_text.delta", + output_index: 1, + item_id: "msg_final", + content_index: 0, + delta: FINAL_TEXT, + }), + sse({ + type: "response.output_text.done", + output_index: 1, + item_id: "msg_final", + content_index: 0, + text: FINAL_TEXT, + }), + sse({ + type: "response.output_item.done", + output_index: 1, + item: { + id: "msg_final", + type: "message", + role: "assistant", + phase: "final", + content: [{ type: "output_text", text: FINAL_TEXT }], + }, + }), + // --- real function_call (must always be forwarded) --- + sse({ + type: "response.output_item.added", + output_index: 2, + item: { + id: "fc_real", + type: "function_call", + call_id: "call_real_1", + name: TOOL_NAME, + arguments: "", + }, + }), + sse({ + type: "response.function_call_arguments.delta", + output_index: 2, + item_id: "fc_real", + delta: TOOL_ARGS_JSON, + }), + sse({ + type: "response.output_item.done", + output_index: 2, + item: { + id: "fc_real", + type: "function_call", + call_id: "call_real_1", + name: TOOL_NAME, + arguments: TOOL_ARGS_JSON, + }, + }), + sse({ + type: "response.completed", + response: { + id: "resp_6952", + output: [ + { + id: "msg_final", + type: "message", + role: "assistant", + phase: "final", + content: [{ type: "output_text", text: FINAL_TEXT }], + }, + { + id: "fc_real", + type: "function_call", + call_id: "call_real_1", + name: TOOL_NAME, + arguments: TOOL_ARGS_JSON, + }, + ], + usage: { input_tokens: 10, output_tokens: 20 }, + }, + }), + ]; +} + +const TRANSLATE_RESPONSES_TO_CLAUDE_OPTIONS = { + mode: "translate", + targetFormat: "openai-responses", + sourceFormat: "claude", + provider: "openai", +}; + +test("TRANSLATE mode drops commentary-phase text before translateResponse (#6952)", async () => { + const output = await readTransformed(buildResponsesStream(), { + ...TRANSLATE_RESPONSES_TO_CLAUDE_OPTIONS, + dropResponsesCommentary: true, + }); + + assert.ok( + !output.includes(COMMENTARY_TEXT), + "commentary-phase prose must not reach the translated client stream" + ); + assert.ok( + !output.includes(COMMENTARY_ARGS_JSON), + "commentary-phase narrated tool-call JSON must not reach the translated client stream" + ); + + // The final prose must appear exactly once — not once from commentary + // duplication and once from the real final item. + const finalTextOccurrences = output.split(FINAL_TEXT).length - 1; + assert.equal( + finalTextOccurrences, + 1, + `expected prose exactly once in the translated stream, got ${finalTextOccurrences}` + ); + + // The real tool call must still be forwarded (arguments are JSON-escaped inside + // an `input_json_delta` SSE frame, so match on the unescaped path fragment). + assert.ok( + output.includes("/tmp/real.txt"), + "the real function_call arguments must be forwarded" + ); + assert.ok(output.includes(TOOL_NAME), "the real function_call name must be forwarded"); +}); + +test("TRANSLATE mode passes commentary through when dropping is disabled (gate/regression) (#6952)", async () => { + const output = await readTransformed(buildResponsesStream(), { + ...TRANSLATE_RESPONSES_TO_CLAUDE_OPTIONS, + dropResponsesCommentary: false, + }); + + assert.ok( + output.includes(COMMENTARY_TEXT), + "with the flag disabled, commentary text must still pass through untouched" + ); + assert.ok(output.includes(FINAL_TEXT), "the final answer text must still be forwarded"); +}); From 9b43a00b60ce53bf705e78d5aaa838bb2f548d62 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:07:58 -0300 Subject: [PATCH 3/7] fix(combos): show embedding/rerank models and disambiguate duplicate names in builder options (#6975, #6957) (#6991) Removes the leftover chat-only isChatCapable gate from addModelOption() (#6975) and adds a name-disambiguation pass at the end of buildModelOptions() so distinct model ids sharing the same upstream display name fall back to their id (#6957). Both proven with TDD repro tests (RED->GREEN). --- .../fixes/6975-6957-combo-builder-models.md | 2 + src/lib/combos/builderOptions.ts | 33 ++++- .../unit/combo-builder-options-route.test.ts | 11 +- tests/unit/repro-6957.test.ts | 130 ++++++++++++++++++ tests/unit/repro-6975.test.ts | 35 +++++ 5 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/6975-6957-combo-builder-models.md create mode 100644 tests/unit/repro-6957.test.ts create mode 100644 tests/unit/repro-6975.test.ts diff --git a/changelog.d/fixes/6975-6957-combo-builder-models.md b/changelog.d/fixes/6975-6957-combo-builder-models.md new file mode 100644 index 0000000000..46f0154967 --- /dev/null +++ b/changelog.d/fixes/6975-6957-combo-builder-models.md @@ -0,0 +1,2 @@ +- **fix(combos):** embeddings-only and rerank-only models (e.g. JinaAI, Gemini auto-imported, OpenRouter custom, reranker models) no longer disappear from the combo builder's model picker — the leftover chat-only `isChatCapable` gate in `addModelOption()` has been removed (#6975). +- **fix(combos):** when 2+ distinct model ids from the same provider would render an identical display name in the combo builder picker (e.g. Mistral's `codestral-latest`/`codestral-2508` aliases sharing one upstream catalog name), each colliding entry now falls back to its own id as the display label so every row stays visually distinguishable and findable (#6957). diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 4ce26daa93..5b2ac572ac 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -155,11 +155,6 @@ function toStringArray(value: unknown): string[] | undefined { return normalized.length > 0 ? normalized : undefined; } -function isChatCapable(supportedEndpoints: string[] | undefined): boolean { - if (!supportedEndpoints || supportedEndpoints.length === 0) return true; - return supportedEndpoints.includes("chat"); -} - function getSourcePriority(source: BuilderModelSource): number { switch (source) { case "imported": @@ -305,7 +300,6 @@ function addModelOption( const modelId = toStringOrNull(input.id); if (!modelId) return; if (getModelIsHidden(providerId, modelId)) return; - if (!isChatCapable(input.supportedEndpoints)) return; const nextSourcePriority = getSourcePriority(input.source); const existing = modelMap.get(modelId); @@ -449,9 +443,36 @@ function buildModelOptions( } } + disambiguateCollidingModelNames(modelMap); return modelMap; } +/** + * #6957: some providers' own catalogs assign the identical display `name` to + * several distinct model ids (e.g. Mistral's "codestral-latest" alias renders + * under the same upstream name as its base "codestral-2508" model). Since the + * builder picker renders `model.name` as the visible option text, two colliding + * names make genuinely different models look like duplicates and hide aliases. + * Run this after all merge loops have populated `modelMap`: for any name shared + * by 2+ distinct ids, fall back every entry in that group to its own `id` as the + * display name (display-only — `id`/`qualifiedModel` used for routing untouched). + */ +function disambiguateCollidingModelNames(modelMap: Map): void { + const idsByName = new Map(); + for (const option of modelMap.values()) { + const bucket = idsByName.get(option.name) || []; + bucket.push(option.id); + idsByName.set(option.name, bucket); + } + for (const [name, ids] of idsByName) { + if (ids.length < 2) continue; + for (const id of ids) { + const option = modelMap.get(id); + if (option && option.name === name) option.name = option.id; + } + } +} + function compareConnections( left: ComboBuilderConnectionOption, right: ComboBuilderConnectionOption diff --git a/tests/unit/combo-builder-options-route.test.ts b/tests/unit/combo-builder-options-route.test.ts index 704e4009f4..9d05767e14 100644 --- a/tests/unit/combo-builder-options-route.test.ts +++ b/tests/unit/combo-builder-options-route.test.ts @@ -103,10 +103,12 @@ test("combo builder options route aggregates providers, connections, models and }); await modelsDb.addCustomModel("openai", "custom-ops", "Custom Ops"); + // #6975: embeddings-only models (supportedEndpoints without "chat") are no longer + // dropped from the combo builder — they must appear like any other model. await modelsDb.addCustomModel( "openai", - "text-embedding-hidden", - "Hidden Embedding", + "text-embedding-visible", + "Text Embedding", "manual", "chat-completions", ["embeddings"] @@ -148,9 +150,10 @@ test("combo builder options route aggregates providers, connections, models and false ); assert.ok(openai.models.some((model) => model.id === "custom-ops")); + // #6975: embeddings-only models must now appear in the combo builder output. assert.equal( - openai.models.some((model) => model.id === "text-embedding-hidden"), - false + openai.models.some((model) => model.id === "text-embedding-visible"), + true ); assert.deepEqual( openai.connections.map((connection) => ({ diff --git a/tests/unit/repro-6957.test.ts b/tests/unit/repro-6957.test.ts new file mode 100644 index 0000000000..696dfada3d --- /dev/null +++ b/tests/unit/repro-6957.test.ts @@ -0,0 +1,130 @@ +/** + * Repro for #6957 — combo builder "2. Model" dropdown shows visually duplicated + * "imported" rows and appears to be missing the "-latest" aliases for a native + * Mistral provider with 2 API-key connections. + * + * Root cause (confirmed against the reporter's actual `GET /api/combos/builder/options` + * payload, issue #6957): there is NO literal `model.id` collision — every synced + * model id is already unique after `buildModelOptions()`/`getAllSyncedAvailableModels()` + * dedup. The bug is that `ComboBuilderModelOption.name` (the text rendered in the + * `