From 9adeb3b673a2adadf8c924b543f87ce42ebb980f Mon Sep 17 00:00:00 2001 From: Harvey Doan Date: Mon, 24 Aug 2026 04:10:21 +0700 Subject: [PATCH 1/9] fix(providers): clamp reasoning_effort to the declared vocabulary of models that expose one (#11274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: the declared-vocabulary clamp integrates with #11232's learned-set clamp (both fire in sequence; REASONING_EFFORT_ORDER import added), reasoning quartet 57/57 including the new opencode-go suite 7/7, 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, all static gates within baseline. Retargeted main→release/v3.8.50 and rebased onto the release tip (authorship preserved; the branch carried main history, so only the real commit was carried). The generic explicit-capability clamp closes the Console Go 400 [1210] loop for models with a declared effort vocabulary. Thank you @linhdmn — the live-probe table in the PR body is exactly the evidence standard we want. --- .../providers/registry/opencode/go/index.ts | 13 ++ open-sse/executors/base/reasoningEffort.ts | 38 ++++++ ...pencode-go-console-go-effort-clamp.test.ts | 120 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 tests/unit/opencode-go-console-go-effort-clamp.test.ts diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index abebd92c0f..21170b5b16 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -219,5 +219,18 @@ export const opencode_goProvider: RegistryEntry = { supportedThinkingEfforts: ["none", "low", "high", "max"], targetFormat: "openai-responses", }, + // Console Go free GLM-tier model (live-verified 2026-08-23): the upstream + // rejects every reasoning_effort outside {low, high, max} whenever tools + // are present — "[1210] This model always engages in thinking and cannot + // be disabled; please use low, high, or max" — which broke clients that + // default to reasoning_effort:"medium" (Hermes). Declaring the exact + // vocabulary lets sanitizeReasoningEffortForProvider clamp off-vocabulary + // requests to the nearest accepted tier instead of burning a 400. + { + id: "ox-alpha-free", + name: "ox-alpha (free)", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + }, ], }; diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index d6ca00ba55..ee520bbc2c 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -11,6 +11,7 @@ import { import { getLearnedReasoningEffort, clampToLearned, + REASONING_EFFORT_ORDER, } from "../../services/learnedReasoningEffortCaps.ts"; /** @@ -357,6 +358,43 @@ export function sanitizeReasoningEffortForProvider( } } + // ── explicit per-model capability clamp ────────────────────────────────── + // When the registry declares supportedThinkingEfforts for this exact model + // and the requested effort falls outside that vocabulary, remap to the + // nearest declared tier: the smallest ranked value ≥ the request, else the + // highest declared (a request above the ceiling lands on the ceiling). + // Live case: opencode-go/ox-alpha-free (Console Go) only accepts + // {low, high, max} — a client's reasoning_effort:"medium" reached the + // upstream verbatim and 400'd every turn ("[1210] This model always engages + // in thinking and cannot be disabled; please use low, high, or max"). The + // learned-caps path can't help here (it only clamps down from xhigh/max, + // and this error text isn't a parseable enum), so the declaration is the + // only source of truth. Models without an explicit declaration keep + // #8057's trust-the-upstream pass-through. + const providerModelIdForClamp = modelStr.startsWith(`${provider}/`) + ? modelStr.slice(provider.length + 1) + : modelStr; + const declaredEfforts = getProviderModels(provider).find( + (entry) => entry.id === providerModelIdForClamp || entry.aliases?.includes(providerModelIdForClamp) + )?.supportedThinkingEfforts; + const declaredRanked = ( + Array.isArray(declaredEfforts) ? declaredEfforts : [] + ) + .map((tier) => ({ tier, rank: REASONING_EFFORT_ORDER.indexOf(tier) })) + .filter((x) => x.rank >= 0) + .sort((a, b) => a.rank - b.rank); + if (declaredRanked.length > 0 && !declaredEfforts!.includes(effortStr)) { + const requestedRank = REASONING_EFFORT_ORDER.indexOf(effortStr); + const nearest = + declaredRanked.find((x) => x.rank >= requestedRank) ?? + declaredRanked[declaredRanked.length - 1]; + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${nearest.tier} (model accepts ${declaredEfforts!.join("/")})` + ); + return writeEffortValue(b, nearest.tier, c); + } + const supportsXHigh = supportsXHighEffort(provider, modelStr); const supportsMax = supportsMaxEffortForProvider(provider, modelStr); diff --git a/tests/unit/opencode-go-console-go-effort-clamp.test.ts b/tests/unit/opencode-go-console-go-effort-clamp.test.ts new file mode 100644 index 0000000000..2009d11a39 --- /dev/null +++ b/tests/unit/opencode-go-console-go-effort-clamp.test.ts @@ -0,0 +1,120 @@ +/** + * Console Go (opencode.ai/zen/go/v1) reasoning-effort vocabulary clamp. + * + * Live-reproduced 2026-08-23 via the Hermes Telegram bot → /v1/chat/completions: + * `opencode-go/ox-alpha-free` rejects every reasoning_effort except + * {low, high, max} whenever the request carries tools — + * + * [400] Error from provider (Console Go): Upstream request failed: [1210] + * This model always engages in thinking and cannot be disabled; please use + * low, high, or max + * + * Hermes sends reasoning_effort:"medium" with 24 tools and died on every turn. + * Two gaps let the bad value reach the upstream verbatim: + * 1. `ox-alpha-free` is a discovery-synced model with no static registry + * entry declaring its effort vocabulary. + * 2. sanitizeReasoningEffortForProvider only consults declared + * supportedThinkingEfforts in the `max` branch (max fallback); other + * out-of-vocabulary values pass through untouched. + * + * Fix under test: declare ["low","high","max"] on the registry entry and add a + * generic explicit-capability clamp that remaps any out-of-vocabulary effort to + * the nearest declared tier (smallest ranked ≥ requested, else the highest). + * Models without a declaration keep today's pass-through behavior (#8057). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); + +function makeLog() { + const messages: Array<[string, string]> = []; + return { + info: (tag: string, msg: string) => messages.push([tag, msg]), + messages, + }; +} + +const HERMES_BODY = { + model: "ox-alpha-free", + max_tokens: 65536, + stream_options: { include_usage: true }, + messages: [{ role: "user", content: "Start telegram bot" }], + tools: [ + { + type: "function", + function: { name: "clarify", description: "ask", parameters: { type: "object" } }, + }, + ], +}; + +test("registry: opencode-go declares ox-alpha-free with the live-verified Console Go effort set", () => { + const entry = REGISTRY["opencode-go"]; + assert.ok(entry, "opencode-go registry entry must exist"); + const model = entry.models.find((m) => m.id === "ox-alpha-free"); + assert.ok(model, "ox-alpha-free must be registered on opencode-go"); + assert.deepEqual(model.supportedThinkingEfforts, ["low", "high", "max"]); +}); + +test("clamp: medium → high for ox-alpha-free (the exact Hermes failure)", () => { + const log = makeLog(); + const body = { ...HERMES_BODY, reasoning_effort: "medium" }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "ox-alpha-free", log); + assert.notEqual(result, body, "must return a new object when mutating"); + assert.equal((result as Record).reasoning_effort, "high"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /medium → high/.test(m)), + "logs the mapping" + ); +}); + +test("clamp: disable-shaped efforts map to low (upstream refuses to stop thinking)", () => { + for (const effort of ["none", "minimal"]) { + const body = { ...HERMES_BODY, reasoning_effort: effort }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "ox-alpha-free", null); + assert.equal( + (result as Record).reasoning_effort, + "low", + `${effort} → low` + ); + } +}); + +test("clamp: xhigh → max for ox-alpha-free", () => { + const body = { ...HERMES_BODY, reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "ox-alpha-free", null); + assert.equal((result as Record).reasoning_effort, "max"); +}); + +test("clamp: in-vocabulary efforts pass through untouched", () => { + for (const effort of ["low", "high", "max"]) { + const body = { ...HERMES_BODY, reasoning_effort: effort }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "ox-alpha-free", null); + assert.equal(result, body, `${effort} must not be rewritten`); + assert.equal((result as Record).reasoning_effort, effort); + } +}); + +test("clamp writes back to every carrier present (top-level + reasoning.effort + output_config.effort)", () => { + const body = { + ...HERMES_BODY, + reasoning_effort: "medium", + reasoning: { effort: "medium" }, + output_config: { effort: "medium" }, + }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "ox-alpha-free", null) as Record< + string, + unknown + >; + assert.equal(result.reasoning_effort, "high"); + assert.deepEqual(result.reasoning, { effort: "high" }); + assert.deepEqual(result.output_config, { effort: "high" }); +}); + +test("no declaration → pass-through unchanged (#8057 policy for unlisted models)", () => { + const body = { ...HERMES_BODY, model: "some-unregistered-model", reasoning_effort: "medium" }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "some-unregistered-model", null); + assert.equal(result, body, "undeclared models keep today's trust-the-upstream behavior"); + assert.equal((result as Record).reasoning_effort, "medium"); +}); From ab150e1f2bb7b0fa87a6926f0c6cb4ff77e4427c Mon Sep 17 00:00:00 2001 From: Prajeeth H Date: Mon, 24 Aug 2026 02:40:54 +0530 Subject: [PATCH 2/9] test(startup): assert disabled boot path log output at runtime (#11016) (#11265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, static gates within baseline. The subprocess spawn this test does timed out locally on the shared devbox under load 35 (ETIMEDOUT on tsx bootstrap — environment, not the test); CI runs it unloaded. Runtime log-output assertion for the disabled credential-health boot path, closing the #11016 follow-up suggested on #11029. Thank you @Prajeeth-12! --- ...redential-health-disabled-boot-log.test.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/unit/credential-health-disabled-boot-log.test.ts diff --git a/tests/unit/credential-health-disabled-boot-log.test.ts b/tests/unit/credential-health-disabled-boot-log.test.ts new file mode 100644 index 0000000000..e6eb92b9cc --- /dev/null +++ b/tests/unit/credential-health-disabled-boot-log.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// #11016 follow-up (suggested by maintainer on PR #11029): assert that the +// disabled boot path produces the correct "[STARTUP] Credential health scheduler +// disabled" log at runtime. +// +// Two complementary assertions: +// 1. Runtime: spawn a subprocess that imports the real scheduler with the disable +// env set, calls initCredentialHealthCheck(), and logs the result using the +// same conditional from instrumentation-node.ts — verifying the actual output. +// 2. Static: read src/instrumentation-node.ts and assert the boot wiring still +// uses initCredentialHealthCheck()'s return value to select the log message. +// This breaks if the production conditional is removed or refactored away. + +const thisDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(thisDir, "../.."); + +// In CI: projectRoot has a real node_modules. +// In a worktree: the junction may not work with tsx; fall back to the main checkout. +function resolveMainCheckout(): string { + const hasRealNodeModules = existsSync(resolve(projectRoot, "node_modules", ".package-lock.json")); + if (hasRealNodeModules) return projectRoot; + const candidate = resolve(projectRoot, "../../.."); + if (existsSync(resolve(candidate, "node_modules", ".package-lock.json"))) return candidate; + return projectRoot; +} + +const mainCwd = resolveMainCheckout(); + +const BOOT_DISABLED_SCRIPT = ` + process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = "true"; + const { initCredentialHealthCheck } = await import( + "./src/lib/credentialHealth/scheduler.ts" + ); + const started = initCredentialHealthCheck(); + console.log( + started + ? "[STARTUP] Credential health scheduler started" + : "[STARTUP] Credential health scheduler disabled" + ); + process.exit(0); +`; + +test("disabled scheduler emits [STARTUP] Credential health scheduler disabled via the real initCredentialHealthCheck", () => { + const result = execFileSync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "--eval", BOOT_DISABLED_SCRIPT], + { + cwd: mainCwd, + env: { + ...process.env, + OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true", + NODE_NO_WARNINGS: "1", + }, + encoding: "utf8", + timeout: 30_000, + } + ); + + assert.match( + result, + /\[STARTUP\] Credential health scheduler disabled/, + "must log the disabled message when OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK is set" + ); + assert.doesNotMatch( + result, + /\[STARTUP\] Credential health scheduler started/, + "must NOT log the started message when disabled" + ); +}); + +test("instrumentation-node.ts wires initCredentialHealthCheck return to the log conditional", () => { + const src = readFileSync( + resolve(projectRoot, "src/instrumentation-node.ts"), + "utf8" + ).replace(/\r\n/g, "\n"); + + assert.match( + src, + /const started = initCredentialHealthCheck\(\)/, + "boot wiring must capture the return value of initCredentialHealthCheck()" + ); + assert.match( + src, + /started[\s\S]{0,50}\?[\s\S]{0,80}scheduler started[\s\S]{0,50}:[\s\S]{0,80}scheduler disabled/, + "boot wiring must use the return value to select started vs disabled log" + ); +}); From 12986c44c97979f0060c5d654e1ea28c51d0e89b Mon Sep 17 00:00:00 2001 From: Mina_ <147425861+Minamaged18@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:10:57 +0300 Subject: [PATCH 3/9] fix(live-ws, dashboard): allow 0.0.0.0 dashboard origin and stop non-square SVG image warnings (#11269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: live-server-allowlist suite green within the board's 88/88, typecheck:core + dashboard-typecheck clean. 0.0.0.0 as a browser Origin is loopback-equivalent (it can never name a LAN host — same treatment as headroom/detect.ts and outboundUrlGuard.ts), so the local-only posture is unchanged and the FORBIDDEN_ORIGIN reconnect flood on the dev server's printed URL stops. Thank you @Minamaged18! --- .../cli-code/components/DefaultToolCard.tsx | 47 ++++++++----------- src/server/ws/liveServerAllowList.ts | 5 ++ src/shared/components/ProviderIcon.tsx | 36 +++++++++++--- src/shared/components/cli/CliToolCard.tsx | 9 +++- .../security/live-server-allowlist.test.ts | 3 ++ 5 files changed, 64 insertions(+), 36 deletions(-) diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx index b7877dc05e..09446dfb16 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { Card, Button, ModelSelectModal } from "@/shared/components"; -import Image from "next/image"; import { useTranslations } from "next-intl"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig"; @@ -643,38 +642,32 @@ export default function DefaultToolCard({ }; const renderIcon = () => { + // Tool SVGs are non-square (e.g. opencode is 234×42, cursor is 467×532). + // next/image's dev check warns whenever the rendered aspect-ratio size + // differs from the square width/height attributes, so these render as a + // plain capped at 32px on both axes — true ratio, no dev noise. + const renderImg = (src: string) => ( + // eslint-disable-next-line @next/next/no-img-element -- local static SVG asset + {tool.name} { + (e.currentTarget as HTMLElement).style.display = "none"; + }} + /> + ); if (tool.image) { - return ( - {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> - ); + return renderImg(tool.image); } if (tool.imageLight || tool.imageDark) { const themedSrc = isDark ? tool.imageDark || tool.imageLight : tool.imageLight || tool.imageDark; - return ( - {tool.name} { - (e.currentTarget as HTMLElement).style.display = "none"; - }} - /> - ); + return renderImg(themedSrc); } if (tool.icon) { return ( diff --git a/src/server/ws/liveServerAllowList.ts b/src/server/ws/liveServerAllowList.ts index 9e3a91d598..1f3101f054 100644 --- a/src/server/ws/liveServerAllowList.ts +++ b/src/server/ws/liveServerAllowList.ts @@ -20,6 +20,11 @@ export const DEFAULT_ALLOWED_ORIGINS: readonly string[] = Object.freeze([ "http://127.0.0.1:20128", "http://localhost:20128", "http://[::1]:20128", + // 0.0.0.0 is the "unspecified" address but browsers treat it as loopback + // when the user pastes it into the address bar; the dashboard is reachable + // at http://0.0.0.0:20128 and its WS Origin is exactly that string. Same + // local-only posture as the entries above — it never refers to a LAN host. + "http://0.0.0.0:20128", ]); /** diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 97c4e135aa..8cb101ad4e 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -401,34 +401,56 @@ const ProviderIcon = memo(function ProviderIcon({ className={className} style={{ display: "inline-flex", alignItems: "center", ...style }} > - */} + {providerId} setFailedAssets((current) => ({ ...current, [themedKey]: true }))} - unoptimized /> ); } - // Tier 2: Local SVG — fastest, cached separately from the JS bundle + // Tier 2: Local SVG — fastest, cached separately from the JS bundle. + // Rendered as a plain (not next/image): provider SVGs carry their own + // intrinsic aspect ratio (e.g. opencode.svg is 234×42), and next/image's + // dev-mode check warns whenever the layout size differs from the square + // width/height attributes — a false positive for non-square logos rendered + // at fixed icon sizes. We keep `width/height` attributes for layout reserve + // but let the intrinsic ratio win on both axes (`width/height: "auto"`) so + // wide logos like opencode render at their true aspect ratio instead of + // being letterboxed into a 1:1 box. if (hasSvg && !svgFailed) { return ( - {providerId} setFailedAssets((current) => ({ ...current, [svgKey]: true }))} - unoptimized /> ); diff --git a/src/shared/components/cli/CliToolCard.tsx b/src/shared/components/cli/CliToolCard.tsx index 4b027558c1..d741bc5f13 100644 --- a/src/shared/components/cli/CliToolCard.tsx +++ b/src/shared/components/cli/CliToolCard.tsx @@ -1,7 +1,6 @@ "use client"; import Link from "next/link"; -import Image from "next/image"; import { useTranslations } from "next-intl"; import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog"; import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus"; @@ -38,12 +37,18 @@ export default function CliToolCard({
{/* Icon / image */} {imageSrc ? ( - (not next/image): tool SVGs are non-square (opencode + // 234×42, cursor 467×532) and next/image's dev check warns whenever the + // rendered aspect-ratio size differs from the square width/height + // attributes. object-contain + max caps keep the logo at its true ratio. + // eslint-disable-next-line @next/next/no-img-element -- local static SVG asset + {tool.name} ) : ( { assert.equal(isOriginAllowed("http://127.0.0.1:20128", EMPTY_ENV), true); assert.equal(isOriginAllowed("http://localhost:20128", EMPTY_ENV), true); assert.equal(isOriginAllowed("http://[::1]:20128", EMPTY_ENV), true); + // 0.0.0.0 is loopback-equivalent in the browser; the dashboard is often + // opened at http://0.0.0.0:20128, which sends exactly that Origin on WS. + assert.equal(isOriginAllowed("http://0.0.0.0:20128", EMPTY_ENV), true); }); it("accepts an Origin matching LIVE_WS_ALLOWED_ORIGINS", () => { From 6d4c4843e9c917590d2c76ef7f2a241cf8448372 Mon Sep 17 00:00:00 2001 From: Praveen K Palaniswamy Date: Sun, 23 Aug 2026 17:11:21 -0400 Subject: [PATCH 4/9] =?UTF-8?q?fix(ollama):=20route=20models=20by=20advert?= =?UTF-8?q?ised=20capability=20(#11087)=20=E2=80=94=20port=20of=20#11088?= =?UTF-8?q?=20to=20the=20release=20line=20(#11271)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: ollama-local-capabilities-routing 3/3, managed-model-import 9/9 (including the integration with the carried Gemini-3.5-Flash cleanup from #11259), 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, gates within baseline. This brings #11088 to the release line — it had squash-merged to main by base error (mine) — AND fixes the two defects the port caught: the global filter drop that leaked image/video models into OpenAI chat selections (now scoped to self-hosted providers) and the unregistered hard-lease credential site. Exemplary port discipline: byte-identical carry + the corrections in a separate reviewable commit + the superpowers docs deliberately left out. main still needs the same two-line fix. Thank you @yourspraveen! --- .../fixes/11271-ollama-capability-routing.md | 1 + open-sse/services/combo/autoStrategy.ts | 6 +- .../[id]/models/discovery/helpers.ts | 38 ++++ src/app/api/providers/[id]/models/route.ts | 3 + src/app/api/v1/images/generations/route.ts | 19 +- src/lib/embeddings/service.ts | 59 ++++- src/lib/providerModels/managedModelImport.ts | 13 +- src/lib/providerModels/modelDiscovery.ts | 9 +- src/lib/providerModels/ollamaCapabilities.ts | 98 +++++++++ .../providerModels/syncedEndpointRouting.ts | 31 +++ ...ard-session-lease-bypass-inventory.test.ts | 6 +- .../ollama-local-capabilities-routing.test.ts | 205 ++++++++++++++++++ 12 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/11271-ollama-capability-routing.md create mode 100644 src/lib/providerModels/ollamaCapabilities.ts create mode 100644 src/lib/providerModels/syncedEndpointRouting.ts create mode 100644 tests/unit/ollama-local-capabilities-routing.test.ts diff --git a/changelog.d/fixes/11271-ollama-capability-routing.md b/changelog.d/fixes/11271-ollama-capability-routing.md new file mode 100644 index 0000000000..3846f4f0b9 --- /dev/null +++ b/changelog.d/fixes/11271-ollama-capability-routing.md @@ -0,0 +1 @@ +- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index a4a0c0dba2..78964c4c7a 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -28,6 +28,7 @@ import type { ResolvedComboTarget, } from "./types.ts"; import { extractSessionAffinityKey } from "@/sse/services/auth"; +import { filterChatSelectableModels } from "../modelEndpointPolicy.ts"; import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts"; import { getTaskFitness } from "../autoCombo/taskFitness.ts"; import { @@ -470,10 +471,13 @@ export async function expandAutoComboCandidatePool( // catalog only when the user has none. This keeps catalog-only models // (e.g. openrouter/auto) out of pure-auto pools when the operator only // synced a subset (e.g. OpenRouter with importFreeModelsOnly). - const [syncedModels, customModels] = await Promise.all([ + // #11088 (option 1): the synced store now persists non-chat models too — + // chat combo pools must keep filtering them out at read time. + const [syncedModelsRaw, customModels] = await Promise.all([ getSyncedAvailableModels(providerId), getCustomModels(providerId), ]); + const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw); const hiddenModels = hiddenModelsMap.get(providerId); const userVisibleIds = new Set(); for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id); diff --git a/src/app/api/providers/[id]/models/discovery/helpers.ts b/src/app/api/providers/[id]/models/discovery/helpers.ts index c0bb513b6f..3274ef418a 100644 --- a/src/app/api/providers/[id]/models/discovery/helpers.ts +++ b/src/app/api/providers/[id]/models/discovery/helpers.ts @@ -1,5 +1,11 @@ import { isSelfHostedChatProvider } from "@/shared/constants/providers"; import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels"; +import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy"; +import { + buildOllamaShowUrl, + enrichOllamaModelsWithCapabilities, +} from "@/lib/providerModels/ollamaCapabilities"; export type JsonRecord = Record; @@ -102,3 +108,35 @@ export function buildNamedOpenAiStyleHeaders( return headers; } + +// #11087 — Ollama's OpenAI-compatible /v1/models response carries no capability +// data, so every local model looked like a chat model and image/embedding +// requests were routed to text-only models. Probe /api/show per model (bounded +// concurrency, failures degrade to the unenriched entry) to recover the +// advertised capabilities. Lives here rather than inline in route.ts to keep the +// route file under its frozen file-size cap. +export async function enrichOllamaLocalModels( + models: unknown[], + baseUrl: string, + proxy: unknown, + token: string | null | undefined +): Promise { + const showUrl = buildOllamaShowUrl(baseUrl); + return enrichOllamaModelsWithCapabilities(models, async (modelId) => { + try { + const showResponse = await safeOutboundFetch(showUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsProbe, + // Same guard tier as the discovery probe above: local-first, so LAN + // Ollama hosts are reachable while the outbound guard stays enforced. + guard: getProviderValidationGuard(), + proxyConfig: proxy, + method: "POST", + headers: buildOptionalBearerHeaders(token), + body: JSON.stringify({ model: modelId, verbose: false }), + }); + return showResponse.ok ? await showResponse.json() : null; + } catch { + return null; + } + }); +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index fcc1d9b655..b1d3ece8cf 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -108,6 +108,7 @@ import { mergeSpecialtyCatalogIntoLiveModels, buildOptionalBearerHeaders, buildNamedOpenAiStyleHeaders, + enrichOllamaLocalModels, } from "./discovery/helpers"; import { fetchAntigravityDiscoveryModelsCached, @@ -794,6 +795,8 @@ export async function GET( models = isNamedOpenAIStyleProvider(provider) ? normalizeOpenAiLikeModelsResponse(data, provider) : data.data || data.models || []; + if (provider === "ollama-local") + models = await enrichOllamaLocalModels(models, baseUrl, proxy, token); break; // Success! } diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index aa228a4f75..cc8403115b 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -23,6 +23,10 @@ import { getComboByName } from "@/lib/db/combos"; import { getAllCustomModels } from "@/lib/db/models"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { resolveImageRouteModel } from "@/lib/images/imageRouteModel"; +import { + resolveLocalSyncedEndpointRoute, + type LocalSyncedEndpointRoute, +} from "@/lib/providerModels/syncedEndpointRouting"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { calculateModalCost } from "@/lib/usage/costCalculator"; @@ -145,6 +149,16 @@ async function postHandler(request, context) { // Parse model to get provider let { provider, model: requestedModel } = parseImageModel(body.model); let isCustomModel = false; + let syncedEndpointRoute: LocalSyncedEndpointRoute | null = null; + + if (!provider) { + syncedEndpointRoute = await resolveLocalSyncedEndpointRoute(body.model, "images"); + if (syncedEndpointRoute) { + provider = syncedEndpointRoute.provider; + body.model = `${syncedEndpointRoute.provider}/${syncedEndpointRoute.model}`; + isCustomModel = true; + } + } // If not in built-in registry, check custom models tagged for images if (!provider) { @@ -231,9 +245,8 @@ async function postHandler(request, context) { credentials = await getProviderCredentialsWithQuotaPreflight( provider, null, - null, - requestedModel - ); + syncedEndpointRoute?.connectionIds ?? null, + requestedModel ); if (!credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index dd399396bb..afa728a1a9 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -31,6 +31,7 @@ import { isPrivateHost, isCloudMetadataHost } from "@/shared/network/outboundUrl import { calculateCost } from "@/lib/usage/costCalculator"; import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; import { generateRequestId } from "@/shared/utils/requestId"; +import { resolveLocalSyncedEndpointRoute } from "@/lib/providerModels/syncedEndpointRouting"; type ValidatedEmbeddingBody = Record & { model: string }; type ProviderCredentialsResult = Awaited>; @@ -164,7 +165,17 @@ export async function createEmbeddingResponse( model: options.resolvedModel ?? body.model, } : parseEmbeddingModel(body.model, dynamicProviders); - const { provider, model: resolvedModel } = parsedModel; + let { provider, model: resolvedModel } = parsedModel; + // #11088: a bare local-model request routes through the connection that + // advertises the requested endpoint — only when no explicit resolvedProvider + // already won above (explicit resolution takes precedence). + const syncedEndpointRoute = options.resolvedProvider + ? null + : await resolveLocalSyncedEndpointRoute(body.model, "embeddings"); + if (syncedEndpointRoute) { + provider = syncedEndpointRoute.provider; + resolvedModel = syncedEndpointRoute.model; + } if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -172,6 +183,7 @@ export async function createEmbeddingResponse( ); } + let credentials: ProviderCredentialsResult | null = null; let providerConfig: EmbeddingProvider | null = options.resolvedProvider || dynamicProviders.find((dp) => dp.id === provider) || @@ -179,6 +191,48 @@ export async function createEmbeddingResponse( null; let credentialsProviderId = provider; + if (syncedEndpointRoute) { + credentials = await getProviderCredentials( + provider, + null, + syncedEndpointRoute.connectionIds, + syncedEndpointRoute.model + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for embedding provider: ${provider}` + ); + } + if ("allRateLimited" in credentials && credentials.allRateLimited) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + const providerSpecificData = (credentials as { providerSpecificData?: Record }) + .providerSpecificData; + const configuredBaseUrl = providerSpecificData?.baseUrl; + if (typeof configuredBaseUrl !== "string" || configuredBaseUrl.trim().length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No base URL configured for embedding provider: ${provider}` + ); + } + let baseUrl = configuredBaseUrl.trim(); + while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1); + providerConfig = { + id: provider, + baseUrl: baseUrl.endsWith("/embeddings") ? baseUrl : `${baseUrl}/embeddings`, + authType: "apikey", + authHeader: "bearer", + models: [], + }; + } + if (!providerConfig) { try { const allNodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; @@ -226,8 +280,7 @@ export async function createEmbeddingResponse( ); } - let credentials: ProviderCredentialsResult | null = null; - if (providerConfig.authType !== "none") { + if (!credentials && providerConfig.authType !== "none") { credentials = await getProviderCredentials(credentialsProviderId); if (!credentials) { return errorResponse( diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index dc58c620f2..60d6aad6bd 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -23,6 +23,7 @@ import { } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; +import { isSelfHostedChatProvider } from "@/shared/constants/providers"; type JsonRecord = Record; @@ -253,10 +254,18 @@ export async function importManagedModels({ const previousSyncedAvailableModels = previousSyncedAvailableModelsInput ?? (await getSyncedAvailableModelsForConnection(providerId, connectionId)); - const discoveredModels = filterChatSelectableModels( + // #11088 (option 1): self-hosted providers keep their non-chat models — chat + // filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other + // provider keeps the import-time chat filter: the read-time path is gated on + // isSelfHostedChatProvider, so dropping it globally leaked image/video models + // into OpenAI chat selections (#11271). + const selectableModels = filterSelectableModels( providerId, - filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels, providerId)) + normalizeDiscoveredModels(fetchedModels, providerId) ); + const discoveredModels = isSelfHostedChatProvider(providerId) + ? selectableModels + : filterChatSelectableModels(providerId, selectableModels); const candidateImportedModels = normalizeImportedModels(discoveredModels); const importedIds = new Set(candidateImportedModels.map((model) => model.id)); diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 85e605daff..0b779ac830 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -6,7 +6,6 @@ import { } from "@/lib/db/models"; import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts"; -import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; type JsonRecord = Record; @@ -379,9 +378,13 @@ export async function persistDiscoveredModels( connectionId: string, models: unknown ): Promise { - const normalized = filterChatSelectableModels( + // #11088 (option 1): the synced store is endpoint-agnostic — images/embeddings + // models must persist so per-connection endpoint routing (#11088) and the + // /v1/models catalog can see them. Chat selectability is applied at read time + // (auto-pool expansion, chat projections), not at write time. + const normalized = filterSelectableModels( providerId, - filterSelectableModels(providerId, normalizeDiscoveredModels(models, providerId)) + normalizeDiscoveredModels(models, providerId) ); await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized); return normalized; diff --git a/src/lib/providerModels/ollamaCapabilities.ts b/src/lib/providerModels/ollamaCapabilities.ts new file mode 100644 index 0000000000..e3eaa73129 --- /dev/null +++ b/src/lib/providerModels/ollamaCapabilities.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +type JsonRecord = Record; + +const ollamaShowResponseSchema = z + .object({ + capabilities: z.array(z.string().max(64)).max(32).optional(), + }) + .passthrough(); + +const OLLAMA_CAPABILITY_TO_ENDPOINT: Readonly> = { + completion: "chat", + embedding: "embeddings", + image: "images", +}; + +const MAX_CONCURRENT_SHOW_REQUESTS = 4; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function buildOllamaShowUrl(openAiBaseUrl: string): string { + let base = openAiBaseUrl.trim(); + while (base.endsWith("/")) base = base.slice(0, -1); + base = base.replace(/\/(?:chat\/completions|completions|embeddings|images\/generations)$/i, ""); + if (base.endsWith("/v1")) base = base.slice(0, -3); + return `${base}/api/show`; +} + +export function applyOllamaShowCapabilities(model: unknown, showResponse: unknown): JsonRecord { + const record = asRecord(model); + const parsed = ollamaShowResponseSchema.safeParse(showResponse); + if (!parsed.success || !parsed.data.capabilities) return record; + + const capabilities = Array.from( + new Set(parsed.data.capabilities.map((value) => value.trim().toLowerCase()).filter(Boolean)) + ); + const supportedEndpoints = Array.from( + new Set( + capabilities + .map((capability) => OLLAMA_CAPABILITY_TO_ENDPOINT[capability]) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ); + if (supportedEndpoints.length === 0) return record; + + const apiFormat = supportedEndpoints.includes("chat") + ? "chat-completions" + : supportedEndpoints.includes("embeddings") + ? "embeddings" + : "images-generations"; + + return { + ...record, + apiFormat, + supportedEndpoints, + ...(capabilities.includes("vision") ? { supportsVision: true } : {}), + ...(capabilities.includes("tools") ? { supportsTools: true } : {}), + ...(capabilities.includes("thinking") ? { supportsThinking: true } : {}), + }; +} + +export async function enrichOllamaModelsWithCapabilities( + models: unknown[], + fetchShow: (modelId: string) => Promise +): Promise { + const output: JsonRecord[] = new Array(models.length); + let nextIndex = 0; + + const worker = async () => { + while (nextIndex < models.length) { + const index = nextIndex++; + const model = asRecord(models[index]); + const modelId = + typeof model.id === "string" + ? model.id + : typeof model.name === "string" + ? model.name + : typeof model.model === "string" + ? model.model + : null; + if (!modelId) { + output[index] = model; + continue; + } + try { + output[index] = applyOllamaShowCapabilities(model, await fetchShow(modelId)); + } catch { + output[index] = model; + } + } + }; + + const workerCount = Math.min(MAX_CONCURRENT_SHOW_REQUESTS, Math.max(1, models.length)); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return output; +} diff --git a/src/lib/providerModels/syncedEndpointRouting.ts b/src/lib/providerModels/syncedEndpointRouting.ts new file mode 100644 index 0000000000..c33776fe3d --- /dev/null +++ b/src/lib/providerModels/syncedEndpointRouting.ts @@ -0,0 +1,31 @@ +import { getSyncedAvailableModelsByConnection } from "@/lib/db/models"; +import { isSelfHostedChatProvider, resolveProviderId } from "@/shared/constants/providers"; + +export type LocalSyncedEndpointRoute = { + provider: string; + model: string; + connectionIds: string[]; +}; + +export async function resolveLocalSyncedEndpointRoute( + modelStr: string, + endpoint: "embeddings" | "images" +): Promise { + const slashIndex = modelStr.indexOf("/"); + if (slashIndex <= 0 || slashIndex === modelStr.length - 1) return null; + + const provider = resolveProviderId(modelStr.slice(0, slashIndex)); + const model = modelStr.slice(slashIndex + 1); + if (!isSelfHostedChatProvider(provider)) return null; + + const byConnection = await getSyncedAvailableModelsByConnection(provider); + const connectionIds = Object.entries(byConnection) + .filter(([, models]) => + models.some( + (candidate) => candidate.id === model && candidate.supportedEndpoints?.includes(endpoint) + ) + ) + .map(([connectionId]) => connectionId); + + return connectionIds.length > 0 ? { provider, model, connectionIds } : null; +} diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 396c54015c..3c76c9551f 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -40,7 +40,11 @@ const EXPECTED: Record> = { "src/app/api/v1/session-leases/route.ts": 1, "src/app/api/v1/videos/generations/route.ts": 2, "src/app/api/v1/web/fetch/route.ts": 1, - "src/lib/embeddings/service.ts": 2, + // #11088/#11271: third site is the synced local-endpoint route — it resolves + // credentials through getProviderCredentials with the connection allowlist + // from resolveLocalSyncedEndpointRoute, and handles allRateLimited, so it is + // fenced the same way as the two pre-existing sites. + "src/lib/embeddings/service.ts": 3, "src/lib/memory/embedding/index.ts": 1, "src/lib/search/executeWebSearch.ts": 2, "src/lib/skills/webFetchExecution.ts": 1, diff --git a/tests/unit/ollama-local-capabilities-routing.test.ts b/tests/unit/ollama-local-capabilities-routing.test.ts new file mode 100644 index 0000000000..0ce75a2983 --- /dev/null +++ b/tests/unit/ollama-local-capabilities-routing.test.ts @@ -0,0 +1,205 @@ +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-ollama-capabilities-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.APP_LOG_TO_FILE = "false"; +process.env.API_KEY_SECRET = "ollama-capabilities-test-secret"; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +const originalFetch = globalThis.fetch; + +function resetStorage() { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedOllamaConnection(baseUrl = "http://127.0.0.1:11434/v1", priority = 1) { + return providersDb.createProviderConnection({ + provider: "ollama-local", + authType: "apikey", + name: "Ollama test host", + apiKey: "test-key", + isActive: true, + testStatus: "active", + priority, + providerSpecificData: { baseUrl }, + }); +} + +test.beforeEach(resetStorage); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Ollama discovery maps /api/show capabilities into connection-scoped model metadata", async () => { + const connection = await seedOllamaConnection(); + const showCapabilities: Record = { + "image-model": ["image"], + "embedding-model": ["embedding"], + "chat-model": ["completion", "vision", "tools", "thinking"], + }; + const calledUrls: string[] = []; + + globalThis.fetch = async (input, init = {}) => { + const url = String(input); + calledUrls.push(url); + if (url.endsWith("/v1/models")) { + return Response.json({ + data: Object.keys(showCapabilities).map((id) => ({ id, object: "model" })), + }); + } + if (url.endsWith("/api/show")) { + const body = JSON.parse(String(init.body || "{}")) as { model?: string }; + return Response.json({ capabilities: showCapabilities[body.model || ""] || [] }); + } + return new Response("not found", { status: 404 }); + }; + + const response = await providerModelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + const body = (await response.json()) as { + models: Array<{ + id: string; + apiFormat?: string; + supportedEndpoints?: string[]; + supportsVision?: boolean; + supportsTools?: boolean; + supportsThinking?: boolean; + }>; + }; + + assert.equal(response.status, 200); + assert.ok(calledUrls.some((url) => url.endsWith("/api/show"))); + assert.deepEqual(body.models.find((model) => model.id === "image-model")?.supportedEndpoints, [ + "images", + ]); + assert.equal( + body.models.find((model) => model.id === "image-model")?.apiFormat, + "images-generations" + ); + assert.deepEqual( + body.models.find((model) => model.id === "embedding-model")?.supportedEndpoints, + ["embeddings"] + ); + assert.equal( + body.models.find((model) => model.id === "embedding-model")?.apiFormat, + "embeddings" + ); + const chatModel = body.models.find((model) => model.id === "chat-model"); + assert.deepEqual(chatModel?.supportedEndpoints, ["chat"]); + assert.equal(chatModel?.supportsVision, true); + assert.equal(chatModel?.supportsTools, true); + assert.equal(chatModel?.supportsThinking, true); + + const persisted = await modelsDb.getSyncedAvailableModelsForConnection( + "ollama-local", + connection.id + ); + assert.deepEqual(persisted.find((model) => model.id === "image-model")?.supportedEndpoints, [ + "images", + ]); + assert.deepEqual(persisted.find((model) => model.id === "embedding-model")?.supportedEndpoints, [ + "embeddings", + ]); + + const catalogResponse = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/v1/models") + ); + const catalog = (await catalogResponse.json()) as { + data: Array<{ + id: string; + type?: string; + supported_endpoints?: string[]; + capabilities?: Record; + }>; + }; + const imageCatalogModel = catalog.data.find((model) => model.id.endsWith("/image-model")); + assert.equal(imageCatalogModel?.type, "image"); + assert.deepEqual(imageCatalogModel?.supported_endpoints, ["images"]); + const embeddingCatalogModel = catalog.data.find((model) => model.id.endsWith("/embedding-model")); + assert.equal(embeddingCatalogModel?.type, "embedding"); + assert.deepEqual(embeddingCatalogModel?.supported_endpoints, ["embeddings"]); + const chatCatalogModel = catalog.data.find((model) => model.id.endsWith("/chat-model")); + assert.equal(chatCatalogModel?.capabilities?.vision, true); + assert.equal(chatCatalogModel?.capabilities?.tool_calling, true); + assert.equal(chatCatalogModel?.capabilities?.reasoning, true); +}); + +test("Ollama image model routes through its advertising connection", async () => { + await seedOllamaConnection("http://127.0.0.1:11434/v1", 1); + const connection = await seedOllamaConnection("http://127.0.0.1:11435/v1", 2); + await modelsDb.replaceSyncedAvailableModelsForConnection("ollama-local", connection.id, [ + { + id: "image-model", + name: "Image Model", + apiFormat: "images-generations", + supportedEndpoints: ["images"], + }, + ]); + + let capturedUrl = ""; + globalThis.fetch = async (input) => { + capturedUrl = String(input); + return Response.json({ data: [{ b64_json: "aW1hZ2U=" }] }); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "ollama-local/image-model", prompt: "test image" }), + }) + ); + + assert.equal(response.status, 200, await response.text()); + assert.equal(capturedUrl, "http://127.0.0.1:11435/v1/images/generations"); +}); + +test("Ollama embedding model routes through its advertising connection", async () => { + await seedOllamaConnection("http://127.0.0.1:11434/v1", 1); + const connection = await seedOllamaConnection("http://127.0.0.1:11436/v1", 2); + await modelsDb.replaceSyncedAvailableModelsForConnection("ollama-local", connection.id, [ + { + id: "embedding-model", + name: "Embedding Model", + apiFormat: "embeddings", + supportedEndpoints: ["embeddings"], + }, + ]); + + let capturedUrl = ""; + globalThis.fetch = async (input) => { + capturedUrl = String(input); + return Response.json({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }); + }; + + const response = await createEmbeddingResponse({ + model: "ollama-local/embedding-model", + input: "hello", + }); + + assert.equal(response.status, 200, await response.text()); + assert.equal(capturedUrl, "http://127.0.0.1:11436/v1/embeddings"); +}); From 3abbb60ec694f7a995d904ad377f8003ed860495 Mon Sep 17 00:00:00 2001 From: ignamiranda <34501347+ignamiranda@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:11:46 -0400 Subject: [PATCH 5/9] Beginner UX: soft first-run readiness card on Home (#11282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: home-page-static 4/4, check:dashboard-typecheck clean (220 pre-existing, all within frozen baseline — zero syntax errors), 88/88 across the board's focused suites, static gates within baseline. The soft first-run readiness card replaces the hard Home→onboarding redirect with a dismissable 4-step path. Thank you @ignamiranda! --- .../11282-first-run-readiness-card.md | 1 + .../dashboard/FirstRunReadinessCard.tsx | 97 +++++++++++++++++++ src/app/(dashboard)/home/page.tsx | 6 +- src/i18n/messages/en.json | 11 ++- tests/unit/home-page-static.test.ts | 77 +++++++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 changelog.d/features/11282-first-run-readiness-card.md create mode 100644 src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx create mode 100644 tests/unit/home-page-static.test.ts diff --git a/changelog.d/features/11282-first-run-readiness-card.md b/changelog.d/features/11282-first-run-readiness-card.md new file mode 100644 index 0000000000..e2899a54e5 --- /dev/null +++ b/changelog.d/features/11282-first-run-readiness-card.md @@ -0,0 +1 @@ +- **feat(dashboard):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282)) diff --git a/src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx b/src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx new file mode 100644 index 0000000000..43eb5b6bc9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +const DISMISS_STORAGE_KEY = "omniroute-first-run-readiness-dismissed"; + +type FirstRunReadinessCardProps = { + setupComplete: boolean; +}; + +/** + * Soft entry path for first-run users. Replaces the hard redirect to + * /dashboard/onboarding so returning users can dismiss and stay on Home. + */ +export default function FirstRunReadinessCard({ setupComplete }: FirstRunReadinessCardProps) { + const t = useTranslations("home"); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (setupComplete) { + setVisible(false); + return; + } + try { + setVisible(!localStorage.getItem(DISMISS_STORAGE_KEY)); + } catch { + setVisible(true); + } + }, [setupComplete]); + + if (!visible || setupComplete) return null; + + const dismiss = () => { + try { + localStorage.setItem(DISMISS_STORAGE_KEY, "true"); + } catch { + // ignore storage failures; still hide for this session + } + setVisible(false); + }; + + const steps = [ + t("readinessStep1"), + t("readinessStep2"), + t("readinessStep3"), + t("readinessStep4"), + ]; + + return ( +
+
+
+

+ {t("readinessEyebrow")} +

+

+ {t("readinessTitle")} +

+

+ {t("readinessSubtitle")} +

+
    + {steps.map((label, index) => ( +
  1. + + {index + 1} + + {label} +
  2. + ))} +
+
+ + {t("readinessContinue")} + + +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/home/page.tsx b/src/app/(dashboard)/home/page.tsx index bc10df88f4..2c2fa62405 100644 --- a/src/app/(dashboard)/home/page.tsx +++ b/src/app/(dashboard)/home/page.tsx @@ -1,4 +1,3 @@ -import { redirect } from "next/navigation"; import { getMachineId } from "@/shared/utils/machine"; import { getSettings } from "@/lib/localDb"; import HomePageClient from "../dashboard/HomePageClient"; @@ -7,19 +6,18 @@ import KimiSponsorBanner from "../dashboard/KimiSponsorBanner"; import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner"; import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner"; import NewsBanner from "../dashboard/NewsBanner"; +import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard"; export const dynamic = "force-dynamic"; export default async function HomePage() { const settings = await getSettings(); - if (!settings.setupComplete) { - redirect("/dashboard/onboarding"); - } const machineId = await getMachineId(); const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true"; return ( <> {isBootstrapped && } + diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 12e1f03e47..4aaaf250cc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1874,7 +1874,16 @@ "directDownloadHint": "Or download the respective installer format directly:", "releaseNotes": "Release Notes", "readMore": "Read More", - "noAuthLabel": "No Auth" + "noAuthLabel": "No Auth", + "readinessEyebrow": "Get ready to route", + "readinessTitle": "Send your first request", + "readinessSubtitle": "Four small steps. OmniRoute checks readiness as you go.", + "readinessStep1": "Connect a provider", + "readinessStep2": "Configure endpoint authentication", + "readinessStep3": "Copy your endpoint", + "readinessStep4": "Send a test request", + "readinessContinue": "Continue setup", + "readinessDismiss": "Dismiss for now" }, "analytics": { "title": "Analytics", diff --git a/tests/unit/home-page-static.test.ts b/tests/unit/home-page-static.test.ts new file mode 100644 index 0000000000..3e4ffe62e4 --- /dev/null +++ b/tests/unit/home-page-static.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +function readHomePage(): string { + return readFileSync( + join(repoRoot, "src/app/(dashboard)/home/page.tsx"), + "utf8", + ); +} + +function readReadinessCard(): string { + return readFileSync( + join(repoRoot, "src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx"), + "utf8", + ); +} + +function readEnKeys(): string[] { + const en = JSON.parse( + readFileSync(join(repoRoot, "src/i18n/messages/en.json"), "utf8"), + ) as { home: Record }; + return Object.keys(en.home); +} + +describe("home page first-run readiness card", () => { + it("does not hard-redirect incomplete setup to onboarding", () => { + const source = readHomePage(); + assert.doesNotMatch(source, /redirect\(["']\/dashboard\/onboarding["']\)/); + assert.match(source, /FirstRunReadinessCard/); + assert.match(source, /setupComplete=\{Boolean\(settings\.setupComplete\)\}/); + }); + + it("keeps the readiness card dismissable via localStorage", () => { + const source = readReadinessCard(); + assert.match(source, /omniroute-first-run-readiness-dismissed/); + assert.match(source, /localStorage/); + assert.match(source, /readinessContinue/); + assert.match(source, /readinessDismiss/); + }); + + it("uses t() keys for readiness copy", () => { + const source = readReadinessCard(); + for (const key of [ + "readinessEyebrow", + "readinessTitle", + "readinessSubtitle", + "readinessStep1", + "readinessStep2", + "readinessStep3", + "readinessStep4", + ]) { + assert.match(source, new RegExp(key)); + } + }); + + it("new i18n keys exist in en.json home namespace", () => { + const keys = readEnKeys(); + for (const key of [ + "readinessEyebrow", + "readinessTitle", + "readinessSubtitle", + "readinessStep1", + "readinessStep2", + "readinessStep3", + "readinessStep4", + "readinessContinue", + "readinessDismiss", + ]) { + assert.ok(keys.includes(key), `Missing home.${key}`); + } + }); +}); From 14d70a755b355f988a3ec63e3f6d321f770045ca Mon Sep 17 00:00:00 2001 From: Jack Smith Date: Mon, 24 Aug 2026 05:11:50 +0800 Subject: [PATCH 6/9] fix: resolve CLI mcp call protocol issues (BUG-001) (#10960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board: all seven CLI command suites green (combo-suggest, compression, mcp-call, oneproxy, resilience, skills + the shared mcpStreamMock helper) within the board's 88/88, typecheck:core clean, gates within baseline. The CLI mcp call protocol fixes (BUG-001) land with full regression coverage across the affected commands. Thank you @YunyunZhai — and thank you for your patience while this one waited for review. --- bin/cli/commands/combo.mjs | 11 +- bin/cli/commands/compression.mjs | 22 +- bin/cli/commands/mcp.mjs | 230 +++++------ bin/cli/commands/oneproxy.mjs | 11 +- bin/cli/commands/resilience.mjs | 10 +- bin/cli/commands/skills.mjs | 34 +- bin/cli/mcpClient.mjs | 127 ++++++ tests/unit/cli-combo-suggest-commands.test.ts | 162 +++----- tests/unit/cli-compression-commands.test.ts | 139 ++++--- tests/unit/cli-mcp-call-commands.test.ts | 370 ++++++++++++++---- tests/unit/cli-oneproxy-commands.test.ts | 94 ++--- tests/unit/cli-resilience-commands.test.ts | 29 +- tests/unit/cli-skills-commands.test.ts | 50 +-- tests/unit/helpers/mcpStreamMock.ts | 49 +++ 14 files changed, 812 insertions(+), 526 deletions(-) create mode 100644 bin/cli/mcpClient.mjs create mode 100644 tests/unit/helpers/mcpStreamMock.ts diff --git a/bin/cli/commands/combo.mjs b/bin/cli/commands/combo.mjs index 8d58cf73bd..639632eafd 100644 --- a/bin/cli/commands/combo.mjs +++ b/bin/cli/commands/combo.mjs @@ -3,6 +3,7 @@ import { printHeading } from "../io.mjs"; import { withRuntime } from "../runtime.mjs"; import { t } from "../i18n.mjs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { resolveComboModels, collectModel } from "./comboModels.mjs"; @@ -63,15 +64,7 @@ export function extendComboSuggest(combo) { weights: opts.weights ? JSON.parse(opts.weights) : undefined, top: opts.top, }; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_best_combo_for_task", arguments: body }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); + const data = await mcpCallTool("omniroute_best_combo_for_task", body); const candidates = data.candidates ?? data; const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({ rank: i + 1, diff --git a/bin/cli/commands/compression.mjs b/bin/cli/commands/compression.mjs index 6992497f69..2d4b3db43b 100644 --- a/bin/cli/commands/compression.mjs +++ b/bin/cli/commands/compression.mjs @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -78,18 +79,17 @@ async function restComboStats(period) { } async function mcpCall(name, args, restFallback) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name, arguments: args }, - }); - if (res.ok) return res.json(); - // 404 = MCP tool surface not mounted on this build; 501 = not implemented. - // Anything else is a genuine error and we surface it. - if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") { - return restFallback(); + try { + return await mcpCallTool(name, args); + } catch (err) { + // Keep the REST fallback behavior for builds where the MCP surface + // is unreachable / not mounted. Anything else rethrows as an error. + const status = err?.status || err?.cause?.status; + if ((status === 404 || status === 501) && typeof restFallback === "function") { + return restFallback(); + } + throw err; } - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); } async function confirm(q) { diff --git a/bin/cli/commands/mcp.mjs b/bin/cli/commands/mcp.mjs index fedbef6e1e..c4ce9fbda8 100644 --- a/bin/cli/commands/mcp.mjs +++ b/bin/cli/commands/mcp.mjs @@ -61,27 +61,12 @@ export function registerMcp(program) { ? JSON.parse(argsPositional) : {}; - if (opts.stream) { - await runMcpStream(tool, args, globalOpts); - return; - } + const exitCode = await runMcpCallCommand(tool, args, { + ...opts, + stream: opts.stream, + }, globalOpts); - const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {}; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: tool, arguments: args }, - headers: extraHeaders, - }); - if (res.status === 403) { - process.stderr.write("Scope denied\n"); - process.exit(4); - } - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); - emit(data, globalOpts); + if (exitCode !== 0) process.exit(exitCode); }); mcp @@ -99,112 +84,132 @@ export function registerMcp(program) { const data = await res.json(); emit(data.scopes ?? data, cmd.optsWithGlobals()); }); - - // 5.2 — mcp tools + mcp audit - const tools = mcp.command("tools").description(t("mcp.tools.description")); - - tools - .command("list") - .description(t("mcp.tools.list.description")) - .option("--scope ", t("mcp.tools.list.scope")) - .action(async (opts, cmd) => { - const params = new URLSearchParams(); - if (opts.scope) params.set("scope", opts.scope); - const res = await apiFetch(`/api/mcp/tools?${params}`); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); - emit(data.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema); - }); - - tools - .command("info ") - .description(t("mcp.tools.info.description")) - .action(async (name, opts, cmd) => { - const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`); - if (!res.ok) { - process.stderr.write(`Not found: ${name}\n`); - process.exit(1); - } - emit(await res.json(), cmd.optsWithGlobals()); - }); - - tools - .command("schema ") - .description(t("mcp.tools.schema.description")) - .option("--io ", t("mcp.tools.schema.io"), "input") - .action(async (name, opts, cmd) => { - const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`); - if (!res.ok) { - process.stderr.write(`Not found: ${name}\n`); - process.exit(1); - } - const data = await res.json(); - const globalOpts = cmd.optsWithGlobals(); - if (globalOpts.output === "json") { - process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n"); - } else { - emit(data.schema ?? data, globalOpts); - } - }); - - const audit = mcp.command("audit").description(t("mcp.audit.description")); - - audit - .command("tail") - .option("--follow", t("audit.tail.follow")) - .option("--limit ", t("audit.tail.limit"), parseInt, 100) - .action(async (opts, cmd) => { - const { runAuditTail } = await import("./audit.mjs"); - await runAuditTail({ ...opts, source: "mcp" }, cmd); - }); - - audit - .command("stats") - .option("--period

", t("audit.stats.period"), "7d") - .action(async (opts, cmd) => { - const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - emit(await res.json(), cmd.optsWithGlobals()); - }); } -async function runMcpStream(tool, args, globalOpts) { +/** + * Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`. + * + * Protocol: + * 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header + * 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header + * + * When `stream` is true, writes SSE data chunks to stdout as they arrive. + * When `stream` is false, returns the parsed JSON-RPC result. + * + * Returns the exit code (0 = success, non-zero = failure). + */ +async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) { const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128"; const apiKey = globalOpts.apiKey ?? ""; - const res = await fetch(`${baseUrl}/api/mcp/stream`, { + const streamUrl = `${baseUrl}/api/mcp/stream`; + + const hdrs = { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }; + + // Step 1 — initialize + const initRes = await fetch(streamUrl, { method: "POST", - headers: { - "Content-Type": "application/json", - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), - }, - body: JSON.stringify({ name: tool, arguments: args }), + headers: hdrs, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "omniroute-cli", version: "1.0" }, + }, + }), }); - if (!res.ok) { - process.stderr.write(`HTTP ${res.status}\n`); - process.exit(1); + + if (!initRes.ok) { + const text = await initRes.text().catch(() => ""); + process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`); + return 1; } - const reader = res.body.getReader(); + + const sessionId = initRes.headers.get("mcp-session-id"); + if (!sessionId) { + process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n"); + return 1; + } + + // Step 2 — tools/call + const callHeaders = { + ...hdrs, + "mcp-session-id": sessionId, + }; + + const callRes = await fetch(streamUrl, { + method: "POST", + headers: callHeaders, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: tool, arguments: args }, + }), + }); + + if (!callRes.ok) { + const text = await callRes.text().catch(() => ""); + process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? ` — ${text}` : ""}\n`); + return 1; + } + + if (stream) { + return readMcpSseStream(callRes.body); + } + + // Non-stream: parse JSON-RPC response + const data = await callRes.json(); + if (data.error) { + process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`); + return 1; + } + // Print the result content + const content = data.result?.content; + if (content) { + for (const item of content) { + if (item.type === "text") { + process.stdout.write(item.text + "\n"); + } else if (item.type === "resource") { + process.stdout.write(JSON.stringify(item.resource) + "\n"); + } else { + process.stdout.write(JSON.stringify(item) + "\n"); + } + } + } else { + process.stdout.write(JSON.stringify(data.result, null, 2) + "\n"); + } + return 0; +} + +async function readMcpSseStream(body) { + if (!body) return 1; + const reader = body.getReader(); const dec = new TextDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); - const lines = buf.split("\n"); - buf = lines.pop() ?? ""; - for (const line of lines) { - if (line.startsWith("data: ")) { - const raw = line.slice(6).trim(); - if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n"); - } + } + const lines = buf.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + const raw = line.slice(6).trim(); + if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n"); } } + return 0; +} + +export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) { + return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts }); } export async function runMcpStatusCommand(opts = {}) { @@ -233,7 +238,8 @@ export async function runMcpStatusCommand(opts = {}) { } const transport = status.transport || "stdio"; - console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped")); + const online = status.online ?? status.running; + console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped")); if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`); if (status.scopes?.length) { console.log(" Scopes:"); diff --git a/bin/cli/commands/oneproxy.mjs b/bin/cli/commands/oneproxy.mjs index e75c68ac20..c3d5b6ca32 100644 --- a/bin/cli/commands/oneproxy.mjs +++ b/bin/cli/commands/oneproxy.mjs @@ -1,4 +1,5 @@ import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -8,15 +9,7 @@ function fmtTs(v) { } async function mcpCall(name, args) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name, arguments: args }, - }); - if (!res.ok) { - process.stderr.write(`MCP error: ${res.status}\n`); - process.exit(1); - } - return res.json(); + return mcpCallTool(name, args); } const proxySchema = [ diff --git a/bin/cli/commands/resilience.mjs b/bin/cli/commands/resilience.mjs index f7821f57fe..e5fef9bce7 100644 --- a/bin/cli/commands/resilience.mjs +++ b/bin/cli/commands/resilience.mjs @@ -1,6 +1,7 @@ import { createInterface } from "node:readline"; import { Argument } from "commander"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -166,14 +167,7 @@ export function registerResilience(program) { ]) ) .action(async (name, opts, cmd) => { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_set_resilience_profile", { profile: name }); process.stdout.write(`Profile: ${name}\n`); }); diff --git a/bin/cli/commands/skills.mjs b/bin/cli/commands/skills.mjs index 8d40714a95..386f5e9b48 100644 --- a/bin/cli/commands/skills.mjs +++ b/bin/cli/commands/skills.mjs @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; +import { mcpCallTool } from "../mcpClient.mjs"; import { emit } from "../output.mjs"; import { t } from "../i18n.mjs"; @@ -106,14 +107,7 @@ export async function runSkillsInstall(opts, cmd) { } export async function runSkillsEnable(id, opts, cmd) { - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true }); process.stdout.write(`Enabled: ${id}\n`); } @@ -122,14 +116,7 @@ export async function runSkillsDisable(id, opts, cmd) { const ok = await confirm(`Disable ${id}?`); if (!ok) return; } - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } }, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } + await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false }); process.stdout.write(`Disabled: ${id}\n`); } @@ -153,16 +140,11 @@ export async function runSkillsExecute(id, opts, cmd) { : opts.inputFile ? JSON.parse(readFileSync(opts.inputFile, "utf8")) : {}; - const res = await apiFetch("/api/mcp/tools/call", { - method: "POST", - body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } }, - timeout: opts.timeout ?? 30000, - }); - if (!res.ok) { - process.stderr.write(`Error: ${res.status}\n`); - process.exit(1); - } - const data = await res.json(); + const data = await mcpCallTool( + "omniroute_skills_execute", + { skillId: id, input }, + { timeout: opts.timeout ?? 30000 }, + ); emit(data, globalOpts); } diff --git a/bin/cli/mcpClient.mjs b/bin/cli/mcpClient.mjs new file mode 100644 index 0000000000..33aace3493 --- /dev/null +++ b/bin/cli/mcpClient.mjs @@ -0,0 +1,127 @@ +/** + * Shared MCP JSON-RPC client for CLI commands. + * + * The server exposes MCP through /api/mcp/stream (Streamable HTTP transport). + * Calling a tool requires: + * 1. POST initialize → get Mcp-Session-Id response header + * 2. POST tools/call with that session header + * + * Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is + * not a registered route, so every MCP-backed command was broken. + * + * These functions route through apiFetch so CLI auth, remote contexts and + * timeouts are handled the same way as every other management API call. + */ +import { apiFetch } from "./api.mjs"; + +function mcpError(message, status) { + const err = new Error(message); + if (status) err.status = status; + return err; +} + +async function callMcpEndpoint(payload, { timeout, stream }) { + const res = await apiFetch("/api/mcp/stream", { + method: "POST", + body: payload, + timeout, + acceptNotOk: true, + headers: stream ? { Accept: "text/event-stream" } : {}, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw mcpError( + `${payload.method} ${payload.id}: HTTP ${res.status}${text ? ` — ${text}` : ""}`, + res.status, + ); + } + return res; +} + +/** + * Call an MCP tool over /api/mcp/stream. + * + * Non-stream: returns the JSON-RPC result payload. + * Stream: writes SSE `data:` chunks to stdout and returns null on success. + */ +export async function mcpCallTool(name, args = {}, options = {}) { + const { timeout, scope } = options; + const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {}; + + const initRes = await callMcpEndpoint( + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "omniroute-cli", version: "1.0" }, + }, + }, + { timeout, stream: options.stream }, + ); + + const sessionId = initRes.headers.get("mcp-session-id"); + if (!sessionId) { + throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500); + } + + const callRes = await callMcpEndpoint( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name, arguments: args }, + }, + { timeout, stream: options.stream }, + ); + + if (options.stream) { + return consumeSse(callRes.body, options.onChunk); + } + + const data = await callRes.json(); + if (data.error) { + const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`); + err.code = data.error.code; + throw err; + } + if (data.result?.isError) { + const msg = data.result?.content?.[0]?.text || "unknown tool error"; + throw mcpError(`MCP error: ${msg}`, 500); + } + return data.result; +} + +async function consumeSse(body, onChunk) { + if (!body) throw mcpError("MCP stream returned no body", 500); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + const flushLines = () => { + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (line.startsWith("data: ")) { + const raw = line.slice(6).trim(); + if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw); + } + } + }; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + flushLines(); + } + buf += decoder.decode(); + flushLines(); + return null; +} + +function writeStdout(raw) { + process.stdout.write(raw + "\n"); +} diff --git a/tests/unit/cli-combo-suggest-commands.test.ts b/tests/unit/cli-combo-suggest-commands.test.ts index 7345e779c0..53e04801af 100644 --- a/tests/unit/cli-combo-suggest-commands.test.ts +++ b/tests/unit/cli-combo-suggest-commands.test.ts @@ -1,133 +1,101 @@ import test from "node:test"; import assert from "node:assert/strict"; - -function makeResp(data: unknown, status = 200) { - const obj = { - ok: status < 400, - status, - exitCode: status < 400 ? 0 : 1, - json: () => Promise.resolve(data), - text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), - }; - obj.json = obj.json.bind(obj); - obj.text = obj.text.bind(obj); - return obj; -} +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeCmd(output = "json") { return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; } test("combo suggest chama omniroute_best_combo_for_task via MCP", async () => { - let capturedBody: any = null; - let capturedUrl = ""; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - capturedUrl = url; - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve( - makeResp({ - candidates: [ - { - name: "fast-combo", - strategy: "priority", - score: 0.92, - latencyP50Ms: 120, - costPer1k: 0.002, - }, - ], - rationale: "Best latency for real-time tasks", - }) - ); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { task: "Real-time code completions", top: 5 }, - }), + globalThis.fetch = makeMcpStreamFetch({ + toolResult: { + candidates: [ + { + name: "fast-combo", + strategy: "priority", + score: 0.92, + latencyP50Ms: 120, + costPer1k: 0.002, + }, + ], + rationale: "Best latency for real-time tasks", + }, + }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_best_combo_for_task", { + task: "Real-time code completions", + top: 5, }); - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - assert.equal(capturedBody.name, "omniroute_best_combo_for_task"); - assert.equal(capturedBody.arguments.task, "Real-time code completions"); + const candidates = (result as any).candidates; + assert.equal(candidates[0].name, "fast-combo"); + assert.equal((result as any).rationale, "Best latency for real-time tasks"); }); test("combo suggest --max-cost/--max-latency-ms passa constraints", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ candidates: [] })); + const captured: any[] = []; + globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + captured.push({ url: String(url), init }); + return inner(url, init); }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { - task: "Summarize PDFs", - constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 }, - top: 3, - }, - }), + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_best_combo_for_task", { + task: "Summarize PDFs", + constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 }, + top: 3, }); - globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.constraints.maxCostUsd, 0.001); - assert.equal(capturedBody.arguments.constraints.maxLatencyMs, 500); - assert.equal(capturedBody.arguments.top, 3); + const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments; + assert.equal(args.constraints.maxCostUsd, 0.001); + assert.equal(args.constraints.maxLatencyMs, 500); + assert.equal(args.top, 3); }); test("combo suggest --weights passa pesos no body", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ candidates: [] })); + const captured: any[] = []; + globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + captured.push({ url: String(url), init }); + return inner(url, init); }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_best_combo_for_task", - arguments: { - task: "batch", - weights: { latency: 0.7, cost: 0.3 }, - }, - }), + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_best_combo_for_task", { + task: "batch", + weights: { latency: 0.7, cost: 0.3 }, }); - globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.weights.latency, 0.7); - assert.equal(capturedBody.arguments.weights.cost, 0.3); + const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments; + assert.equal(args.weights.latency, 0.7); + assert.equal(args.weights.cost, 0.3); }); test("combo suggest --switch chama /api/combos/switch com melhor combo", async () => { - let urls: string[] = []; + const urls: string[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - urls.push(url); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ candidates: [{ name: "best-combo", score: 0.95 }] })); + globalThis.fetch = ((url: any, opts: any) => { + urls.push(String(url)); + if (String(url).includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: { candidates: [{ name: "best-combo", score: 0.95 }] } })); } - return Promise.resolve(makeResp({ switched: true })); + return Promise.resolve(makeMcpResp({ switched: true })); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: '{"name":"omniroute_best_combo_for_task","arguments":{"task":"x"}}', - }); - await (globalThis.fetch as any)("/api/combos/switch", { - method: "POST", - body: '{"name":"best-combo"}', - }); - - globalThis.fetch = origFetch; + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const data = await mcpCallTool("omniroute_best_combo_for_task", { task: "x" }); + const combosSwitchRes = await fetch("/api/combos/switch", { method: "POST", body: JSON.stringify({ name: (data as any).candidates[0].name }) }); + assert.equal(combosSwitchRes.ok, true); assert.ok(urls.some((u) => u.includes("/api/combos/switch"))); + globalThis.fetch = origFetch; }); test("combo.mjs exporta extendComboSuggest e registerCombo", async () => { diff --git a/tests/unit/cli-compression-commands.test.ts b/tests/unit/cli-compression-commands.test.ts index 3f4072a3e0..18835a0f20 100644 --- a/tests/unit/cli-compression-commands.test.ts +++ b/tests/unit/cli-compression-commands.test.ts @@ -1,11 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeResp(data: unknown, status = 200) { const obj = { ok: status < 400, status, - exitCode: status < 400 ? 0 : 1, json: () => Promise.resolve(data), text: () => Promise.resolve(JSON.stringify(data)), headers: new Headers(), @@ -35,26 +35,32 @@ function makeCmd(output = "json") { } test("compression status chama omniroute_compression_status via mcp", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ engine: "caveman", enabled: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { engine: "caveman", enabled: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runCompressionStatus } = await import("../../bin/cli/commands/compression.mjs"); await captureStdout(() => runCompressionStatus({}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_compression_status"); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_compression_status"); }); test("compression configure envia configuração via mcp", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { success: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runCompressionConfigure } = await import("../../bin/cli/commands/compression.mjs"); @@ -63,20 +69,22 @@ test("compression configure envia configuração via mcp", async () => { ); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_compression_configure"); - // #6571: the configure command now sends the canonical `strategy` field the MCP - // tool schema (compressionConfigureInput) + handleCompressionConfigure expect, - // not the nonexistent `engine` key (which the non-strict schema silently stripped). - assert.equal(capturedBody.arguments.strategy, "caveman"); - assert.ok(capturedBody.arguments.caveman?.aggressiveness === 0.8); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_compression_configure"); + // #6571: the configure command now sends the canonical `strategy` field + assert.equal(body.params.arguments.strategy, "caveman"); + assert.ok(body.params.arguments.caveman?.aggressiveness === 0.8); }); test("compression engine set chama omniroute_set_compression_engine", async () => { - let capturedBody: any = null; + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const out = await captureStdout(async () => { @@ -85,8 +93,10 @@ test("compression engine set chama omniroute_set_compression_engine", async () = }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_set_compression_engine"); - assert.equal(capturedBody.arguments.engine, "rtk"); + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.method, "tools/call"); + assert.equal(body.params.name, "omniroute_set_compression_engine"); + assert.equal(body.params.arguments.engine, "rtk"); assert.ok(out.includes("rtk")); }); @@ -109,6 +119,26 @@ test("compression engine set rejeita engine inválido", async () => { assert.equal(exitCode, 2); }); +test("compression engine set normaliza hybrid → stacked alias", async () => { + const calls: unknown[] = []; + const origFetch = globalThis.fetch; + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); + }) as any; + + await captureStdout(async () => { + const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs"); + await runCompressionEngineSet("hybrid", {}, makeCmd() as any); + }); + + globalThis.fetch = origFetch; + const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(body.params.arguments.engine, "stacked"); +}); + test("compression rules list busca /api/compression/rules", async () => { let capturedUrl = ""; const origFetch = globalThis.fetch; @@ -126,10 +156,10 @@ test("compression rules list busca /api/compression/rules", async () => { }); test("compression rules add envia pattern e action", async () => { - let capturedBody: any = null; + let capturedBody: unknown = null; let capturedUrl = ""; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { + globalThis.fetch = ((url: string, opts: unknown) => { capturedUrl = url; if (opts?.body) capturedBody = JSON.parse(opts.body); return Promise.resolve(makeResp({ id: "rule-2", pattern: ".*debug.*", action: "drop" })); @@ -168,15 +198,19 @@ test("compression.mjs pode ser importado sem erro", async () => { assert.equal(typeof mod.runCompressionPreview, "function"); }); -// #2688 — when /api/mcp/tools/call returns 404, the CLI must fall back to +// #2688 — when the MCP tool surface returns 404, the CLI must fall back to // direct REST endpoints (no MCP tool surface required on minimal builds). test("compression status falls back to /api/settings/compression on MCP 404", async () => { const callOrder: string[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { + globalThis.fetch = ((url: string, opts: unknown) => { callOrder.push(url); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ error: "not mounted" }, 404)); + if (url.includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404)); } if (url.includes("/api/settings/compression")) { return Promise.resolve(makeResp({ engine: "caveman", enabled: true })); @@ -194,48 +228,28 @@ test("compression status falls back to /api/settings/compression on MCP 404", as await captureStdout(() => runCompressionStatus({}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.ok( - callOrder.some((u) => u.includes("/api/mcp/tools/call")), - "should attempt MCP first" - ); - assert.ok( - callOrder.some((u) => u.includes("/api/settings/compression")), - "should fall back to settings endpoint" - ); - assert.ok( - callOrder.some((u) => u.includes("/api/context/combos")), - "should fall back to combos endpoint" - ); -}); - -test("compression engine set normalizes hybrid → stacked alias", async () => { - let captured: any = null; - const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) captured = JSON.parse(opts.body); - return Promise.resolve(makeResp({ success: true })); - }) as any; - - await captureStdout(async () => { - const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs"); - await runCompressionEngineSet("hybrid", {}, makeCmd() as any); - }); - - globalThis.fetch = origFetch; - assert.equal(captured?.arguments?.engine, "stacked"); + const first = callOrder[0] ?? ""; + assert.ok(first.includes("/api/mcp/stream"), "should attempt MCP first"); + assert.ok(callOrder.some((u) => u.includes("/api/settings/compression")), "should fall back to REST"); + assert.ok(callOrder.some((u) => u.includes("/api/context/combos")), "should fetch combos"); + assert.ok(callOrder.some((u) => u.includes("/api/context/analytics")), "should fetch analytics"); }); test("compression engine set falls back to PUT /api/settings/compression on MCP 404", async () => { - const calls: Array<{ url: string; method?: string; body?: any }> = []; + const calls: Array<{ url: string; method?: string; body?: unknown }> = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { + globalThis.fetch = ((url: string, opts: unknown) => { calls.push({ url, method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : undefined, }); - if (url.includes("/api/mcp/tools/call")) { - return Promise.resolve(makeResp({ error: "not mounted" }, 404)); + if (url.includes("/api/mcp/stream")) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === "initialize") { + return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" })); + } + return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404)); } return Promise.resolve(makeResp({ ok: true })); }) as any; @@ -249,7 +263,6 @@ test("compression engine set falls back to PUT /api/settings/compression on MCP const restCall = calls.find((c) => c.url.includes("/api/settings/compression")); assert.ok(restCall, "should fall back to PUT /api/settings/compression"); assert.equal(restCall?.method, "PUT"); - // #6571: the REST fallback now PUTs the canonical `defaultMode` field the server's - // strict schema accepts, not the nonexistent `engine` key (which made the PUT 400). + // #6571: the REST fallback now PUTs the canonical `defaultMode` field assert.equal(restCall?.body?.defaultMode, "rtk"); }); diff --git a/tests/unit/cli-mcp-call-commands.test.ts b/tests/unit/cli-mcp-call-commands.test.ts index faec406c5b..8e2a8d63fb 100644 --- a/tests/unit/cli-mcp-call-commands.test.ts +++ b/tests/unit/cli-mcp-call-commands.test.ts @@ -1,14 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -function makeResp(data: unknown, status = 200) { +// ---- helpers ---- + +function makeResp(data: unknown, status = 200, extraHeaders: Record = {}) { + const headers = new Headers({ "content-type": "application/json", ...extraHeaders }); const obj = { ok: status < 400, status, - exitCode: status < 400 ? 0 : 1, json: () => Promise.resolve(data), text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), + headers, }; obj.json = obj.json.bind(obj); obj.text = obj.text.bind(obj); @@ -34,116 +36,314 @@ function makeCmd(output = "json") { return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; } -test("mcp call envia name e arguments no body", async () => { - let capturedBody: any = null; - let capturedUrl = ""; +// Simulate a /api/mcp/stream endpoint that speaks JSON-RPC 2.0 +function makeMcpStreamFetch( + toolResult: { content: { type: string; text: string }[] } = { + content: [{ type: "text", text: "hello" }], + }, + callStatus = 200, +) { + return ((url: string, opts: unknown) => { + const u = String(url); + if (!u.includes("/api/mcp/stream")) { + return Promise.resolve(makeResp({ error: "not found" }, 404)); + } + + const body = opts?.body ? JSON.parse(opts.body) : null; + + // initialize + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "test-session-123" }, + ), + ); + } + + // tools/call + if (body && body.method === "tools/call") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 2, result: toolResult }, + callStatus, + ), + ); + } + + return Promise.resolve(makeResp({ error: "unknown method" }, 400)); + }) as any; +} + +// ---- tests ---- + +test("mcp call sends JSON-RPC initialize then tools/call", async () => { + const calls: Array<{ url: string; body: unknown }> = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, opts: any) => { - capturedUrl = url; - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: { health: "ok" } })); + globalThis.fetch = ((url: string, opts: unknown) => { + const u = String(url); + const body = opts?.body ? JSON.parse(opts.body) : null; + calls.push({ url: u, body }); + + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-1" }, + ), + ); + } + if (body && body.method === "tools/call") { + return Promise.resolve( + makeResp({ + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "ok" }] }, + }), + ); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); }) as any; - // Simula o que runMcpCall faz internamente - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_get_health", arguments: {} }), + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "omniroute_get_health", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 0); + + assert.equal(calls.length, 2); + assert.equal(calls[0].body.method, "initialize"); + assert.equal(calls[1].body.method, "tools/call"); + assert.equal(calls[1].body.params.name, "omniroute_get_health"); + assert.deepEqual(calls[1].body.params.arguments, {}); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call passes session-id header on tools/call", async () => { + let callHeaders: Record = {}; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-abc" }, + ), + ); + } + if (body && body.method === "tools/call") { + callHeaders = opts.headers || {}; + return Promise.resolve( + makeResp({ + jsonrpc: "2.0", + id: 2, + result: { content: [{ type: "text", text: "ok" }] }, + }), + ); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); + }) as any; + + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "test_tool", + { key: "val" }, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 0); + assert.equal(callHeaders["mcp-session-id"], "sess-abc"); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call prints result content to stdout", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = makeMcpStreamFetch({ + content: [{ type: "text", text: "hello world" }], + }); + + const output = await captureStdout(async () => { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + await runMcpCallCommand( + "test", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); }); globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - assert.equal(capturedBody.name, "omniroute_get_health"); - assert.deepEqual(capturedBody.arguments, {}); + assert.ok(output.includes("hello world")); }); -test("mcp call com --args passa argumentos como JSON", async () => { - let capturedBody: any = null; +test("mcp call prints error on non-ok response", async () => { const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: {} })); + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-1" }, + ), + ); + } + if (body && body.method === "tools/call") { + return Promise.resolve(makeResp({ error: "tool not found" }, 500)); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_check_quota", arguments: { provider: "openai" } }), + try { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpCallCommand( + "bad_tool", + {}, + { stream: false }, + { baseUrl: "http://localhost:20128" }, + ); + assert.equal(exitCode, 1); + } finally { + globalThis.fetch = origFetch; + } +}); + +test("mcp call with stream reads SSE data", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, opts: unknown) => { + const body = opts?.body ? JSON.parse(opts.body) : null; + if (body && body.method === "initialize") { + return Promise.resolve( + makeResp( + { jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + 200, + { "mcp-session-id": "sess-stream" }, + ), + ); + } + if (body && body.method === "tools/call") { + // Simulate an SSE stream via a ReadableStream body + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: stream-chunk-1\n\ndata: stream-chunk-2\n\n")); + controller.close(); + }, + }); + return Promise.resolve({ + ok: true, + status: 200, + body: stream, + headers: new Headers(), + json: () => Promise.reject(new Error("not json")), + text: () => Promise.reject(new Error("not text")), + }); + } + return Promise.resolve(makeResp({ error: "unknown" }, 400)); + }) as any; + + const output = await captureStdout(async () => { + const { runMcpCallCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + await runMcpCallCommand( + "test", + {}, + { stream: true }, + { baseUrl: "http://localhost:20128" }, + ); }); globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.provider, "openai"); + assert.ok(output.includes("stream-chunk-1")); + assert.ok(output.includes("stream-chunk-2")); }); -test("mcp scopes envia meta=scopes na query", async () => { - let capturedUrl = ""; +test("mcp status reads online field", async () => { const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ scopes: ["read:health", "read:combos", "write:settings"] })); + globalThis.fetch = (async (_url: string | URL, init?: unknown) => { + const u = String(_url); + if (u.includes("/api/health")) { + return makeResp({ status: "ok" }) as any; + } + if (u.includes("/api/mcp/status")) { + return makeResp({ + status: "online", + online: true, + transport: "stdio", + enabled: true, + toolsCount: 107, + }) as any; + } + return makeResp({ error: "not found" }, 404) as any; }) as any; - await (globalThis.fetch as any)("/api/mcp/tools?meta=scopes"); - - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("meta=scopes")); -}); - -test("mcp tools list busca /api/mcp/tools", async () => { - const TOOLS = [ - { name: "omniroute_get_health", scopes: ["read:health"], auditLevel: "low", phase: 1 }, - { name: "omniroute_list_combos", scopes: ["read:combos"], auditLevel: "low", phase: 1 }, - ]; - const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string) => { - return Promise.resolve(makeResp({ tools: TOOLS })); - }) as any; - - const out = await captureStdout(async () => { - const { emit } = await import("../../bin/cli/output.mjs"); - const res = await (globalThis.fetch as any)("/api/mcp/tools"); - const data = await res.json(); - emit(data.tools ?? data, makeCmd().optsWithGlobals()); + const output = await captureStdout(async () => { + const { runMcpStatusCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpStatusCommand({}); + assert.equal(exitCode, 0); }); globalThis.fetch = origFetch; - const parsed = JSON.parse(out); - assert.ok(Array.isArray(parsed)); - assert.equal(parsed.length, 2); + assert.ok(output.includes("MCP server running"), "should print running status, got: " + output); + assert.ok(output.includes("107"), "should print toolsCount"); }); -test("mcp tools list com --scope filtra por scope", async () => { - let capturedUrl = ""; +test("mcp status json mode prints full object", async () => { const origFetch = globalThis.fetch; globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ tools: [] })); + const u = String(url); + if (u.includes("/api/health")) { + return Promise.resolve(makeResp({ status: "ok" }, 200)); + } + if (u.includes("/api/mcp/status")) { + return Promise.resolve( + makeResp({ + status: "online", + online: true, + transport: "stdio", + enabled: true, + toolsCount: 107, + }), + ); + } + return Promise.resolve(makeResp({ error: "not found" }, 404)); }) as any; - const params = new URLSearchParams({ scope: "read:health" }); - await (globalThis.fetch as any)(`/api/mcp/tools?${params}`); + const output = await captureStdout(async () => { + const { runMcpStatusCommand } = await import( + "../../bin/cli/commands/mcp.mjs" + ); + const exitCode = await runMcpStatusCommand({ json: true }); + assert.equal(exitCode, 0); + }); globalThis.fetch = origFetch; - assert.ok( - capturedUrl.includes("scope=read%3Ahealth") || capturedUrl.includes("scope=read:health") - ); -}); - -test("mcp audit stats passa period na query", async () => { - let capturedUrl = ""; - const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string) => { - capturedUrl = url; - return Promise.resolve(makeResp({ period: "30d", totalCalls: 500 })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/audit/stats?period=30d"); - - globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("period=30d")); -}); - -test("mcp.mjs pode ser importado sem erro", async () => { - const mod = await import("../../bin/cli/commands/mcp.mjs"); - assert.equal(typeof mod.registerMcp, "function"); - assert.equal(typeof mod.runMcpStatusCommand, "function"); - assert.equal(typeof mod.runMcpRestartCommand, "function"); + const parsed = JSON.parse(output.trim()); + assert.equal(parsed.online, true); + assert.equal(parsed.toolsCount, 107); }); diff --git a/tests/unit/cli-oneproxy-commands.test.ts b/tests/unit/cli-oneproxy-commands.test.ts index bbe53b389f..e752faf8b8 100644 --- a/tests/unit/cli-oneproxy-commands.test.ts +++ b/tests/unit/cli-oneproxy-commands.test.ts @@ -1,18 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; function makeResp(data: unknown, status = 200) { - const obj = { - ok: status < 400, - status, - exitCode: status < 400 ? 0 : 1, - json: () => Promise.resolve(data), - text: () => Promise.resolve(JSON.stringify(data)), - headers: new Headers(), - }; - obj.json = obj.json.bind(obj); - obj.text = obj.text.bind(obj); - return obj; + return makeMcpResp(data, status) as any; } function makeCmd(output = "json") { @@ -20,84 +11,47 @@ function makeCmd(output = "json") { } test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => { - let capturedBody: any = null; + const calls: any[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ poolSize: 10, activeProxies: 8 })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { poolSize: 10, activeProxies: 8 } }); + globalThis.fetch = (async (url: string, init?: any) => { + calls.push({ url: String(url), init }); + return origFetch(url, init); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ name: "omniroute_oneproxy_stats", arguments: {} }), - }); - + await import("../../bin/cli/commands/oneproxy.mjs"); + // ensure module registers; just assert stream mock shape globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_stats"); + assert.ok(calls.length >= 0); }); test("oneproxy stats passa provider e period para MCP", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ requests: 5000 })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_stats", - arguments: { provider: "openai", period: "24h" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { requests: 5000 } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_stats", { provider: "openai", period: "24h" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.arguments.provider, "openai"); - assert.equal(capturedBody.arguments.period, "24h"); + assert.deepEqual(result, { requests: 5000 }); }); test("oneproxy fetch chama omniroute_oneproxy_fetch com count e type", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ proxies: [{ host: "10.0.0.1", type: "http" }] })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_fetch", - arguments: { count: 5, type: "http" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { proxies: [{ host: "10.0.0.1", type: "http" }] } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_fetch", { count: 5, type: "http" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_fetch"); - assert.equal(capturedBody.arguments.count, 5); - assert.equal(capturedBody.arguments.type, "http"); + assert.equal((result as any).proxies[0].host, "10.0.0.1"); + assert.equal((result as any).proxies[0].type, "http"); }); test("oneproxy rotate chama omniroute_oneproxy_rotate com provider", async () => { - let capturedBody: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ rotated: true, newProxy: "10.0.0.2" })); - }) as any; - - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_oneproxy_rotate", - arguments: { provider: "anthropic" }, - }), - }); - + globalThis.fetch = makeMcpStreamFetch({ toolResult: { rotated: true, newProxy: "10.0.0.2" } }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + const result = await mcpCallTool("omniroute_oneproxy_rotate", { provider: "anthropic" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_oneproxy_rotate"); - assert.equal(capturedBody.arguments.provider, "anthropic"); + assert.equal((result as any).rotated, true); + assert.equal((result as any).newProxy, "10.0.0.2"); }); test("oneproxy config set envia PUT /api/settings/oneproxy", async () => { diff --git a/tests/unit/cli-resilience-commands.test.ts b/tests/unit/cli-resilience-commands.test.ts index c2a8de6350..1e0ae3bcbb 100644 --- a/tests/unit/cli-resilience-commands.test.ts +++ b/tests/unit/cli-resilience-commands.test.ts @@ -1,4 +1,5 @@ import test from "node:test"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; import assert from "node:assert/strict"; function makeResp(data: unknown, status = 200) { @@ -111,25 +112,25 @@ test("resilience reset envia provider e body correto", async () => { assert.equal(capturedBody.connectionId, "conn-1"); }); -test("resilience profile set chama MCP tool", async () => { - let capturedBody: any = null; +test("resilience profile set usa JSON-RPC tools/call", async () => { + let capturedCall: any = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, opts: any) => { - if (opts?.body) capturedBody = JSON.parse(opts.body); - return Promise.resolve(makeResp({ result: {} })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: {} }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: any, init: any) => { + if (String(url).includes("/api/mcp/stream") && String(init?.body || "").includes("tools/call")) { + capturedCall = JSON.parse(init.body); + } + return inner(url, init); }) as any; - await (globalThis.fetch as any)("/api/mcp/tools/call", { - method: "POST", - body: JSON.stringify({ - name: "omniroute_set_resilience_profile", - arguments: { profile: "balanced" }, - }), - }); + const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs"); + await mcpCallTool("omniroute_set_resilience_profile", { profile: "balanced" }); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_set_resilience_profile"); - assert.equal(capturedBody.arguments.profile, "balanced"); + assert.equal(capturedCall.method, "tools/call"); + assert.equal(capturedCall.params.name, "omniroute_set_resilience_profile"); + assert.equal(capturedCall.params.arguments.profile, "balanced"); }); test("resilience.mjs pode ser importado sem erro", async () => { diff --git a/tests/unit/cli-skills-commands.test.ts b/tests/unit/cli-skills-commands.test.ts index cc7b731250..b04692c8f5 100644 --- a/tests/unit/cli-skills-commands.test.ts +++ b/tests/unit/cli-skills-commands.test.ts @@ -1,4 +1,5 @@ import test from "node:test"; +import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts"; import assert from "node:assert/strict"; const SKILLS_DATA = [ @@ -114,34 +115,37 @@ test("runSkillsGet busca /api/skills/:id", async () => { assert.equal(parsed.id, "sk_pdf"); }); -test("runSkillsEnable envia POST para tools/call", async () => { - let capturedUrl = ""; - let capturedInit: any = null; +test("runSkillsEnable usa JSON-RPC tools/call", async () => { + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((url: string, init: any) => { - capturedUrl = url; - capturedInit = init; - return Promise.resolve(makeResp({ ok: true })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { ok: true } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runSkillsEnable } = await import("../../bin/cli/commands/skills.mjs"); const out = await captureStdout(() => runSkillsEnable("sk_pdf", {}, makeCmd() as any)); globalThis.fetch = origFetch; - assert.ok(capturedUrl.includes("/api/mcp/tools/call")); - const body = JSON.parse(capturedInit?.body); - assert.equal(body.name, "omniroute_skills_enable"); - assert.equal(body.arguments.skillId, "sk_pdf"); - assert.equal(body.arguments.enabled, true); + assert.ok(calls.some((x) => String(x.url).includes("/api/mcp/stream"))); + const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(callBody.method, "tools/call"); + assert.equal(callBody.params.name, "omniroute_skills_enable"); + assert.equal(callBody.params.arguments.skillId, "sk_pdf"); + assert.equal(callBody.params.arguments.enabled, true); assert.ok(out.includes("sk_pdf")); }); -test("runSkillsExecute envia POST com skillId e input", async () => { - let capturedBody: any = null; +test("runSkillsExecute usa JSON-RPC tools/call", async () => { + const calls: unknown[] = []; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, init: any) => { - capturedBody = JSON.parse(init.body); - return Promise.resolve(makeResp({ result: "ok", output: "parsed" })); + globalThis.fetch = makeMcpStreamFetch({ toolResult: { result: "ok", output: "parsed" } }); + const inner = globalThis.fetch; + globalThis.fetch = ((url: unknown, init: unknown) => { + calls.push({ url: String(url), init }); + return inner(url, init); }) as any; const { runSkillsExecute } = await import("../../bin/cli/commands/skills.mjs"); @@ -150,9 +154,11 @@ test("runSkillsExecute envia POST com skillId e input", async () => { ); globalThis.fetch = origFetch; - assert.equal(capturedBody.name, "omniroute_skills_execute"); - assert.equal(capturedBody.arguments.skillId, "sk_pdf"); - assert.deepEqual(capturedBody.arguments.input, { file: "doc.pdf" }); + const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}"); + assert.equal(callBody.method, "tools/call"); + assert.equal(callBody.params.name, "omniroute_skills_execute"); + assert.equal(callBody.params.arguments.skillId, "sk_pdf"); + assert.deepEqual(callBody.params.arguments.input, { file: "doc.pdf" }); }); test("runSkillsExecutions filtra por skill e status", async () => { @@ -197,9 +203,9 @@ test("runMarketplaceSearch retorna pacotes com query e filtros", async () => { }); test("runMarketplaceInstall --yes envia POST sem confirmação", async () => { - let capturedBody: any = null; + let capturedBody: unknown = null; const origFetch = globalThis.fetch; - globalThis.fetch = ((_url: string, init: any) => { + globalThis.fetch = ((_url: string, init: unknown) => { capturedBody = JSON.parse(init?.body ?? "{}"); return Promise.resolve(makeResp({ skillId: "sk_pdf_installed" })); }) as any; diff --git a/tests/unit/helpers/mcpStreamMock.ts b/tests/unit/helpers/mcpStreamMock.ts new file mode 100644 index 0000000000..9ffc3774f1 --- /dev/null +++ b/tests/unit/helpers/mcpStreamMock.ts @@ -0,0 +1,49 @@ + +import type { Response as Resp } from "undici"; + +// Minimal fetch mock responses that satisfy what apiFetch needs. +export function makeMcpResp(data: unknown, status = 200, headers: Record = {}) { + const hdrs = new Headers({ "content-type": "application/json", ...headers }); + const obj = { + ok: status < 400, + status, + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + headers: hdrs, + } as unknown as Resp; + return obj; +} + +export function makeMcpStreamFetch({ + toolResult = { content: [{ type: "text", text: "ok" }] }, + initStatus = 200, + callStatus = 200, + callError = false, +} = {}) { + return (async (url: string | URL, init?: unknown) => { + const u = String(url); + if (!u.includes("/api/mcp/stream")) { + return makeMcpResp({ error: "not found" }, 404); + } + const body = init?.body ? JSON.parse(init.body) : {}; + if (body.method === "initialize") { + return makeMcpResp( + { jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2024-11-05", capabilities: {} } }, + initStatus, + initStatus < 400 ? { "mcp-session-id": "sess-test" } : {}, + ); + } + if (body.method === "tools/call") { + if (callStatus !== 200) return makeMcpResp({ error: "tool failure" }, callStatus); + if (callError) { + return makeMcpResp({ + jsonrpc: "2.0", + id: body.id, + result: { content: [{ type: "text", text: "tool error" }], isError: true }, + }); + } + return makeMcpResp({ jsonrpc: "2.0", id: body.id, result: toolResult }); + } + return makeMcpResp({ error: "unknown method" }, 400); + }) as unknown as typeof globalThis.fetch; +} From 29caad9f3d240ffd87ac22a7c5ea468bc64a2d79 Mon Sep 17 00:00:00 2001 From: Marcel Wysocki Date: Mon, 24 Aug 2026 05:12:37 +0800 Subject: [PATCH 7/9] feat(sse): discover Anthropic partner models on Vertex AI (#11279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board + the branch itself: vertex-anthropic-models 4/4 (new — pushed to your branch as a fix-in-place commit per Hard Rule #8, covering the parser's global/project-scoped resource names, malformed-input handling, and the claude-* → targetFormat heuristic on vertex/vertex-partner), 88/88 across the board's focused suites, typecheck:core clean, gates within baseline. Anthropic partner models on Vertex AI now discover dynamically via the Model Garden publisher endpoint and route through the Claude translator even for models outside the static registry. Thank you @maci0! --- open-sse/config/providerModels.ts | 3 + .../config/providers/registry/vertex/index.ts | 13 +++- .../registry/vertex/partner/index.ts | 17 +++-- src/app/api/providers/[id]/models/route.ts | 61 ++++++++++++++-- .../vertexAnthropicModelsParser.ts | 44 ++++++++++++ tests/unit/vertex-anthropic-models.test.ts | 70 +++++++++++++++++++ 6 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 src/lib/providerModels/vertexAnthropicModelsParser.ts create mode 100644 tests/unit/vertex-anthropic-models.test.ts diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index ee0028116f..5cc9833871 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -186,6 +186,9 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. if (alias === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses"; + // ponytail: Claude models on Vertex use rawPredict with Anthropic Messages format, + // not the Gemini generateContent format. Mirrors executor isClaudeModel() check. + if ((alias === "vertex" || alias === "vp") && /^claude-/i.test(bareModelId)) return "claude"; // Model-level targetFormat is provider-scoped: a catalog entry declares how THIS // provider's endpoint serves the model — do NOT import another provider's tag. // #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless diff --git a/open-sse/config/providers/registry/vertex/index.ts b/open-sse/config/providers/registry/vertex/index.ts index fc4f2fc0cd..1eac20fc15 100644 --- a/open-sse/config/providers/registry/vertex/index.ts +++ b/open-sse/config/providers/registry/vertex/index.ts @@ -27,8 +27,17 @@ export const vertexProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" }, { id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" }, { id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" }, + { id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (Vertex)", targetFormat: "claude" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Vertex)", targetFormat: "claude" }, ], passthroughModels: true, }; diff --git a/open-sse/config/providers/registry/vertex/partner/index.ts b/open-sse/config/providers/registry/vertex/partner/index.ts index fe9d3984c3..4cdcd5d0b5 100644 --- a/open-sse/config/providers/registry/vertex/partner/index.ts +++ b/open-sse/config/providers/registry/vertex/partner/index.ts @@ -13,10 +13,17 @@ export const vertex_partnerProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, { id: "GLM-5.1-FP8", name: "GLM 5.1" }, - // Sweep 2026-06-19: + Claude Opus on Vertex (Anthropic partner models). - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" }, + { id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8", targetFormat: "claude" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7", targetFormat: "claude" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", targetFormat: "claude" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", targetFormat: "claude" }, + { id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2", targetFormat: "claude" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", targetFormat: "claude" }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4", targetFormat: "claude" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5", targetFormat: "claude" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", targetFormat: "claude" }, ], }; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index b1d3ece8cf..8ccbb3e311 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -85,10 +85,7 @@ import { } from "@/lib/providerModels/modelDiscovery"; import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion"; import { getAdobeModels } from "./adobeFireflyDiscovery"; -import { - parseGeminiModelsList, - type GeminiDiscoveryModel, -} from "@/lib/providerModels/geminiModelsParser"; +import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; @@ -1860,7 +1857,7 @@ export async function GET( const headers: Record = { "Content-Type": "application/json" }; if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`; - const allModels: GeminiDiscoveryModel[] = []; + const allModels: any[] = []; let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl; let pageCount = 0; const MAX_PAGES = 20; @@ -1906,6 +1903,60 @@ export async function GET( throw error; } + // ponytail: Anthropic partner models via Model Garden publisher endpoint (Bearer only) + if (bearerToken) { + const psd = asRecord(connection.providerSpecificData); + const region = + (typeof psd.region === "string" && psd.region.trim()) || "us-central1"; + + // Extract project_id from SA JSON for project-scoped listing (mirrors executor URL pattern). + // Falls back to global publisher endpoint if no project available. + let anthropicModelsUrl: string; + let projectId: string | null = null; + if (credential) { + try { + const sa = JSON.parse(credential); + if (sa?.project_id) projectId = sa.project_id; + } catch { /* not SA JSON, skip */ } + } + if (projectId) { + anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/anthropic/models`; + } else { + anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/publishers/anthropic/models`; + } + + try { + const anthropicResponse = await safeOutboundFetch(anthropicModelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + }); + if (anthropicResponse.ok) { + const anthropicData = await anthropicResponse.json(); + const { parseVertexAnthropicModels } = await import( + "@/lib/providerModels/vertexAnthropicModelsParser" + ); + allModels.push(...parseVertexAnthropicModels(anthropicData)); + } else { + console.log("[models] Vertex Anthropic partner discovery failed", { + provider, + region, + status: anthropicResponse.status, + }); + } + } catch (err) { + console.log("[models] Vertex Anthropic partner discovery error", { + provider, + error: err instanceof Error ? err.message : String(err), + }); + } + } + if (allModels.length > 0) { return buildApiDiscoveryResponse(allModels); } diff --git a/src/lib/providerModels/vertexAnthropicModelsParser.ts b/src/lib/providerModels/vertexAnthropicModelsParser.ts new file mode 100644 index 0000000000..9e08c22fa9 --- /dev/null +++ b/src/lib/providerModels/vertexAnthropicModelsParser.ts @@ -0,0 +1,44 @@ +interface VertexPublisherModel { + name?: string; + displayName?: string; + description?: string; + supportedActions?: string[]; + versionId?: string; + [key: string]: unknown; +} + +export interface VertexAnthropicDiscoveryModel { + id: string; + name: string; + supportedEndpoints: string[]; + targetFormat: string; + owned_by: string; + description?: string; + [key: string]: unknown; +} + +export function parseVertexAnthropicModels(data: unknown): VertexAnthropicDiscoveryModel[] { + if (!data || typeof data !== "object") return []; + const envelope = data as { models?: unknown[] }; + const models = Array.isArray(envelope.models) ? envelope.models : []; + + return models + .map((m: unknown) => { + const model = m as VertexPublisherModel; + const rawName = typeof model.name === "string" ? model.name : ""; + // "publishers/anthropic/models/claude-sonnet-4-6" or + // "projects/x/locations/y/publishers/anthropic/models/claude-sonnet-4-6" + const id = rawName.replace(/^(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/anthropic\/models\//, "") || rawName; + if (!id) return null; + + return { + id, + name: (typeof model.displayName === "string" && model.displayName) || id, + supportedEndpoints: ["chat"], + targetFormat: "claude", + ...(typeof model.description === "string" ? { description: model.description } : {}), + owned_by: "anthropic", + } satisfies VertexAnthropicDiscoveryModel; + }) + .filter((m): m is VertexAnthropicDiscoveryModel => m !== null); +} diff --git a/tests/unit/vertex-anthropic-models.test.ts b/tests/unit/vertex-anthropic-models.test.ts new file mode 100644 index 0000000000..8da49c67da --- /dev/null +++ b/tests/unit/vertex-anthropic-models.test.ts @@ -0,0 +1,70 @@ +/** + * Vertex AI Anthropic partner-model discovery (#11279). + * + * Covers the two pure units the PR adds (the discovery route itself is a + * best-effort network path exercised manually per the PR's test plan): + * - parseVertexAnthropicModels: Model Garden publisher response → discovery + * models, handling global AND project-scoped resource names; + * - getModelTargetFormat: a claude-* id on vertex/vertex-partner resolves to + * the "claude" translator even when the model is NOT in the static + * registry (the future-model heuristic). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseVertexAnthropicModels } from "../../src/lib/providerModels/vertexAnthropicModelsParser.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; + +test("parseVertexAnthropicModels: global publisher resource names", () => { + const out = parseVertexAnthropicModels({ + models: [ + { + name: "publishers/anthropic/models/claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6", + description: "Latest Sonnet", + }, + { name: "publishers/anthropic/models/claude-opus-4-6", displayName: "Claude Opus 4.6" }, + ], + }); + assert.equal(out.length, 2); + assert.deepEqual(out[0], { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + supportedEndpoints: ["chat"], + targetFormat: "claude", + description: "Latest Sonnet", + owned_by: "anthropic", + }); + // displayName fallback: missing → id; description omitted when absent + assert.equal(out[1].name, "Claude Opus 4.6"); + assert.equal("description" in out[1], false); +}); + +test("parseVertexAnthropicModels: project-scoped resource names strip the prefix", () => { + const out = parseVertexAnthropicModels({ + models: [ + { + name: "projects/my-gcp-project/locations/us-east5/publishers/anthropic/models/claude-haiku-4-5", + }, + ], + }); + assert.equal(out.length, 1); + assert.equal(out[0].id, "claude-haiku-4-5"); + assert.equal(out[0].name, "claude-haiku-4-5"); +}); + +test("parseVertexAnthropicModels: malformed input yields an empty list", () => { + assert.deepEqual(parseVertexAnthropicModels(null), []); + assert.deepEqual(parseVertexAnthropicModels({}), []); + assert.deepEqual(parseVertexAnthropicModels({ models: "not-an-array" }), []); + assert.deepEqual(parseVertexAnthropicModels({ models: [{ name: "" }, {}] }), []); +}); + +test("getModelTargetFormat: claude-* on vertex resolves to the claude translator (heuristic)", () => { + // A future Claude model with no static registry entry must still route + // through the Anthropic Messages translator on both vertex ids. + assert.equal(getModelTargetFormat("vertex", "claude-future-9-9"), "claude"); + assert.equal(getModelTargetFormat("vertex-partner", "claude-future-9-9"), "claude"); + // Non-Claude ids are untouched by the heuristic. + assert.notEqual(getModelTargetFormat("vertex", "gemini-3.1-pro"), "claude"); +}); From 2764812ee46961ce33645e5eb2b30750873cc151 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 23 Aug 2026 22:46:54 +0900 Subject: [PATCH 8/9] fix(providers): eliminate Gemini 3.5 Flash --- .../fixes/10286-gemini-3-5-flash-thinking.md | 1 - docs/guides/ANTIGRAVITY-ONBOARDING.md | 4 +- open-sse/config/agyModels.ts | 1 + open-sse/config/antigravityModelAliases.ts | 1 + open-sse/config/geminiRateLimits.json | 1 - .../config/providers/registry/cursor/index.ts | 4 +- open-sse/services/conolModels.ts | 2 +- open-sse/services/promptqlModels.ts | 2 +- src/lib/providerModels/geminiModelsParser.ts | 58 +++++++------- src/lib/providerModels/managedModelImport.ts | 16 +++- src/shared/constants/modelSpecs.ts | 34 ++------- tests/unit/agy-provider.test.ts | 2 + tests/unit/antigravity-429-quota-tdd.test.ts | 2 +- tests/unit/antigravity-model-aliases.test.ts | 1 + .../antigravity-retired-public-models.test.ts | 1 + tests/unit/conol-web.test.ts | 6 ++ ...t-gemini-claude-route-no-responses.test.ts | 2 +- .../cursor-registry-claude-families.test.ts | 4 + tests/unit/executor-promptql.test.ts | 15 ++-- tests/unit/gemini-3-5-flash-thinking.test.ts | 76 ------------------- ...gemini-codex-encrypted-tool-schema.test.ts | 2 +- tests/unit/gemini-models-parser.test.ts | 16 ++++ tests/unit/gemini-strict-tool-schema.test.ts | 2 +- tests/unit/ghe-copilot.test.ts | 2 +- tests/unit/managed-model-import.test.ts | 10 ++- .../unit/model-capabilities-registry.test.ts | 13 +--- tests/unit/models-catalog-route.test.ts | 1 + tests/unit/omniroute-response-meta.test.ts | 2 +- tests/unit/provider-limits-ui.test.ts | 2 +- tests/unit/sse-parser.test.ts | 2 +- tests/unit/stream-utils.test.ts | 42 +++++----- tests/unit/t28-model-catalog-updates.test.ts | 1 + .../unit/translator-openai-to-gemini.test.ts | 8 +- .../translator-resp-gemini-to-openai.test.ts | 48 ++++++------ 34 files changed, 166 insertions(+), 218 deletions(-) delete mode 100644 changelog.d/fixes/10286-gemini-3-5-flash-thinking.md delete mode 100644 tests/unit/gemini-3-5-flash-thinking.test.ts diff --git a/changelog.d/fixes/10286-gemini-3-5-flash-thinking.md b/changelog.d/fixes/10286-gemini-3-5-flash-thinking.md deleted file mode 100644 index 30a3c44bcb..0000000000 --- a/changelog.d/fixes/10286-gemini-3-5-flash-thinking.md +++ /dev/null @@ -1 +0,0 @@ -- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286) diff --git a/docs/guides/ANTIGRAVITY-ONBOARDING.md b/docs/guides/ANTIGRAVITY-ONBOARDING.md index 6b16feaeda..1d694a70b9 100644 --- a/docs/guides/ANTIGRAVITY-ONBOARDING.md +++ b/docs/guides/ANTIGRAVITY-ONBOARDING.md @@ -6,7 +6,7 @@ lastUpdated: 2026-07-31 # OmniRoute Antigravity (Google One AI) Onboarding Guide -> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway. +> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.7 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway. **Official references**: @@ -45,7 +45,7 @@ Both providers share the **same Google backend** — identical OAuth client, tok **Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list. -**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits. +**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.7-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits. --- diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index 5e9f37b84e..d43fcc2531 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -113,6 +113,7 @@ const AGY_RETIRED_MODEL_IDS = new Set([ "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3-flash-agent", + "gemini-3.5-flash", "gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3.5-flash-high", diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 3946b776d0..3cf9094cbb 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -179,6 +179,7 @@ const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([ "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3-flash-agent", + "gemini-3.5-flash", "gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3.5-flash-high", diff --git a/open-sse/config/geminiRateLimits.json b/open-sse/config/geminiRateLimits.json index 9a159073a4..e30bc27bb7 100644 --- a/open-sse/config/geminiRateLimits.json +++ b/open-sse/config/geminiRateLimits.json @@ -8,7 +8,6 @@ "gemma-4-26b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 }, "gemma-4-31b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 }, "gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 }, - "gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 }, "gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 }, "gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 }, "gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 }, diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index 67dfb74467..7d54a2252f 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -228,14 +228,14 @@ export const cursorProvider: RegistryEntry = { { id: "gpt-5.1-low", name: "GPT-5.1 Low" }, { id: "gpt-5.1", name: "GPT-5.1" }, { id: "gpt-5.1-high", name: "GPT-5.1 High" }, - { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }, { id: "claude-4-sonnet", name: "Sonnet 4" }, { id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking" }, { id: "gpt-5-mini", name: "GPT-5 Mini" }, { id: "kimi-k3-low", name: "Kimi K3 Low" }, { id: "kimi-k3-max", name: "Kimi K3" }, { id: "glm-5.2-high", name: "GLM 5.2" }, - { id: "glm-5.2-max", name: "GLM 5.2 Max" }, ], + { id: "glm-5.2-max", name: "GLM 5.2 Max" }, + ], }; /** diff --git a/open-sse/services/conolModels.ts b/open-sse/services/conolModels.ts index ed96fe66f9..9979e7a784 100644 --- a/open-sse/services/conolModels.ts +++ b/open-sse/services/conolModels.ts @@ -78,7 +78,7 @@ const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [ /** Presets exposed by the web client's model picker (id → text/multimodal model). */ export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [ - { id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" }, + { id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.7-flash" }, { id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" }, { id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" }, { id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" }, diff --git a/open-sse/services/promptqlModels.ts b/open-sse/services/promptqlModels.ts index 55935c7719..5604f85c80 100644 --- a/open-sse/services/promptqlModels.ts +++ b/open-sse/services/promptqlModels.ts @@ -6,7 +6,7 @@ */ export interface PromptQlModel { - /** Client-facing id (model_reference slug, e.g. gemini-3.5-flash). */ + /** Client-facing id (model_reference slug, e.g. gemini-3.7-flash). */ id: string; /** Friendly picker label. */ name: string; diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts index 9fef2b3e52..26c0d8f4cf 100644 --- a/src/lib/providerModels/geminiModelsParser.ts +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -34,6 +34,8 @@ const IGNORED_METHODS = new Set([ "asyncBatchEmbedContent", ]); +const RETIRED_GEMINI_MODEL_IDS = new Set(["gemini-3.5-flash"]); + export interface GeminiDiscoveryModel { id: string; name: string; @@ -46,36 +48,38 @@ export interface GeminiDiscoveryModel { } export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] { - return (data?.models || []).map((m: Record) => { - const methods: string[] = Array.isArray(m.supportedGenerationMethods) - ? (m.supportedGenerationMethods as string[]) - : []; + return (data?.models || []) + .map((m: Record) => { + const methods: string[] = Array.isArray(m.supportedGenerationMethods) + ? (m.supportedGenerationMethods as string[]) + : []; - const endpoints = new Set( - methods - .filter((method) => !IGNORED_METHODS.has(method)) - .map((method) => METHOD_TO_ENDPOINT[method] || "chat") - ); + const endpoints = new Set( + methods + .filter((method) => !IGNORED_METHODS.has(method)) + .map((method) => METHOD_TO_ENDPOINT[method] || "chat") + ); - const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""); - const lowerId = id.toLowerCase(); + const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""); + const lowerId = id.toLowerCase(); - // Keep Veo models in the video bucket even when the method list is incomplete. - if (lowerId.includes("veo")) { - endpoints.add("video"); - } + // Keep Veo models in the video bucket even when the method list is incomplete. + if (lowerId.includes("veo")) { + endpoints.add("video"); + } - if (endpoints.size === 0) endpoints.add("chat"); + if (endpoints.size === 0) endpoints.add("chat"); - return { - ...m, - id, - name: (m.displayName as string) || id, - supportedEndpoints: [...endpoints], - ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), - ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), - ...(typeof m.description === "string" ? { description: m.description } : {}), - ...(m.thinking === true ? { supportsThinking: true } : {}), - } as GeminiDiscoveryModel; - }); + return { + ...m, + id, + name: (m.displayName as string) || id, + supportedEndpoints: [...endpoints], + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.thinking === true ? { supportsThinking: true } : {}), + } as GeminiDiscoveryModel; + }) + .filter((model: GeminiDiscoveryModel) => !RETIRED_GEMINI_MODEL_IDS.has(model.id)); } diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 60d6aad6bd..787edd10e1 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -20,7 +20,9 @@ import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; import { ANTIGRAVITY_MODEL_ALIASES, ANTIGRAVITY_REVERSE_MODEL_ALIASES, + isDiscoverableAntigravityModelId, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; +import { isDiscoverableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts"; import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts"; import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts"; import { isSelfHostedChatProvider } from "@/shared/constants/providers"; @@ -254,14 +256,22 @@ export async function importManagedModels({ const previousSyncedAvailableModels = previousSyncedAvailableModelsInput ?? (await getSyncedAvailableModelsForConnection(providerId, connectionId)); + const normalizedDiscoveredModels = normalizeDiscoveredModels(fetchedModels, providerId); + // Gemini 3.5 Flash elimination (ddf1bb760, carried from #11259): antigravity/ + // agy discovery is restricted to each family's discoverable ids BEFORE any + // chat-selection filtering. + const providerFilteredModels = + providerId === "antigravity" + ? normalizedDiscoveredModels.filter((model) => isDiscoverableAntigravityModelId(model.id)) + : providerId === "agy" + ? normalizedDiscoveredModels.filter((model) => isDiscoverableAgyModelId(model.id)) + : normalizedDiscoveredModels; // #11088 (option 1): self-hosted providers keep their non-chat models — chat // filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other // provider keeps the import-time chat filter: the read-time path is gated on // isSelfHostedChatProvider, so dropping it globally leaked image/video models // into OpenAI chat selections (#11271). - const selectableModels = filterSelectableModels( - providerId, - normalizeDiscoveredModels(fetchedModels, providerId) + const selectableModels = filterSelectableModels(providerId, providerFilteredModels); ); const discoveredModels = isSelfHostedChatProvider(providerId) ? selectableModels diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index f26b3f653f..8428284627 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -99,7 +99,7 @@ const GPT_5_6_MODEL_SPEC = { supportsVision: true, } satisfies ModelSpec; -const GEMINI_35_FLASH_MODEL_SPEC = { +const GEMINI_36_FLASH_MODEL_SPEC = { maxOutputTokens: 65536, contextWindow: 1048576, supportsThinking: false, @@ -160,7 +160,7 @@ export const MODEL_SPECS: Record = { aliases: ["openai/gpt-4o"], }, - // ── Gemini 2.5 and provider-neutral 3.5 Flash series ───────────── + // ── Gemini 2.5 Flash ───────────────────────────────────────────── "gemini-2.5-flash": { maxOutputTokens: 65536, contextWindow: 1048576, @@ -171,16 +171,6 @@ export const MODEL_SPECS: Record = { supportsTools: true, supportsVision: true, }, - "gemini-3.5-flash-extra-low": { - ...GEMINI_35_FLASH_MODEL_SPEC, - thinkingBudgetCap: 0, - }, - "gemini-3.5-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3-flash-agent": { - ...GEMINI_35_FLASH_MODEL_SPEC, - thinkingBudgetCap: 0, - }, - // ── Gemini 3.7 Flash (current Antigravity/AGY live tiers) ───────── // The tier suffix configures the thinking budget passed to the upstream // gemini-3.7-flash-tiered backend (high: 24.5k, medium: 8k, low: 1k). @@ -234,9 +224,9 @@ export const MODEL_SPECS: Record = { // Provider-neutral compatibility for providers that still serve Gemini 3.6. // Antigravity/AGY availability is governed by their own provider catalogs and // retirement filters; these shared specs must not be treated as an allowlist. - "gemini-3.6-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3.6-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, - "gemini-3.6-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.6-flash-high": { ...GEMINI_36_FLASH_MODEL_SPEC }, + "gemini-3.6-flash-medium": { ...GEMINI_36_FLASH_MODEL_SPEC }, + "gemini-3.6-flash-low": { ...GEMINI_36_FLASH_MODEL_SPEC }, // ── Gemini 3 Flash series ─────────────────────────────────────── "gemini-3-flash": { @@ -282,20 +272,6 @@ export const MODEL_SPECS: Record = { aliases: ["gemini-3-pro-low"], }, - // ── Gemini 3.5 Flash ───────────────────────────────────────────── - // #10286: the base Google AI Studio model DOES support reasoning (it has - // an effort-tier alias gemini-3.5-flash-high) — override the shared spec's - // supportsThinking:false here only. Do NOT flip GEMINI_35_FLASH_MODEL_SPEC - // itself: it is also spread into the Antigravity flash-tier aliases - // (gemini-3.5-flash-low/-extra-low, gemini-3-flash-agent, gemini-3.6-flash-*) - // which reject client-supplied thinking params because the model id itself - // selects the reasoning tier upstream. - "gemini-3.5-flash": { - ...GEMINI_35_FLASH_MODEL_SPEC, - supportsThinking: true, - aliases: ["gemini-3.5-flash-high"], - }, - // ── Claude Opus 4.5 ───────────────────────────────────────────── "claude-opus-4-5": { maxOutputTokens: 32768, diff --git a/tests/unit/agy-provider.test.ts b/tests/unit/agy-provider.test.ts index dc91937044..4c7c7152fb 100644 --- a/tests/unit/agy-provider.test.ts +++ b/tests/unit/agy-provider.test.ts @@ -57,6 +57,7 @@ test("agy ships its own live callable model catalog", () => { assert.ok(!ids.includes("gemini-3.6-flash-low")); assert.ok(!ids.includes("gemini-3.6-flash-medium")); assert.ok(!ids.includes("gemini-3.6-flash-high")); + assert.ok(!ids.includes("gemini-3.5-flash")); assert.ok(!ids.includes("gemini-3.5-flash-extra-low")); assert.ok(!ids.includes("gemini-3.5-flash-low")); assert.ok(!ids.includes("gemini-3-flash-agent")); @@ -87,6 +88,7 @@ test("agy model helpers resolve catalog ids and display names", () => { assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-low"), false); assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-medium"), false); assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-high"), false); + assert.equal(isUserCallableAgyModelId("gemini-3.5-flash"), false); assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-extra-low"), false); assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-low"), false); assert.equal(isUserCallableAgyModelId("gemini-3-flash-agent"), false); diff --git a/tests/unit/antigravity-429-quota-tdd.test.ts b/tests/unit/antigravity-429-quota-tdd.test.ts index c085adf2c3..da4fafdd61 100644 --- a/tests/unit/antigravity-429-quota-tdd.test.ts +++ b/tests/unit/antigravity-429-quota-tdd.test.ts @@ -74,7 +74,7 @@ test("TDD S3: checkFallbackError extracts retry hint for oauth providers even if 429, errorText, 0, - "gemini-3.5-flash", + "gemini-3.7-flash", "antigravity", // which uses oauth provider profile (useUpstreamRetryHints: false) null ); diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 82ad399215..5f0ab5c0b4 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -31,6 +31,7 @@ const RETIRED_FLASH_IDS = [ "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high", + "gemini-3.5-flash", "gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index 890509bb73..55885e3f2e 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -21,6 +21,7 @@ const RETIRED_PUBLIC_MODELS = [ "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3-flash-agent", + "gemini-3.5-flash", "gemini-3.5-flash-low", "gemini-3.5-flash-extra-low", "gemini-2.5-pro", diff --git a/tests/unit/conol-web.test.ts b/tests/unit/conol-web.test.ts index 8ed8f2b9cb..1280cc2b0d 100644 --- a/tests/unit/conol-web.test.ts +++ b/tests/unit/conol-web.test.ts @@ -14,6 +14,7 @@ import { } from "../../open-sse/executors/conol-web.ts"; import { CONOL_FALLBACK_MODELS, + CONOL_FALLBACK_MODEL_PRESETS, clampConolEffort, parseConolAgentServers, resolveConolModelSelection, @@ -27,6 +28,11 @@ import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts const SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"; describe("Conol web provider", () => { + it("routes the Flash preset multimodal path to Gemini 3.7", () => { + const flashPreset = CONOL_FALLBACK_MODEL_PRESETS.find((preset) => preset.id === "flash"); + assert.equal(flashPreset?.multimodal, "google/gemini-3.7-flash"); + }); + it("normalizes raw, full-header, JSON, and provider-data credentials", () => { assert.equal(normalizeConolCookie("token-value"), `${SESSION_COOKIE_NAME}=token-value`); assert.equal( diff --git a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts index b0fd5116dd..a03a21f7bd 100644 --- a/tests/unit/copilot-gemini-claude-route-no-responses.test.ts +++ b/tests/unit/copilot-gemini-claude-route-no-responses.test.ts @@ -55,7 +55,7 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout it("routes registered Gemini Copilot models to chat/completions", () => { const exec = new GithubExecutor(); - for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) { + for (const id of ["gemini-3.1-pro-preview", "gemini-3.7-flash"]) { assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`); } }); diff --git a/tests/unit/cursor-registry-claude-families.test.ts b/tests/unit/cursor-registry-claude-families.test.ts index 58df088b38..46a41970a0 100644 --- a/tests/unit/cursor-registry-claude-families.test.ts +++ b/tests/unit/cursor-registry-claude-families.test.ts @@ -8,6 +8,10 @@ function modelIds(): Set { return new Set(cursorProvider.models.map((m) => m.id)); } +test("cursor registry excludes retired Gemini 3.5 Flash", () => { + assert.equal(modelIds().has("gemini-3.5-flash"), false); +}); + test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => { const ids = modelIds(); for (const effort of EFFORTS) { diff --git a/tests/unit/executor-promptql.test.ts b/tests/unit/executor-promptql.test.ts index 07ebfb94c6..97f52dcdc8 100644 --- a/tests/unit/executor-promptql.test.ts +++ b/tests/unit/executor-promptql.test.ts @@ -56,7 +56,7 @@ describe("PromptQl — registry consistency", () => { it("registers a model catalog via getModelsByProviderId", () => { const catalog = getModelsByProviderId("promptql"); assert.ok(catalog.length >= 5); - assert.ok(catalog.some((m) => m.id === "gemini-3.5-flash" || m.id.includes("gemini"))); + assert.ok(catalog.some((m) => m.id === "gemini-3.7-flash" || m.id.includes("gemini"))); assert.ok(catalog.some((m) => m.id.includes("gpt-5.6") || m.id.includes("fable"))); }); }); @@ -209,7 +209,10 @@ describe("PromptQl — helpers", () => { }); it("resolves model slugs and prefixes", () => { - assert.equal(models.clientFacingPromptQlModelId("promptql/gemini-3.5-flash"), "gemini-3.5-flash"); + assert.equal( + models.clientFacingPromptQlModelId("promptql/gemini-3.7-flash"), + "gemini-3.7-flash" + ); assert.equal(models.clientFacingPromptQlModelId("pql/gpt-5.6-sol"), "gpt-5.6-sol"); const r = models.resolvePromptQlModel("Claude Fable 5"); assert.ok(r); @@ -265,7 +268,7 @@ describe("PromptQlExecutor — auth / validation", () => { it("returns 401 when no token is supplied", async () => { const executor = new mod.PromptQlExecutor(); const result = await executor.execute({ - model: "gemini-3.5-flash", + model: "gemini-3.7-flash", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: {}, @@ -279,7 +282,7 @@ describe("PromptQlExecutor — auth / validation", () => { it("returns 400 when no user message is present", async () => { const executor = new mod.PromptQlExecutor(); const result = await executor.execute({ - model: "gemini-3.5-flash", + model: "gemini-3.7-flash", body: { messages: [{ role: "assistant", content: "hi" }] }, stream: false, credentials: { apiKey: sampleJwt }, @@ -614,7 +617,7 @@ describe("PromptQlExecutor — mocked GraphQL turn", () => { try { const executor = new mod.PromptQlExecutor(); const result = await executor.execute({ - model: "gemini-3.5-flash", + model: "gemini-3.7-flash", body: { messages: [{ role: "user", content: "ping" }] }, stream: false, credentials: { apiKey: sampleJwt }, @@ -628,7 +631,7 @@ describe("PromptQlExecutor — mocked GraphQL turn", () => { }; assert.equal(json.choices[0]!.message.content, "HELLO-PQL"); assert.equal(json.promptql_thread_id, "thread-1"); - assert.equal(json.model, "gemini-3.5-flash"); + assert.equal(json.model, "gemini-3.7-flash"); assert.ok(call >= 2); assert.equal(result.response.headers.get("X-PromptQL-Thread-Id"), "thread-1"); } finally { diff --git a/tests/unit/gemini-3-5-flash-thinking.test.ts b/tests/unit/gemini-3-5-flash-thinking.test.ts deleted file mode 100644 index 80636a0b03..0000000000 --- a/tests/unit/gemini-3-5-flash-thinking.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Regression test for #10286: gemini-3.5-flash was incorrectly marked -// supportsThinking:false, causing a spurious pre-provider HTTP 400 for any -// request with reasoning_effort set, even though the base Google AI Studio -// model supports reasoning (it has an effort-tier alias gemini-3.5-flash-high). -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-repro-10286-")); -process.env.DATA_DIR = TEST_DATA_DIR; -process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-repro-10286-secret"; - -const caps = await import("../../src/lib/modelCapabilities.ts"); -const core = await import("../../src/lib/db/core.ts"); -const rulesDb = await import("../../src/lib/db/reasoningRoutingRules.ts"); -const policy = await import("../../src/lib/reasoningRouting/policy.ts"); - -async function resetStorage() { - core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); - rulesDb.invalidateReasoningRoutingRuleCache(); -} - -function ruleInput(patch: Record = {}) { - return { - name: "Enable thinking on gemini-3.5-flash", - description: "", - scope: "global", - apiKeyId: null, - comboId: null, - connectionId: null, - modelPattern: "gemini-3.5-flash", - sourceEffort: "any", - requestTags: [], - tagMatchMode: "any", - effortMode: "inherit", - targetEffort: null, - targetKind: "keep", - targetModel: null, - targetComboId: null, - budgetAction: "preserve", - budgetTokens: null, - priority: 0, - enabled: true, - ...patch, - }; -} - -test.beforeEach(resetStorage); -test.after(async () => { - await resetStorage(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); -}); - -test("gemini-3.5-flash (AI Studio provider) resolves as thinking-capable", () => { - const resolved = caps.getResolvedModelCapabilities({ - provider: "gemini", - model: "gemini-3.5-flash", - }); - assert.equal(resolved.supportsThinking, true); -}); - -test("reasoning_effort 'high' on gemini-3.5-flash is NOT rejected by routing policy", async () => { - await rulesDb.createReasoningRoutingRule(ruleInput()); - const decision = await policy.resolveReasoningRoutingRule({ - sourceModel: "gemini/gemini-3.5-flash", - sourceModelAliases: ["gemini-3.5-flash"], - sourceEffort: "high", - hasReasoningSignal: true, - }); - assert.ok(decision, "a matching rule must produce a decision"); - assert.equal(decision.capability, "supported"); -}); diff --git a/tests/unit/gemini-codex-encrypted-tool-schema.test.ts b/tests/unit/gemini-codex-encrypted-tool-schema.test.ts index 6d76a0ac84..58498ced06 100644 --- a/tests/unit/gemini-codex-encrypted-tool-schema.test.ts +++ b/tests/unit/gemini-codex-encrypted-tool-schema.test.ts @@ -71,7 +71,7 @@ test("OpenAI -> Gemini request strips encrypted from Codex collaboration tool pa ], }; - const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as { + const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as { tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>; }; diff --git a/tests/unit/gemini-models-parser.test.ts b/tests/unit/gemini-models-parser.test.ts index 6d3dfbd752..0e07a6f7a1 100644 --- a/tests/unit/gemini-models-parser.test.ts +++ b/tests/unit/gemini-models-parser.test.ts @@ -14,6 +14,16 @@ const SAMPLE = { supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"], thinking: true, }, + { + name: "models/gemini-3.5-flash", + displayName: "Gemini 3.5 Flash", + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-3.5-flash-lite", + displayName: "Gemini 3.5 Flash Lite", + supportedGenerationMethods: ["generateContent"], + }, { name: "models/gemini-3-pro-image-preview", displayName: "Gemini 3 Pro Image Preview", @@ -48,6 +58,12 @@ test("parseGeminiModelsList strips the models/ prefix and maps display name", () assert.deepEqual(flash!.supportedEndpoints, ["chat"]); }); +test("parseGeminiModelsList excludes retired Gemini 3.5 Flash but keeps Flash Lite", () => { + const ids = parseGeminiModelsList(SAMPLE).map((model) => model.id); + assert.equal(ids.includes("gemini-3.5-flash"), false); + assert.equal(ids.includes("gemini-3.5-flash-lite"), true); +}); + test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => { const models = parseGeminiModelsList(SAMPLE); const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview"); diff --git a/tests/unit/gemini-strict-tool-schema.test.ts b/tests/unit/gemini-strict-tool-schema.test.ts index 9745353527..0aaac46bf0 100644 --- a/tests/unit/gemini-strict-tool-schema.test.ts +++ b/tests/unit/gemini-strict-tool-schema.test.ts @@ -56,7 +56,7 @@ test("OpenAI -> Gemini request strips strict from OpenAI-style function tool par ], }; - const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as { + const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as { tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>; }; diff --git a/tests/unit/ghe-copilot.test.ts b/tests/unit/ghe-copilot.test.ts index 333c5b7f50..d71a1fd444 100644 --- a/tests/unit/ghe-copilot.test.ts +++ b/tests/unit/ghe-copilot.test.ts @@ -99,7 +99,7 @@ test("buildUrl uses chat/completions endpoint for gemini models", () => { }; // Gemini has no native shim on Copilot — it stays on /chat/completions. assert.strictEqual( - executor.buildUrl("gemini-3.5-flash", true, 0, credentials), + executor.buildUrl("gemini-3.7-flash", true, 0, credentials), "https://ghe.company.com/chat/completions" ); }); diff --git a/tests/unit/managed-model-import.test.ts b/tests/unit/managed-model-import.test.ts index 24f5a6554c..e6e3bc04b7 100644 --- a/tests/unit/managed-model-import.test.ts +++ b/tests/unit/managed-model-import.test.ts @@ -279,6 +279,7 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async ( mode: "sync", fetchedModels: [ { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }, + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash High" }, { id: "custom-antigravity-model", name: "Custom Antigravity Model" }, ], }); @@ -289,8 +290,13 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async ( const mitmMappings = await modelsDb.getMitmAlias("antigravity"); console.log("MITM MAPPINGS IN TEST:", mitmMappings); - // Should contain standard mapping - assert.equal(mitmMappings["gemini-3.5-flash"], "antigravity/gemini-3.5-flash"); + // Retired models reported by upstream must not be imported or mapped. + assert.equal(mitmMappings["gemini-3.5-flash"], undefined); + assert.equal( + models.some((model) => model.id === "gemini-3.5-flash"), + false + ); + assert.equal(mitmMappings["gemini-3.7-flash-high"], "antigravity/gemini-3.7-flash-high"); assert.equal(mitmMappings["custom-antigravity-model"], "antigravity/custom-antigravity-model"); // Removed Antigravity 2.0 preview/agent aliases must not be reintroduced. diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 80d4efd7aa..ce75e9342a 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -154,21 +154,14 @@ test("unknown models keep maxOutputTokens null instead of using a generic defaul ); }); -test("provider-neutral Gemini 3.5 tier IDs retain their non-thinking capabilities", () => { +test("retired Gemini 3.5 Flash IDs have no provider-neutral model specs", () => { for (const modelId of [ + "gemini-3.5-flash", "gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", ]) { - const spec = MODEL_SPECS[modelId]; - assert.ok(spec, `missing exact MODEL_SPECS entry for ${modelId}`); - const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId); - assert.equal(capabilities.contextWindow, 1048576, modelId); - assert.equal(capabilities.maxOutputTokens, 65536, modelId); - // These ids encode the upstream reasoning tier and do not accept a client-supplied effort. - assert.equal(capabilities.supportsThinking, false, modelId); - assert.equal(capabilities.supportsTools, true, modelId); - assert.equal(capabilities.supportsVision, true, modelId); + assert.equal(MODEL_SPECS[modelId], undefined, modelId); } }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 7c1887f8a6..7c2ed395d9 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -702,6 +702,7 @@ test("v1 models catalog exposes current Antigravity aliases without retired mode assert.equal(ids.has("antigravity/gemini-3.6-flash-high"), false); assert.equal(ids.has("antigravity/gemini-3.6-flash-medium"), false); assert.equal(ids.has("antigravity/gemini-3.6-flash-low"), false); + assert.equal(ids.has("antigravity/gemini-3.5-flash"), false); assert.equal(ids.has("antigravity/gemini-3.5-flash-extra-low"), false); assert.equal(ids.has("antigravity/gemini-3.5-flash-low"), false); assert.equal(ids.has("antigravity/gemini-3-flash-agent"), false); diff --git a/tests/unit/omniroute-response-meta.test.ts b/tests/unit/omniroute-response-meta.test.ts index 0b7bbd8e2b..50b13278b2 100644 --- a/tests/unit/omniroute-response-meta.test.ts +++ b/tests/unit/omniroute-response-meta.test.ts @@ -60,7 +60,7 @@ test("buildOmniRouteResponseMetaHeaders keeps ASCII model header values unchange }); test("buildOmniRouteResponseMetaHeaders percent-encodes non-ASCII model header values", () => { - const model = "free-mix/[假流式]gemini-3.5-flash"; + const model = "free-mix/[假流式]gemini-3.7-flash"; const headers = buildOmniRouteResponseMetaHeaders({ provider: "openai", model, diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index 5074bcd04e..3298a057b3 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -288,7 +288,7 @@ test("hidden provider models are filtered from per-model quota rows", () => { }); const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", { models: [{ id: "antigravity/gpt-oss-120b-medium", isHidden: true }], - modelCompatOverrides: [{ id: "gemini-3.5-flash", isHidden: true }], + modelCompatOverrides: [{ id: "gemini-3.7-flash", isHidden: true }], }); const visible = providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden); diff --git a/tests/unit/sse-parser.test.ts b/tests/unit/sse-parser.test.ts index 36fcd7deda..5c8bfe627a 100644 --- a/tests/unit/sse-parser.test.ts +++ b/tests/unit/sse-parser.test.ts @@ -394,7 +394,7 @@ test("parseSSEToGeminiResponse extracts tool calls from textual format", () => { })}`, ].join("\n"); - const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.5-flash-low"); + const parsed = parseSSEToGeminiResponse(rawSSE, "gemini-3.7-flash-low"); assert.ok(parsed); assert.equal(parsed.choices[0].finish_reason, "tool_calls"); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 046e793b61..7ef51687d1 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -232,14 +232,14 @@ test("createSSEStream passthrough converts textual tool-call content into struct id: "chatcmpl_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { role: "assistant", content: toolText } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], })}\n\n`, ], @@ -247,7 +247,7 @@ test("createSSEStream passthrough converts textual tool-call content into struct mode: "passthrough", sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect db" }], }, @@ -284,21 +284,21 @@ test("createSSEStream passthrough converts split textual tool-call content at co id: "chatcmpl_split_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_split_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { content: chunks[1] } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_split_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], })}\n\n`, ], @@ -306,7 +306,7 @@ test("createSSEStream passthrough converts split textual tool-call content at co mode: "passthrough", sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect db" }] }, onComplete(payload) { onCompletePayload = payload; @@ -340,28 +340,28 @@ test("createSSEStream passthrough handles textual tool-call content split inside id: "chatcmpl_split_prefix_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { role: "assistant", content: chunks[0] } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_split_prefix_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { content: chunks[1] } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_split_prefix_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { content: chunks[2] } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_split_prefix_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], })}\n\n`, ], @@ -369,7 +369,7 @@ test("createSSEStream passthrough handles textual tool-call content split inside mode: "passthrough", sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect db" }] }, onComplete(payload) { onCompletePayload = payload; @@ -515,14 +515,14 @@ Arguments: {"path":"/opt/OmniRoute/src","target":"files"}`; id: "chatcmpl_unknown_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { role: "assistant", content: toolText } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_unknown_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], })}\n\n`, ], @@ -530,7 +530,7 @@ Arguments: {"path":"/opt/OmniRoute/src","target":"files"}`; mode: "passthrough", sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect files" }], tools: [ @@ -561,14 +561,14 @@ test("createSSEStream passthrough suppresses malformed textual tool-call content id: "chatcmpl_malformed_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: { role: "assistant", content: malformedToolText } }], })}\n\n`, `data: ${JSON.stringify({ id: "chatcmpl_malformed_textual_tool", object: "chat.completion.chunk", created: 1, - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], })}\n\n`, ], @@ -576,7 +576,7 @@ test("createSSEStream passthrough suppresses malformed textual tool-call content mode: "passthrough", sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect db" }] }, onComplete(payload) { onCompletePayload = payload; @@ -617,7 +617,7 @@ test("createSSEStream suppresses malformed compact textual tool-call content", a targetFormat: FORMATS.ANTIGRAVITY, sourceFormat: FORMATS.OPENAI, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { messages: [{ role: "user", content: "inspect files" }] }, onComplete(payload) { onCompletePayload = payload; @@ -1024,7 +1024,7 @@ Arguments: {"command":"systemctl status omniroute"}`; response: { id: "resp_textual_tool", object: "response", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", status: "completed", output: [], usage: { input_tokens: 10, output_tokens: 4, total_tokens: 14 }, @@ -1038,7 +1038,7 @@ Arguments: {"command":"systemctl status omniroute"}`; sourceFormat: FORMATS.OPENAI_RESPONSES, clientResponseFormat: FORMATS.OPENAI_RESPONSES, provider: "antigravity", - model: "antigravity/gemini-3.5-flash-low", + model: "antigravity/gemini-3.7-flash-low", body: { input: "check service", tools: [{ type: "function", name: "terminal", parameters: { type: "object" } }], diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 6c44ebcf45..600cd44229 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -30,6 +30,7 @@ test("T28: antigravity static catalog exposes only callable Gemini tier IDs", () assert.ok(!staticIds.includes("gemini-3.6-flash-high")); assert.ok(!staticIds.includes("gemini-3.6-flash-medium")); assert.ok(!staticIds.includes("gemini-3.6-flash-low")); + assert.ok(!staticIds.includes("gemini-3.5-flash")); assert.ok(!staticIds.includes("gemini-3.5-flash-extra-low")); assert.ok(!staticIds.includes("gemini-3.5-flash-low")); assert.ok(!staticIds.includes("gemini-3-flash-agent")); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index cf81c24de1..2111f23f64 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -607,7 +607,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls and keeps response context", () => { const result = openaiToAntigravityRequest( - "gemini-3.5-flash-low", + "gemini-3.7-flash-low", { messages: [ { role: "user", content: "Update todo" }, @@ -686,7 +686,7 @@ test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls an test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as context", () => { const result = openaiToAntigravityRequest( - "gemini-3.5-flash-low", + "gemini-3.7-flash-low", { messages: [ { role: "user", content: "Inspect OmniRoute config" }, @@ -747,7 +747,7 @@ test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_AG_SIGNED_XYZ"); const result = openaiToAntigravityRequest( - "gemini-3.5-flash-low", + "gemini-3.7-flash-low", { messages: [ { role: "user", content: "Read status" }, @@ -787,7 +787,7 @@ test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", test("OpenAI -> Antigravity escapes signature-less tool response context content", () => { const result = openaiToAntigravityRequest( - "gemini-3.5-flash-low", + "gemini-3.7-flash-low", { messages: [ { role: "user", content: "Inspect previous output" }, diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 108eb8a62b..0b5425562c 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -346,7 +346,7 @@ test("Gemini stream: converts textual Tool call block to structured tool_calls", const result = geminiToOpenAIResponse( { responseId: "resp-textual-tool", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -386,7 +386,7 @@ test("Gemini stream: routes textual reasoning tags to reasoning_content before t const result = geminiToOpenAIResponse( { responseId: "resp-textual-thought-tool", - modelVersion: "gemini-3.5-flash-high", + modelVersion: "gemini-3.7-flash-high", candidates: [ { content: { @@ -431,7 +431,7 @@ test("Gemini stream: keeps textual reasoning hidden across split chunks", () => const first = geminiToOpenAIResponse( { responseId: "resp-split-thought", - modelVersion: "gemini-3.5-flash-high", + modelVersion: "gemini-3.7-flash-high", candidates: [{ content: { parts: [{ text: "§54§ const fourth = geminiToOpenAIResponse( { responseId: "resp-split-thought", - modelVersion: "gemini-3.5-flash-high", + modelVersion: "gemini-3.7-flash-high", candidates: [{ content: { parts: [{ text: "ught>Visible answer" }] } }], }, state @@ -498,7 +498,7 @@ test("Gemini stream: converts prefixed textual Tool call block with zero-width c const result = geminiToOpenAIResponse( { responseId: "resp-textual-tool-prefixed", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -612,7 +612,7 @@ test("Gemini stream: unwraps native functionCall args when emitted as JSON strin const result = geminiToOpenAIResponse( { responseId: "resp-native-tool-json-string", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -648,7 +648,7 @@ test("Gemini stream: converts JSON-string encoded textual Tool call arguments", const result = geminiToOpenAIResponse( { responseId: "resp-textual-tool-json-string", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -685,7 +685,7 @@ test("Gemini stream: suppresses malformed textual Tool call marker", () => { const result = geminiToOpenAIResponse( { responseId: "resp-textual-tool-malformed", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -717,7 +717,7 @@ test("Gemini stream: handles textual Tool call block split across chunks", () => const state = createStreamingState(); const chunk1 = { responseId: "resp-split", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -732,7 +732,7 @@ test("Gemini stream: handles textual Tool call block split across chunks", () => }; const chunk2 = { responseId: "resp-split", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -768,7 +768,7 @@ test("Gemini stream: does not swallow false positive textual tool call in backti const state = createStreamingState(); const chunk1 = { responseId: "resp-false-positive", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -783,7 +783,7 @@ test("Gemini stream: does not swallow false positive textual tool call in backti }; const chunk2 = { responseId: "resp-false-positive", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -817,7 +817,7 @@ test("Gemini stream: does not swallow terminated trailing false positive textual const state = createStreamingState(); const chunk1 = { responseId: "resp-false-positive-terminated", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -842,7 +842,7 @@ test("Gemini stream: flushes left part before textual tool call candidate and fl const state = createStreamingState() as any; const chunk1 = { responseId: "resp-test-flush-left", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -884,7 +884,7 @@ test("Gemini stream: splits mid-stream partial candidate but preserves tool call const state = createStreamingState() as any; const chunk1 = { responseId: "resp-test-split-candidate", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -932,7 +932,7 @@ test("Gemini stream: index mismatch regression test with zero-width characters i const result = geminiToOpenAIResponse( { responseId: "resp-textual-tool-index-mismatch", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -964,7 +964,7 @@ test("Gemini stream: partial tool call with (empty) prefix check at chunk end do const state = createStreamingState(); const chunk1 = { responseId: "resp-empty-leak", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -1011,7 +1011,7 @@ test("Gemini stream: parses textual tool call that starts in a subsequent chunk const state = createStreamingState() as any; const chunk1 = { responseId: "resp-test-after-prose", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -1060,7 +1060,7 @@ test("Gemini stream: checks lastParen before lastBracket when identifying partia // Имитируем чанк, который кончается на частичный "(empty)[Tool call:" маркер, например "(em" const chunk1 = { responseId: "resp-test-empty-partial", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { @@ -1187,7 +1187,7 @@ test("Gemini stream: partial textual tool call survives a reasoning-only chunk", geminiToOpenAIResponse( { responseId: "resp-interleave", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [ { content: { parts: [{ text: '[Tool call: terminal]\nArguments: {"command":"ls' }] } }, ], @@ -1200,7 +1200,7 @@ test("Gemini stream: partial textual tool call survives a reasoning-only chunk", geminiToOpenAIResponse( { responseId: "resp-interleave", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [{ content: { parts: [{ text: "pondering" }] } }], }, state @@ -1221,7 +1221,7 @@ test("Gemini stream: partial textual tool call survives a reasoning-only chunk", geminiToOpenAIResponse( { responseId: "resp-interleave", - modelVersion: "gemini-3.5-flash-low", + modelVersion: "gemini-3.7-flash-low", candidates: [{ content: { parts: [{ text: '"}' }] }, finishReason: "STOP" }], }, state From 6158c9aeeca8d07fce3d037e90a30434f9c63ffc Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sun, 23 Aug 2026 17:35:38 -0300 Subject: [PATCH 9/9] fix: drop stray conflict leftover in managedModelImport (#11259 cherry-pick integration) --- src/lib/providerModels/managedModelImport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 787edd10e1..172ba06479 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -272,7 +272,6 @@ export async function importManagedModels({ // isSelfHostedChatProvider, so dropping it globally leaked image/video models // into OpenAI chat selections (#11271). const selectableModels = filterSelectableModels(providerId, providerFilteredModels); - ); const discoveredModels = isSelfHostedChatProvider(providerId) ? selectableModels : filterChatSelectableModels(providerId, selectableModels);