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/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/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/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/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/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/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/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/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/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/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/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)/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/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/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 c86a6abb95..b1e4a50b69 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"; @@ -106,6 +103,7 @@ import { mergeSpecialtyCatalogIntoLiveModels, buildOptionalBearerHeaders, buildNamedOpenAiStyleHeaders, + enrichOllamaLocalModels, } from "./discovery/helpers"; import { fetchAntigravityDiscoveryModelsCached, @@ -792,6 +790,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! } @@ -1803,7 +1803,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; @@ -1849,6 +1849,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/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/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/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/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 dc58c620f2..172ba06479 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -20,9 +20,12 @@ 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"; type JsonRecord = Record; @@ -253,10 +256,25 @@ export async function importManagedModels({ const previousSyncedAvailableModels = previousSyncedAvailableModelsInput ?? (await getSyncedAvailableModelsForConnection(providerId, connectionId)); - const discoveredModels = filterChatSelectableModels( - providerId, - filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels, providerId)) - ); + 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, providerFilteredModels); + 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/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/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} ) : ( = { 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/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/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/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" + ); +}); 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/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 11161c231d..8778f3a787 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/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; +} 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}`); + } + }); +}); 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/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"); +}); 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/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"); +}); 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/security/live-server-allowlist.test.ts b/tests/unit/security/live-server-allowlist.test.ts index bd837118a0..fbd4fd9be8 100644 --- a/tests/unit/security/live-server-allowlist.test.ts +++ b/tests/unit/security/live-server-allowlist.test.ts @@ -130,6 +130,9 @@ describe("isOriginAllowed", () => { 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", () => { 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 e4f8eb9ed8..34dcfcadf8 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 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"); +});