mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
Compare commits
7 Commits
fix/11291-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc60c3790 | ||
|
|
9df1781065 | ||
|
|
4f9038f470 | ||
|
|
17f5e4e0e9 | ||
|
|
950855e168 | ||
|
|
5c03cfeedc | ||
|
|
c2df757610 |
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -2,11 +2,11 @@ name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -104,10 +104,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
|
||||
// plugin provider, so `claude-primary` here carries no provider prefix.
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -162,15 +159,11 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
|
||||
// "claude-primary") must also stay unprefixed — OpenCode looks up
|
||||
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
|
||||
const claude = out["claude-primary"];
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
|
||||
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
|
||||
// resolves the combo id directly.
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
|
||||
@@ -64,7 +63,15 @@ export function extendComboSuggest(combo) {
|
||||
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
|
||||
top: opts.top,
|
||||
};
|
||||
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
|
||||
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 candidates = data.candidates ?? data;
|
||||
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
|
||||
rank: i + 1,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -79,17 +78,18 @@ async function restComboStats(period) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args, 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;
|
||||
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();
|
||||
}
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function confirm(q) {
|
||||
|
||||
@@ -61,12 +61,27 @@ export function registerMcp(program) {
|
||||
? JSON.parse(argsPositional)
|
||||
: {};
|
||||
|
||||
const exitCode = await runMcpCallCommand(tool, args, {
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
}, globalOpts);
|
||||
if (opts.stream) {
|
||||
await runMcpStream(tool, args, globalOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
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);
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -84,132 +99,112 @@ 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 <s>", 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 <name>")
|
||||
.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 <name>")
|
||||
.description(t("mcp.tools.schema.description"))
|
||||
.option("--io <kind>", 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 <n>", 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 <p>", 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());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {} } = {}) {
|
||||
async function runMcpStream(tool, args, globalOpts) {
|
||||
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
|
||||
const apiKey = globalOpts.apiKey ?? "";
|
||||
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, {
|
||||
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
|
||||
method: "POST",
|
||||
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" },
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ name: tool, arguments: args }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const text = await initRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`HTTP ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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 reader = res.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");
|
||||
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");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
|
||||
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
|
||||
}
|
||||
|
||||
export async function runMcpStatusCommand(opts = {}) {
|
||||
@@ -238,8 +233,7 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
}
|
||||
|
||||
const transport = status.transport || "stdio";
|
||||
const online = status.online ?? status.running;
|
||||
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
||||
if (status.scopes?.length) {
|
||||
console.log(" Scopes:");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -9,7 +8,15 @@ function fmtTs(v) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args) {
|
||||
return mcpCallTool(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();
|
||||
}
|
||||
|
||||
const proxySchema = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -167,7 +166,14 @@ export function registerResilience(program) {
|
||||
])
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
|
||||
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);
|
||||
}
|
||||
process.stdout.write(`Profile: ${name}\n`);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
||||
import { resolveServerHost } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -162,15 +162,6 @@ export async function runServe(opts = {}) {
|
||||
`);
|
||||
}
|
||||
|
||||
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
|
||||
// deliberate local-first choice, but it must be loud at startup — an operator
|
||||
// on an untrusted network learns the two escape hatches here, not after a
|
||||
// surprise quota bill.
|
||||
const exposureWarning = resolveExposureWarning();
|
||||
if (exposureWarning) {
|
||||
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
|
||||
}
|
||||
|
||||
const serverWsJs = join(APP_DIR, "server-ws.mjs");
|
||||
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -107,7 +106,14 @@ export async function runSkillsInstall(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runSkillsEnable(id, opts, cmd) {
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true });
|
||||
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);
|
||||
}
|
||||
process.stdout.write(`Enabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -116,7 +122,14 @@ export async function runSkillsDisable(id, opts, cmd) {
|
||||
const ok = await confirm(`Disable ${id}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false });
|
||||
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);
|
||||
}
|
||||
process.stdout.write(`Disabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -140,11 +153,16 @@ export async function runSkillsExecute(id, opts, cmd) {
|
||||
: opts.inputFile
|
||||
? JSON.parse(readFileSync(opts.inputFile, "utf8"))
|
||||
: {};
|
||||
const data = await mcpCallTool(
|
||||
"omniroute_skills_execute",
|
||||
{ skillId: id, input },
|
||||
{ timeout: opts.timeout ?? 30000 },
|
||||
);
|
||||
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();
|
||||
emit(data, globalOpts);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
@@ -24,34 +24,3 @@ export function resolveServerHost(
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds
|
||||
* all interfaces while the inference plane requires no credentials, so any
|
||||
* LAN peer can spend the operator's quota. That local-first posture is a
|
||||
* deliberate, documented default — but it must be LOUD at startup so an
|
||||
* operator who never read the docs still learns the two escape hatches.
|
||||
*
|
||||
* Returns the warning text when the server will listen on a non-loopback
|
||||
* interface with no API-key requirement, or null when the exposure is closed.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @param {string} [host]
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) {
|
||||
if (LOOPBACK_HOSTS.has(host)) return null;
|
||||
const requireKey = String(env.REQUIRE_API_KEY || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null;
|
||||
return (
|
||||
`SECURITY: listening on ${host} with NO API-key requirement — the inference ` +
|
||||
`plane (/v1/*) is reachable by ANY device that can route to this host, and ` +
|
||||
`requests are billed to your configured providers. This local-first default ` +
|
||||
`is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` +
|
||||
`or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **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))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(dashboard):** lead Traffic Inspector with a purpose-first header that separates "what happened" from "how it happened", so beginners can read request outcomes without drowning in protocol detail ([#11283](https://github.com/diegosouzapw/OmniRoute/pull/11283))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(dashboard):** add an Essentials sidebar preset that shows only the beginner core path (Home → Endpoints → API Keys → Providers → Health → Settings) while keeping Advanced tools reachable via Command Palette search ([#11286](https://github.com/diegosouzapw/OmniRoute/pull/11286))
|
||||
1
changelog.d/fixes/10286-gemini-3-5-flash-thinking.md
Normal file
1
changelog.d/fixes/10286-gemini-3-5-flash-thinking.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(oauth): stop treating the Kiro profile ARN as an account identity in `findKiroConnectionByIdentity()`, so a second Google/GitHub social login creates a new connection instead of overwriting the first — distinct Builder ID accounts share the same CodeWhisperer profile ARN, and the social token is not a JWT, so no e-mail was available to disambiguate them (#10815)
|
||||
@@ -1 +0,0 @@
|
||||
- **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
|
||||
@@ -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.7 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.5 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.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.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -113,7 +113,6 @@ 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",
|
||||
|
||||
@@ -179,7 +179,6 @@ 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",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"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 },
|
||||
|
||||
@@ -186,9 +186,6 @@ 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
|
||||
|
||||
@@ -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" }, ],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -219,18 +219,5 @@ 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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -27,17 +27,8 @@ 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-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" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
};
|
||||
|
||||
@@ -13,17 +13,10 @@ 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" },
|
||||
{ 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" },
|
||||
// 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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {
|
||||
getLearnedReasoningEffort,
|
||||
clampToLearned,
|
||||
REASONING_EFFORT_ORDER,
|
||||
} from "../../services/learnedReasoningEffortCaps.ts";
|
||||
|
||||
/**
|
||||
@@ -358,43 +357,6 @@ 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);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
ExecuteInput,
|
||||
@@ -15,11 +13,6 @@ import {
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
|
||||
|
||||
/** Correlation-id fallback for runtimes without crypto.randomUUID — still CSPRNG-backed. */
|
||||
function randomIdFallback(): string {
|
||||
return `${Date.now()}-${randomBytes(9).toString("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Copilot credential refresh resolves to.
|
||||
*
|
||||
@@ -336,7 +329,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-request-id":
|
||||
crypto.randomUUID?.() || randomIdFallback(),
|
||||
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
};
|
||||
|
||||
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
|
||||
@@ -345,7 +338,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
// fresh uuids. A Copilot-aware client may pin the session/task ids across a
|
||||
// conversation via its own headers — honor those when present, else mint.
|
||||
const genId = () =>
|
||||
crypto.randomUUID?.() || randomIdFallback();
|
||||
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
|
||||
headers["x-client-session-id"] =
|
||||
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();
|
||||
|
||||
@@ -31,7 +31,6 @@ import * as xSearch from "./search/xSearch.ts";
|
||||
import { freeWebSearch } from "../services/freeWebSearch.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
@@ -314,23 +313,9 @@ function getProviderSettingString(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSearchBaseUrl(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): string {
|
||||
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
|
||||
const override = getProviderSettingString(params, "baseUrl");
|
||||
if (override) {
|
||||
// GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options /
|
||||
// providerSpecificData) and flows into a plain fetch() sink — validate it
|
||||
// before any builder uses it as the server-side fetch target. Mode is
|
||||
// block-metadata (NOT public-only): the primary searxng use case is a
|
||||
// self-hosted instance on loopback/LAN, so private hosts keep working,
|
||||
// while cloud-metadata endpoints (IMDS credential theft) are rejected.
|
||||
// The catalog's own config.baseUrl is operator config and stays untouched.
|
||||
parseAndValidateNonMetadataUrl(override);
|
||||
return override.replace(/\/+$/, "");
|
||||
}
|
||||
return config.baseUrl.replace(/\/+$/, "");
|
||||
return (override || config.baseUrl).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 {
|
||||
@@ -471,13 +470,10 @@ 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).
|
||||
// #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([
|
||||
const [syncedModels, customModels] = await Promise.all([
|
||||
getSyncedAvailableModels(providerId),
|
||||
getCustomModels(providerId),
|
||||
]);
|
||||
const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw);
|
||||
const hiddenModels = hiddenModelsMap.get(providerId);
|
||||
const userVisibleIds = new Set<string>();
|
||||
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
|
||||
|
||||
@@ -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.7-flash" },
|
||||
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-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" },
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
export interface PromptQlModel {
|
||||
/** Client-facing id (model_reference slug, e.g. gemini-3.7-flash). */
|
||||
/** Client-facing id (model_reference slug, e.g. gemini-3.5-flash). */
|
||||
id: string;
|
||||
/** Friendly picker label. */
|
||||
name: string;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"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 (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("readinessTitle")}
|
||||
className="mb-4 rounded-xl border border-blue-200 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-5 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-blue-700/80 dark:text-blue-300/80">
|
||||
{t("readinessEyebrow")}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-blue-950 dark:text-blue-100">
|
||||
{t("readinessTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-blue-900/80 dark:text-blue-200/80">
|
||||
{t("readinessSubtitle")}
|
||||
</p>
|
||||
<ol className="mt-3 space-y-1.5 text-sm text-blue-900 dark:text-blue-100">
|
||||
{steps.map((label, index) => (
|
||||
<li key={label} className="flex items-center gap-2">
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-200/80 dark:bg-blue-400/20 text-xs font-semibold text-blue-800 dark:text-blue-200">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard/onboarding"
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
{t("readinessContinue")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="text-sm font-medium text-blue-800/80 hover:text-blue-950 dark:text-blue-200/80 dark:hover:text-blue-100"
|
||||
>
|
||||
{t("readinessDismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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";
|
||||
@@ -642,32 +643,38 @@ 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 <img> 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
|
||||
<img
|
||||
src={src}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
style={{ width: "auto", height: "auto", maxWidth: 32, maxHeight: 32 }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (tool.image) {
|
||||
return renderImg(tool.image);
|
||||
return (
|
||||
<Image
|
||||
src={tool.image}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.imageLight || tool.imageDark) {
|
||||
const themedSrc = isDark
|
||||
? tool.imageDark || tool.imageLight
|
||||
: tool.imageLight || tool.imageDark;
|
||||
return renderImg(themedSrc);
|
||||
return (
|
||||
<Image
|
||||
src={themedSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return (
|
||||
|
||||
@@ -521,7 +521,6 @@ export default function SidebarTab() {
|
||||
|
||||
const presetLabels: Record<SidebarPresetId, string> = {
|
||||
all: getSettingsLabel("presetAll", "All"),
|
||||
essentials: getSettingsLabel("presetEssentials", "Essentials"),
|
||||
minimal: getSettingsLabel("presetMinimal", "Minimal"),
|
||||
developer: getSettingsLabel("presetDeveloper", "Developer"),
|
||||
admin: getSettingsLabel("presetAdmin", "Admin"),
|
||||
@@ -529,10 +528,6 @@ export default function SidebarTab() {
|
||||
|
||||
const presetDescriptions: Record<SidebarPresetId, string> = {
|
||||
all: getSettingsLabel("presetAllDesc", "Show everything"),
|
||||
essentials: getSettingsLabel(
|
||||
"presetEssentialsDesc",
|
||||
"Beginner path — Advanced tools stay searchable"
|
||||
),
|
||||
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
|
||||
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
|
||||
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),
|
||||
|
||||
@@ -15,15 +15,7 @@ import { HistoricSessionBanner } from "./components/session/HistoricSessionBanne
|
||||
|
||||
const BUFFER_MAX = 1000;
|
||||
|
||||
export function TrafficInspectorPageClient({
|
||||
title,
|
||||
subtitle,
|
||||
purpose,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
purpose?: string;
|
||||
} = {}) {
|
||||
export function TrafficInspectorPageClient() {
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
|
||||
@@ -99,18 +91,6 @@ export function TrafficInspectorPageClient({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{title && (
|
||||
<div className="shrink-0 px-4 pt-4 pb-2">
|
||||
<h1 className="text-2xl font-bold text-text-main">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-text-muted mt-1 max-w-2xl">{subtitle}</p>
|
||||
)}
|
||||
{purpose && (
|
||||
<p className="text-xs text-text-muted mt-2 max-w-2xl italic">{purpose}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Capture modes toolbar */}
|
||||
<div className="shrink-0 px-4 pt-4 pb-2">
|
||||
<CaptureModesToolbar customHostCount={0} />
|
||||
|
||||
@@ -9,7 +9,6 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TrafficInspectorPage() {
|
||||
const t = await getTranslations("sidebar");
|
||||
return <TrafficInspectorPageClient title={t("trafficInspector")} subtitle={t("trafficInspectorSubtitle")} purpose={t("trafficInspectorPurpose")} />;
|
||||
export default function TrafficInspectorPage() {
|
||||
return <TrafficInspectorPageClient />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import HomePageClient from "../dashboard/HomePageClient";
|
||||
@@ -6,18 +7,19 @@ 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 && <BootstrapBanner />}
|
||||
<FirstRunReadinessCard setupComplete={Boolean(settings.setupComplete)} />
|
||||
<KimiSponsorBanner />
|
||||
<CheaperInferenceSponsorBanner />
|
||||
<VscodeCopilotBanner />
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
* Auth: Bearer token via Authorization header
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTaskManager } from "@/lib/a2a/taskManager";
|
||||
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
|
||||
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
|
||||
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
|
||||
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
|
||||
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
|
||||
@@ -53,7 +55,7 @@ function buildV1Task(
|
||||
? result.artifacts
|
||||
.map((a) =>
|
||||
a && typeof a === "object" && typeof (a as { content?: unknown }).content === "string"
|
||||
? (a as { content: string }).content
|
||||
? ((a as { content: string }).content)
|
||||
: ""
|
||||
)
|
||||
.filter((s) => s.length > 0)
|
||||
@@ -122,13 +124,39 @@ function toMessageArray(raw: unknown): A2AMessage[] | null {
|
||||
|
||||
// ============ Auth ============
|
||||
|
||||
/**
|
||||
* Constant-time comparison of the presented bearer token against the configured
|
||||
* key. A plain `===` short-circuits on the first differing byte, leaking the
|
||||
* length of the shared prefix through response timing; `timingSafeEqual` does
|
||||
* not. It requires equal-length buffers, so mismatched lengths are rejected up
|
||||
* front (the length itself is not secret).
|
||||
*/
|
||||
function tokensMatch(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
async function authenticate(req: NextRequest): Promise<boolean> {
|
||||
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
|
||||
// pipeline enforces for /v1 never ran here — the route accepted every caller
|
||||
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
|
||||
// (GHSA-v54m-6rm3-p565). The shared helper applies the same posture on both
|
||||
// the JSON-RPC and the REST task surfaces (GHSA-jcm5-6wpp-wjj8).
|
||||
return authenticateA2ARequest(req);
|
||||
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
|
||||
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
|
||||
// A2A key; otherwise stay keyless (the same local-first default as /v1).
|
||||
const apiKey = extractApiKey(req);
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
return apiKey ? await isValidApiKey(apiKey) : false;
|
||||
}
|
||||
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (configuredKey) {
|
||||
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
|
||||
}
|
||||
|
||||
// No API key required and none configured — allow (keyless local-first).
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -185,9 +213,6 @@ export async function POST(req: NextRequest) {
|
||||
if (disabledResponse) return disabledResponse;
|
||||
|
||||
const tm = getTaskManager();
|
||||
// GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's
|
||||
// owner id (hashed API key; undefined under the keyless local-first posture).
|
||||
const callerOwner = resolveA2AOwner(req);
|
||||
|
||||
// A2A 1.0 method-name compatibility (SendMessage → message/send, etc.)
|
||||
const isV1Method = method in V1_METHOD_ALIASES;
|
||||
@@ -211,7 +236,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
try {
|
||||
tm.updateTask(task.id, "working");
|
||||
const result = await handler(task);
|
||||
@@ -277,7 +302,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
tm.updateTask(task.id, "working");
|
||||
|
||||
const stream = createA2AStream(
|
||||
@@ -298,7 +323,7 @@ export async function POST(req: NextRequest) {
|
||||
const taskId = params?.taskId || params?.id;
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
const task = tm.getTask(taskId, callerOwner);
|
||||
const task = tm.getTask(taskId);
|
||||
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
|
||||
|
||||
return jsonRpcResult(id, { task });
|
||||
@@ -310,7 +335,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
try {
|
||||
const task = tm.cancelTask(taskId, callerOwner);
|
||||
const task = tm.cancelTask(taskId);
|
||||
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Shared authorization for the REST A2A task routes (GHSA-jcm5-6wpp-wjj8).
|
||||
*
|
||||
* Dual audience: the dashboard calls these routes with a management session,
|
||||
* A2A clients with an inference API key. Posture matrix:
|
||||
*
|
||||
* - REQUIRE_API_KEY=true: a valid OmniRoute key is mandatory (the same
|
||||
* posture the /v1 inference plane enforces); a management session also
|
||||
* passes (dashboard), via alwaysRequireAuth so requireLogin=false cannot
|
||||
* bypass it.
|
||||
* - otherwise + requireLogin=true: management session, or a valid key.
|
||||
* - otherwise + requireLogin=false (local-first default): open, by design.
|
||||
*
|
||||
* Callers authenticated by key are owner-scoped — another principal's tasks
|
||||
* answer as if they did not exist. Management/operator view sees all tasks.
|
||||
*/
|
||||
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { resolveA2AOwner } from "@/lib/a2a/authenticate";
|
||||
|
||||
export interface A2ARestAuth {
|
||||
/** Owner scope for task reads/mutations; undefined = operator view (all tasks). */
|
||||
owner: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: the failure branch is whatever requireManagementAuth returns — today a
|
||||
* plain `Response` from createErrorResponse(), NOT a NextResponse. Callers must
|
||||
* test with `instanceof Response` (NextResponse extends Response), never
|
||||
* `instanceof NextResponse`, or the 401 silently falls through to the handler.
|
||||
*/
|
||||
export async function authorizeA2ATaskRoute(request: Request): Promise<A2ARestAuth | Response> {
|
||||
const apiKey = extractApiKey(request);
|
||||
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
|
||||
const managementError = await requireManagementAuth(request, {
|
||||
invalidApiKeyStatus: 401,
|
||||
alwaysRequireAuth: true,
|
||||
});
|
||||
if (managementError === null) return { owner: undefined };
|
||||
return managementError;
|
||||
}
|
||||
|
||||
const managementError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (managementError === null) return { owner: undefined };
|
||||
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
|
||||
return managementError;
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTaskManager } from "@/lib/a2a/taskManager";
|
||||
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all. The owner check
|
||||
// happens inside cancelTask: another principal's task throws the same
|
||||
// "not found" a missing one would (no existence oracle).
|
||||
const auth = await authorizeA2ATaskRoute(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.cancelTask(id, auth.owner);
|
||||
const task = tm.cancelTask(id);
|
||||
return NextResponse.json({ task: { id: task.id, state: task.state } });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to cancel A2A task"
|
||||
);
|
||||
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
|
||||
const status = message.includes("not found") ? 404 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTaskManager } from "@/lib/a2a/taskManager";
|
||||
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all — open regardless
|
||||
// of configuration. Another principal's task answers 404, same as a missing
|
||||
// one, so an IDOR probe cannot tell the two apart.
|
||||
const auth = await authorizeA2ATaskRoute(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.getTask(id, auth.owner);
|
||||
const task = tm.getTask(id);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to load A2A task"
|
||||
),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
const message = error instanceof Error ? error.message : "Failed to load A2A task";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
|
||||
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
|
||||
import { createConductorTask } from "@/lib/conductor/hubProxy";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
|
||||
@@ -23,11 +22,6 @@ function parseIntParam(value: string | null, fallback: number): number {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// GHSA-jcm5-6wpp-wjj8: the list route had no auth call at all. Management
|
||||
// (or the keyless posture) sees every task; a bare API key must be valid
|
||||
// and is owner-scoped.
|
||||
const auth = await authorizeA2ATaskRoute(request);
|
||||
if (auth instanceof Response) return auth;
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const stateParam = searchParams.get("state");
|
||||
@@ -42,7 +36,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const tm = getTaskManager();
|
||||
const total = tm.countTasks({ state, skill });
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner);
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset });
|
||||
|
||||
return NextResponse.json({
|
||||
tasks,
|
||||
@@ -110,10 +104,7 @@ export function authenticateA2A(request: Request): boolean {
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!authenticateA2A(request)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized: missing or invalid API key" },
|
||||
{ status: 401 }
|
||||
);
|
||||
return NextResponse.json({ error: "Unauthorized: missing or invalid API key" }, { status: 401 });
|
||||
}
|
||||
const settings = await getSettings();
|
||||
if (settings.a2aEnabled !== true) {
|
||||
@@ -131,18 +122,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const parsed = delegationSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, { status: 400 });
|
||||
}
|
||||
const { skill, messages, metadata } = parsed.data;
|
||||
if (skill !== "conductor" && !skill.startsWith("conductor-cli-")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)",
|
||||
},
|
||||
{ error: "Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -153,9 +138,7 @@ export async function POST(request: Request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const prompt =
|
||||
[...messages].reverse().find((m) => m.role === "user")?.content ??
|
||||
messages[messages.length - 1].content;
|
||||
const prompt = [...messages].reverse().find((m) => m.role === "user")?.content ?? messages[messages.length - 1].content;
|
||||
|
||||
const created = await createConductorTask({
|
||||
repoUrl: conductor.repo.url,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
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<string, unknown>;
|
||||
|
||||
@@ -108,35 +102,3 @@ 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<JsonRecord[]> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +85,10 @@ import {
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion";
|
||||
import { getAdobeModels } from "./adobeFireflyDiscovery";
|
||||
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
|
||||
import {
|
||||
parseGeminiModelsList,
|
||||
type GeminiDiscoveryModel,
|
||||
} from "@/lib/providerModels/geminiModelsParser";
|
||||
import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
|
||||
@@ -105,7 +108,6 @@ import {
|
||||
mergeSpecialtyCatalogIntoLiveModels,
|
||||
buildOptionalBearerHeaders,
|
||||
buildNamedOpenAiStyleHeaders,
|
||||
enrichOllamaLocalModels,
|
||||
} from "./discovery/helpers";
|
||||
import {
|
||||
fetchAntigravityDiscoveryModelsCached,
|
||||
@@ -792,8 +794,6 @@ 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!
|
||||
}
|
||||
|
||||
@@ -1857,7 +1857,7 @@ export async function GET(
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
|
||||
const allModels: any[] = [];
|
||||
const allModels: GeminiDiscoveryModel[] = [];
|
||||
let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 20;
|
||||
@@ -1903,60 +1903,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ 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";
|
||||
@@ -149,16 +145,6 @@ 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) {
|
||||
@@ -245,8 +231,9 @@ async function postHandler(request, context) {
|
||||
credentials = await getProviderCredentialsWithQuotaPreflight(
|
||||
provider,
|
||||
null,
|
||||
syncedEndpointRoute?.connectionIds ?? null,
|
||||
requestedModel );
|
||||
null,
|
||||
requestedModel
|
||||
);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
|
||||
@@ -1266,8 +1266,7 @@
|
||||
"agentBridge": "Agent Bridge",
|
||||
"agentBridgeSubtitle": "Intercept IDE agent traffic",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"trafficInspectorSubtitle": "Inspect request and response traffic from your apps",
|
||||
"trafficInspectorPurpose": "See exactly what your application sends to and receives from AI providers. Works with any OpenAI-compatible client.",
|
||||
"trafficInspectorSubtitle": "Monitor LLM calls + debug any HTTPS traffic",
|
||||
"cliCode": "CLI Code",
|
||||
"cliCodeSubtitle": "Code tools pointing to OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1869,16 +1868,7 @@
|
||||
"directDownloadHint": "Or download the respective installer format directly:",
|
||||
"releaseNotes": "Release Notes",
|
||||
"readMore": "Read More",
|
||||
"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"
|
||||
"noAuthLabel": "No Auth"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@@ -6710,18 +6700,6 @@
|
||||
"sidebarVisibility": "Hide sidebar items",
|
||||
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
|
||||
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",
|
||||
"presetAll": "All",
|
||||
"presetAllDesc": "Show everything",
|
||||
"presetEssentials": "Essentials",
|
||||
"presetEssentialsDesc": "Beginner path - Advanced tools stay searchable",
|
||||
"presetMinimal": "Minimal",
|
||||
"presetMinimalDesc": "Core pages only",
|
||||
"presetDeveloper": "Developer",
|
||||
"presetDeveloperDesc": "Dev & proxy tools",
|
||||
"presetAdmin": "Admin",
|
||||
"presetAdminDesc": "Monitoring & audit",
|
||||
"settingsSidebarTitle": "Sidebar Customization",
|
||||
"settingsSidebarDesc": "Choose which sidebar items to show. Essentials keeps Advanced tools searchable.",
|
||||
"hideHealthLogs": "Hide Health Check Logs",
|
||||
"hideHealthLogsDesc": "When ON, suppress [HealthCheck] messages in server console",
|
||||
"themeAccent": "Theme color",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Shared A2A authentication + caller-owner resolution (GHSA-jcm5-6wpp-wjj8).
|
||||
*
|
||||
* The JSON-RPC router (/a2a) grew its own authenticate() for GHSA-v54m, but
|
||||
* the REST task routes under /api/a2a/tasks/ had no auth call at all. Both
|
||||
* surfaces now share this single implementation so they cannot drift again:
|
||||
* same REQUIRE_API_KEY posture as /v1, same keyless local-first default, and
|
||||
* a stable owner id (hashed API key) used to scope task visibility.
|
||||
*/
|
||||
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
function tokensMatch(provided: string, expected: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request may use the A2A surface at all. Mirrors the JSON-RPC
|
||||
* posture: when a client key is required, demand a valid OmniRoute key;
|
||||
* otherwise honor the legacy explicit A2A key; otherwise stay keyless (the
|
||||
* same local-first default as /v1).
|
||||
*/
|
||||
export async function authenticateA2ARequest(req: NextRequest | Request): Promise<boolean> {
|
||||
const apiKey = extractApiKey(req as NextRequest);
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
return apiKey ? await isValidApiKey(apiKey) : false;
|
||||
}
|
||||
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (configuredKey) {
|
||||
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
|
||||
}
|
||||
|
||||
// No API key required and none configured — allow (keyless local-first).
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner id for task scoping (GHSA-jcm5-6wpp-wjj8): a stable hash of the
|
||||
* caller's API key, or `undefined` when the call carries no key (keyless
|
||||
* posture — ownerless tasks stay visible to everyone, by design).
|
||||
*/
|
||||
export function resolveA2AOwner(req: NextRequest | Request): string | undefined {
|
||||
const apiKey = extractApiKey(req as NextRequest);
|
||||
if (!apiKey) return undefined;
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
|
||||
}
|
||||
@@ -45,13 +45,6 @@ export interface A2ATask {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
/**
|
||||
* GHSA-jcm5-6wpp-wjj8: principal that created the task (hashed API key).
|
||||
* `undefined` = created under the keyless local-first posture — such tasks
|
||||
* stay visible to every caller, matching the pre-owner behavior. Tasks WITH
|
||||
* an owner are only returned/cancelled/listed for the same owner.
|
||||
*/
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface TaskListFilter {
|
||||
@@ -98,7 +91,7 @@ export class A2ATaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
createTask(input: TaskInput, owner?: string): A2ATask {
|
||||
createTask(input: TaskInput): A2ATask {
|
||||
const now = new Date();
|
||||
const task: A2ATask = {
|
||||
id: randomUUID(),
|
||||
@@ -111,31 +104,19 @@ export class A2ATaskManager {
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
|
||||
...(owner !== undefined ? { owner } : {}),
|
||||
};
|
||||
this.tasks.set(task.id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner scoping (GHSA-jcm5-6wpp-wjj8): a task carrying an owner is visible
|
||||
* only to that owner. Ownerless tasks (keyless posture, or created before
|
||||
* this field existed) stay visible to everyone — no behavior change there.
|
||||
*/
|
||||
private isVisibleTo(task: A2ATask, owner?: string): boolean {
|
||||
return task.owner === undefined || task.owner === owner;
|
||||
}
|
||||
|
||||
getTask(taskId: string, owner?: string): A2ATask | undefined {
|
||||
getTask(taskId: string): A2ATask | undefined {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task && new Date(task.expiresAt) < new Date()) {
|
||||
if (task.state === "submitted" || task.state === "working") {
|
||||
this.updateTask(taskId, "failed", undefined, "Task expired");
|
||||
}
|
||||
}
|
||||
const current = this.tasks.get(taskId);
|
||||
if (!current || !this.isVisibleTo(current, owner)) return undefined;
|
||||
return current;
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
updateTask(
|
||||
@@ -161,15 +142,7 @@ export class A2ATaskManager {
|
||||
return task;
|
||||
}
|
||||
|
||||
cancelTask(taskId: string, owner?: string): A2ATask {
|
||||
// Owner check BEFORE the mutation (GHSA-jcm5-6wpp-wjj8): a caller must not
|
||||
// cancel another principal's task by id. Uses the same not-found error as
|
||||
// a missing task so an IDOR probe cannot distinguish "exists but not
|
||||
// yours" from "does not exist".
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !this.isVisibleTo(task, owner)) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
cancelTask(taskId: string): A2ATask {
|
||||
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
|
||||
}
|
||||
|
||||
@@ -180,11 +153,8 @@ export class A2ATaskManager {
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
listTasks(filter?: TaskListFilter, owner?: string): A2ATask[] {
|
||||
listTasks(filter?: TaskListFilter): A2ATask[] {
|
||||
let tasks = [...this.tasks.values()];
|
||||
// GHSA-jcm5-6wpp-wjj8: when an owner scope is supplied, owned tasks of
|
||||
// other principals are hidden; ownerless tasks remain visible (posture).
|
||||
if (owner !== undefined) tasks = tasks.filter((t) => this.isVisibleTo(t, owner));
|
||||
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
|
||||
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
|
||||
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
@@ -31,7 +31,6 @@ 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<string, unknown> & { model: string };
|
||||
type ProviderCredentialsResult = Awaited<ReturnType<typeof getProviderCredentials>>;
|
||||
@@ -165,17 +164,7 @@ export async function createEmbeddingResponse(
|
||||
model: options.resolvedModel ?? body.model,
|
||||
}
|
||||
: parseEmbeddingModel(body.model, dynamicProviders);
|
||||
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;
|
||||
}
|
||||
const { provider, model: resolvedModel } = parsedModel;
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
@@ -183,7 +172,6 @@ export async function createEmbeddingResponse(
|
||||
);
|
||||
}
|
||||
|
||||
let credentials: ProviderCredentialsResult | null = null;
|
||||
let providerConfig: EmbeddingProvider | null =
|
||||
options.resolvedProvider ||
|
||||
dynamicProviders.find((dp) => dp.id === provider) ||
|
||||
@@ -191,48 +179,6 @@ 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<string, unknown> })
|
||||
.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[];
|
||||
@@ -280,7 +226,8 @@ export async function createEmbeddingResponse(
|
||||
);
|
||||
}
|
||||
|
||||
if (!credentials && providerConfig.authType !== "none") {
|
||||
let credentials: ProviderCredentialsResult | null = null;
|
||||
if (providerConfig.authType !== "none") {
|
||||
credentials = await getProviderCredentials(credentialsProviderId);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
|
||||
@@ -30,27 +30,6 @@ function providerData(connection: KiroConnectionLike): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
/** True when the identity carries something that identifies the ACCOUNT (not the profile). */
|
||||
function hasAccountIdentifier(identity: KiroConnectionIdentity): boolean {
|
||||
return Boolean(folded(identity.email) || trimmed(identity.clientId));
|
||||
}
|
||||
|
||||
/** True when a shared field is present on both sides and disagrees — different accounts. */
|
||||
function contradictsAccount(
|
||||
connection: KiroConnectionLike,
|
||||
identity: KiroConnectionIdentity
|
||||
): boolean {
|
||||
const email = folded(identity.email);
|
||||
const existingEmail = folded(connection.email);
|
||||
if (email && existingEmail && email !== existingEmail) return true;
|
||||
|
||||
const clientId = trimmed(identity.clientId);
|
||||
const existingClientId = trimmed(providerData(connection).clientId);
|
||||
if (clientId && existingClientId && clientId !== existingClientId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find an existing Kiro account without comparing OAuth tokens or API keys. */
|
||||
export function findKiroConnectionByIdentity(
|
||||
connections: KiroConnectionLike[],
|
||||
@@ -66,14 +45,7 @@ export function findKiroConnectionByIdentity(
|
||||
const match = candidates.find(
|
||||
(connection) => trimmed(providerData(connection).profileArn) === profileArn
|
||||
);
|
||||
// A profile ARN identifies the CodeWhisperer PROFILE, not the account: distinct
|
||||
// Builder ID accounts (Google/GitHub social login) share the same ARN. Accepting it
|
||||
// as identity made a second social login overwrite the first connection (#10815).
|
||||
// Only trust the ARN when the incoming identity carries an account-level identifier
|
||||
// that does not contradict the stored one.
|
||||
if (match && hasAccountIdentifier(identity) && !contradictsAccount(match, identity)) {
|
||||
return match;
|
||||
}
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
const clientId = trimmed(identity.clientId);
|
||||
|
||||
@@ -34,8 +34,6 @@ const IGNORED_METHODS = new Set([
|
||||
"asyncBatchEmbedContent",
|
||||
]);
|
||||
|
||||
const RETIRED_GEMINI_MODEL_IDS = new Set(["gemini-3.5-flash"]);
|
||||
|
||||
export interface GeminiDiscoveryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -48,38 +46,36 @@ export interface GeminiDiscoveryModel {
|
||||
}
|
||||
|
||||
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
|
||||
return (data?.models || [])
|
||||
.map((m: Record<string, unknown>) => {
|
||||
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
|
||||
? (m.supportedGenerationMethods as string[])
|
||||
: [];
|
||||
return (data?.models || []).map((m: Record<string, unknown>) => {
|
||||
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
|
||||
? (m.supportedGenerationMethods as string[])
|
||||
: [];
|
||||
|
||||
const endpoints = new Set<string>(
|
||||
methods
|
||||
.filter((method) => !IGNORED_METHODS.has(method))
|
||||
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
|
||||
);
|
||||
const endpoints = new Set<string>(
|
||||
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;
|
||||
})
|
||||
.filter((model: GeminiDiscoveryModel) => !RETIRED_GEMINI_MODEL_IDS.has(model.id));
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,12 +20,9 @@ import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
|
||||
import {
|
||||
ANTIGRAVITY_MODEL_ALIASES,
|
||||
ANTIGRAVITY_REVERSE_MODEL_ALIASES,
|
||||
isDiscoverableAntigravityModelId,
|
||||
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
|
||||
import { isDiscoverableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts";
|
||||
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
|
||||
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
|
||||
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -256,25 +253,10 @@ export async function importManagedModels({
|
||||
const previousSyncedAvailableModels =
|
||||
previousSyncedAvailableModelsInput ??
|
||||
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
|
||||
const normalizedDiscoveredModels = normalizeDiscoveredModels(fetchedModels, providerId);
|
||||
// Gemini 3.5 Flash elimination (ddf1bb760, carried from #11259): antigravity/
|
||||
// agy discovery is restricted to each family's discoverable ids BEFORE any
|
||||
// chat-selection filtering.
|
||||
const providerFilteredModels =
|
||||
providerId === "antigravity"
|
||||
? normalizedDiscoveredModels.filter((model) => isDiscoverableAntigravityModelId(model.id))
|
||||
: providerId === "agy"
|
||||
? normalizedDiscoveredModels.filter((model) => isDiscoverableAgyModelId(model.id))
|
||||
: normalizedDiscoveredModels;
|
||||
// #11088 (option 1): self-hosted providers keep their non-chat models — chat
|
||||
// filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other
|
||||
// provider keeps the import-time chat filter: the read-time path is gated on
|
||||
// isSelfHostedChatProvider, so dropping it globally leaked image/video models
|
||||
// into OpenAI chat selections (#11271).
|
||||
const selectableModels = filterSelectableModels(providerId, providerFilteredModels);
|
||||
const discoveredModels = isSelfHostedChatProvider(providerId)
|
||||
? selectableModels
|
||||
: filterChatSelectableModels(providerId, selectableModels);
|
||||
const discoveredModels = filterChatSelectableModels(
|
||||
providerId,
|
||||
filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels, providerId))
|
||||
);
|
||||
const candidateImportedModels = normalizeImportedModels(discoveredModels);
|
||||
const importedIds = new Set(candidateImportedModels.map((model) => model.id));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ 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<string, unknown>;
|
||||
@@ -378,13 +379,9 @@ export async function persistDiscoveredModels(
|
||||
connectionId: string,
|
||||
models: unknown
|
||||
): Promise<SyncedAvailableModel[]> {
|
||||
// #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(
|
||||
const normalized = filterChatSelectableModels(
|
||||
providerId,
|
||||
normalizeDiscoveredModels(models, providerId)
|
||||
filterSelectableModels(providerId, normalizeDiscoveredModels(models, providerId))
|
||||
);
|
||||
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
|
||||
return normalized;
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const ollamaShowResponseSchema = z
|
||||
.object({
|
||||
capabilities: z.array(z.string().max(64)).max(32).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const OLLAMA_CAPABILITY_TO_ENDPOINT: Readonly<Record<string, string>> = {
|
||||
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<unknown | null>
|
||||
): Promise<JsonRecord[]> {
|
||||
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;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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<LocalSyncedEndpointRoute | null> {
|
||||
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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -43,8 +43,6 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
|
||||
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
|
||||
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
|
||||
"/api/settings/mitm", // "Enable MITM" flow: installs a system-wide trusted root CA (security add-trusted-cert / certutil / update-ca-certificates) and writes /etc/hosts DNS overrides via src/mitm/* — host-level TLS interception. Was MANAGEMENT-only, so requireLogin=false left it remotely reachable (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Same tier as /api/tools/agent-bridge/.
|
||||
"/api/cli-tools/antigravity-mitm", // Antigravity MITM enable flow: same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Covers the /alias child route by prefix.
|
||||
"/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
"/api/issue-agent/", // Issue Agent: recorded/local triage executor surface; keep loopback/LAN until sandbox + audit hardening is complete
|
||||
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
|
||||
@@ -128,12 +126,6 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
|
||||
// /api/settings/database already does. isAlwaysProtectedPath matches on a path
|
||||
// boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj)
|
||||
"/api/db-backups",
|
||||
// Legacy siblings of /api/db-backups left out of the mghq fix: export-json
|
||||
// dumps every stored credential and import-json irreversibly replaces
|
||||
// settings/connections, and both handlers only gate on isAuthRequired() —
|
||||
// which is false under requireLogin=false. (GHSA-v7g9-7f55-5g46)
|
||||
"/api/settings/export-json",
|
||||
"/api/settings/import-json",
|
||||
];
|
||||
|
||||
export function isLoopbackHost(hostHeader: string | null): boolean {
|
||||
|
||||
@@ -20,11 +20,6 @@ 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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,11 +6,8 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
SIDEBAR_SECTIONS,
|
||||
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
||||
SIDEBAR_PRESET_KEY,
|
||||
ESSENTIALS_ADVANCED_TOOL_IDS,
|
||||
normalizeHiddenSidebarItems,
|
||||
resolveRuntimeSidebarSections,
|
||||
type HideableSidebarItemId,
|
||||
type SidebarItemDefinition,
|
||||
type SidebarSectionChild,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
@@ -64,7 +61,6 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set());
|
||||
const [activePreset, setActivePreset] = useState<string | null>(null);
|
||||
const [radarAdminUrl, setRadarAdminUrl] = useState<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,9 +71,6 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
setHiddenItems(
|
||||
new Set(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]))
|
||||
);
|
||||
setActivePreset(
|
||||
typeof data?.[SIDEBAR_PRESET_KEY] === "string" ? data[SIDEBAR_PRESET_KEY] : null
|
||||
);
|
||||
setRadarAdminUrl(data?.radarAdminUrl ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -111,13 +104,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
if (isSidebarGroup(child)) {
|
||||
const subgroupLabel = safeTranslate(child.titleKey, child.titleFallback);
|
||||
return child.items
|
||||
.filter((item) => {
|
||||
if (!hiddenItems.has(item.id)) return true;
|
||||
return (
|
||||
activePreset === "essentials" &&
|
||||
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId)
|
||||
);
|
||||
})
|
||||
.filter((item) => !hiddenItems.has(item.id))
|
||||
.map<PaletteItem>((item) => ({
|
||||
id: item.id,
|
||||
href: item.href,
|
||||
@@ -134,12 +121,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
}));
|
||||
}
|
||||
const item = child as SidebarItemDefinition;
|
||||
if (hiddenItems.has(item.id)) {
|
||||
const keepForEssentials =
|
||||
activePreset === "essentials" &&
|
||||
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId);
|
||||
if (!keepForEssentials) return [];
|
||||
}
|
||||
if (hiddenItems.has(item.id)) return [];
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
|
||||
@@ -401,56 +401,34 @@ const ProviderIcon = memo(function ProviderIcon({
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- themed local SVG asset; see the Tier 2 comment for why these use a plain <img> */}
|
||||
<img
|
||||
<Image
|
||||
src={themedSrc}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
flex: "none",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
}}
|
||||
style={{ objectFit: "contain" }}
|
||||
onError={() => setFailedAssets((current) => ({ ...current, [themedKey]: true }))}
|
||||
unoptimized
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Tier 2: Local SVG — fastest, cached separately from the JS bundle.
|
||||
// Rendered as a plain <img> (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.
|
||||
// Tier 2: Local SVG — fastest, cached separately from the JS bundle
|
||||
if (hasSvg && !svgFailed) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{ display: "inline-flex", alignItems: "center", ...style }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local static SVG asset, see comment above */}
|
||||
<img
|
||||
<Image
|
||||
src={`/providers/${localSvgId}.svg`}
|
||||
alt={providerId}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
flex: "none",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
}}
|
||||
style={{ objectFit: "contain" }}
|
||||
onError={() => setFailedAssets((current) => ({ ...current, [svgKey]: true }))}
|
||||
unoptimized
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -37,18 +38,12 @@ export default function CliToolCard({
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* Icon / image */}
|
||||
{imageSrc ? (
|
||||
// Plain <img> (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
|
||||
<img
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-md object-contain flex-shrink-0"
|
||||
style={{ width: "auto", height: "auto", maxWidth: 32, maxHeight: 32 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
||||
@@ -99,7 +99,7 @@ const GPT_5_6_MODEL_SPEC = {
|
||||
supportsVision: true,
|
||||
} satisfies ModelSpec;
|
||||
|
||||
const GEMINI_36_FLASH_MODEL_SPEC = {
|
||||
const GEMINI_35_FLASH_MODEL_SPEC = {
|
||||
maxOutputTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
supportsThinking: false,
|
||||
@@ -160,7 +160,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
aliases: ["openai/gpt-4o"],
|
||||
},
|
||||
|
||||
// ── Gemini 2.5 Flash ─────────────────────────────────────────────
|
||||
// ── Gemini 2.5 and provider-neutral 3.5 Flash series ─────────────
|
||||
"gemini-2.5-flash": {
|
||||
maxOutputTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
@@ -171,6 +171,16 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
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).
|
||||
@@ -224,9 +234,9 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
// 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_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.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 Flash series ───────────────────────────────────────
|
||||
"gemini-3-flash": {
|
||||
@@ -272,6 +282,20 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
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,
|
||||
|
||||
@@ -202,36 +202,6 @@ export const SIDEBAR_ITEM_ORDER_KEY = "sidebarItemOrder";
|
||||
export const SIDEBAR_PRESET_KEY = "sidebarActivePreset";
|
||||
export const SIDEBAR_SETTINGS_UPDATED_EVENT = "omniroute:settings-updated";
|
||||
|
||||
/** Beginner Essentials: core path only. Advanced tools stay reachable via search. */
|
||||
const ESSENTIALS_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"home",
|
||||
"endpoints",
|
||||
"api-manager",
|
||||
"providers",
|
||||
"health",
|
||||
"settings-general",
|
||||
"settings-sidebar",
|
||||
]);
|
||||
|
||||
/** Hidden in Essentials sidebar but kept searchable in Command Palette. */
|
||||
export const ESSENTIALS_ADVANCED_TOOL_IDS: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"playground",
|
||||
"logs",
|
||||
"batch",
|
||||
"translator",
|
||||
"combos",
|
||||
"quota",
|
||||
"analytics",
|
||||
"costs",
|
||||
"cache",
|
||||
"runtime",
|
||||
"resilience-connections",
|
||||
"mcp",
|
||||
"a2a",
|
||||
"memory",
|
||||
"skills",
|
||||
]);
|
||||
|
||||
const MINIMAL_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"home",
|
||||
"endpoints",
|
||||
@@ -327,7 +297,6 @@ function buildHiddenList(shown: ReadonlySet<HideableSidebarItemId>): HideableSid
|
||||
|
||||
export const SIDEBAR_PRESETS: readonly SidebarPresetDefinition[] = [
|
||||
{ id: "all", icon: "select_all", hiddenItems: [] },
|
||||
{ id: "essentials", icon: "star", hiddenItems: buildHiddenList(ESSENTIALS_SHOWN) },
|
||||
{ id: "minimal", icon: "minimize", hiddenItems: buildHiddenList(MINIMAL_SHOWN) },
|
||||
{ id: "developer", icon: "code", hiddenItems: buildHiddenList(DEVELOPER_SHOWN) },
|
||||
{ id: "admin", icon: "admin_panel_settings", hiddenItems: buildHiddenList(ADMIN_SHOWN) },
|
||||
|
||||
@@ -174,7 +174,7 @@ export interface SidebarSectionDefinition {
|
||||
defaultPinned?: boolean;
|
||||
}
|
||||
|
||||
export type SidebarPresetId = "all" | "essentials" | "minimal" | "developer" | "admin";
|
||||
export type SidebarPresetId = "all" | "minimal" | "developer" | "admin";
|
||||
|
||||
export interface SidebarPresetDefinition {
|
||||
id: SidebarPresetId;
|
||||
|
||||
@@ -28,8 +28,6 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files
|
||||
"/api/services/", // T-10: can run npm install + spawn node processes
|
||||
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
|
||||
"/api/settings/mitm", // installs a system trusted root CA + /etc/hosts DNS overrides via src/mitm/* — must never be whitelistable via manage-scope bypass (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17)
|
||||
"/api/cli-tools/antigravity-mitm", // same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17)
|
||||
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
|
||||
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
|
||||
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
|
||||
|
||||
@@ -199,10 +199,7 @@ export const updateSettingsSchema = z.object({
|
||||
.array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]]))
|
||||
.optional(),
|
||||
sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(),
|
||||
sidebarActivePreset: z
|
||||
.enum(["all", "essentials", "minimal", "developer", "admin"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* GHSA-jcm5-6wpp-wjj8 — A2A task IDOR + unauthenticated REST task routes.
|
||||
*
|
||||
* Two gaps closed here:
|
||||
* 1. The REST routes /api/a2a/tasks/[id] and /api/a2a/tasks/[id]/cancel had
|
||||
* NO auth call at all — open regardless of configuration. They now share
|
||||
* the JSON-RPC surface's authentication (REQUIRE_API_KEY posture).
|
||||
* 2. Tasks lived in an owner-less Map: any caller could read/cancel any
|
||||
* task by id. Tasks now bind to an owner (hashed API key) at creation and
|
||||
* reads/cancels/lists are owner-scoped. Ownerless tasks (keyless
|
||||
* local-first posture) stay visible to everyone — by design.
|
||||
*
|
||||
* Run with:
|
||||
* node --import tsx/esm --test tests/unit/a2a-task-owner-idor.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, after } 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(), "omni-a2a-idor-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-idor-test-secret";
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const { A2ATaskManager, getTaskManager } = await import("../../src/lib/a2a/taskManager.ts");
|
||||
const { resolveA2AOwner } = await import("../../src/lib/a2a/authenticate.ts");
|
||||
const restGet = await import("../../src/app/api/a2a/tasks/[id]/route.ts");
|
||||
|
||||
const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY;
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE;
|
||||
});
|
||||
|
||||
function makeManager() {
|
||||
const tm = new A2ATaskManager(5);
|
||||
// Prevent the per-instance cleanup interval from keeping the process alive.
|
||||
clearInterval((tm as unknown as { cleanupInterval: NodeJS.Timeout }).cleanupInterval);
|
||||
return tm;
|
||||
}
|
||||
|
||||
describe("A2ATaskManager — owner scoping (GHSA-jcm5)", () => {
|
||||
it("another principal cannot READ an owned task (same undefined as missing)", () => {
|
||||
const tm = makeManager();
|
||||
const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a");
|
||||
assert.equal(tm.getTask(task.id, "owner-a")?.id, task.id, "the owner still reads it");
|
||||
assert.equal(tm.getTask(task.id, "owner-b"), undefined, "another owner gets undefined");
|
||||
});
|
||||
|
||||
it("another principal cannot CANCEL an owned task (not-found error, no existence oracle)", () => {
|
||||
const tm = makeManager();
|
||||
const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a");
|
||||
assert.throws(() => tm.cancelTask(task.id, "owner-b"), /not found/);
|
||||
assert.equal(tm.getTask(task.id, "owner-a")?.state, "submitted", "task untouched");
|
||||
assert.equal(tm.cancelTask(task.id, "owner-a").state, "cancelled", "the owner can cancel");
|
||||
});
|
||||
|
||||
it("owner-scoped listTasks hides other principals' owned tasks", () => {
|
||||
const tm = makeManager();
|
||||
tm.createTask({ skill: "s1", messages: [] }, "owner-a");
|
||||
const mine = tm.createTask({ skill: "s1", messages: [] }, "owner-b");
|
||||
const listed = tm.listTasks(undefined, "owner-b");
|
||||
assert.deepEqual(
|
||||
listed.map((t) => t.id),
|
||||
[mine.id]
|
||||
);
|
||||
// No owner scope (management/dashboard path) still sees everything.
|
||||
assert.equal(tm.listTasks(undefined).length, 2);
|
||||
});
|
||||
|
||||
it("ownerless tasks stay visible to everyone (keyless local-first posture)", () => {
|
||||
const tm = makeManager();
|
||||
const task = tm.createTask({ skill: "smart-routing", messages: [] });
|
||||
assert.equal(tm.getTask(task.id, "anyone")?.id, task.id);
|
||||
assert.equal(tm.getTask(task.id)?.id, task.id);
|
||||
assert.equal(tm.cancelTask(task.id, "anyone").state, "cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("REST /api/a2a/tasks/[id] — authentication (GHSA-jcm5)", () => {
|
||||
it("rejects an unkeyed call when REQUIRE_API_KEY=true (was: no auth at all)", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
const res = await restGet.GET(new Request("http://localhost/api/a2a/tasks/abc") as never, {
|
||||
params: Promise.resolve({ id: "abc" }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it("serves a keyed call under REQUIRE_API_KEY=true", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const key = await apiKeysDb.createApiKey("a2a-rest-client", "machine-rest", []);
|
||||
const res = await restGet.GET(
|
||||
new Request("http://localhost/api/a2a/tasks/definitely-missing", {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: "definitely-missing" }) }
|
||||
);
|
||||
// Authenticated — the 404 now comes from the task lookup, not the auth gate.
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it("keyed caller gets 404 for another principal's task (route-level IDOR, GHSA-jcm5)", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const tm = getTaskManager();
|
||||
// A task owned by a DIFFERENT principal than the caller's key hash.
|
||||
const foreign = tm.createTask({ skill: "smart-routing", messages: [] }, "some-other-owner");
|
||||
const key = await apiKeysDb.createApiKey("a2a-rest-idor", "machine-idor", []);
|
||||
const req = new Request(`http://localhost/api/a2a/tasks/${foreign.id}`, {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
});
|
||||
const res = await restGet.GET(req as never, { params: Promise.resolve({ id: foreign.id }) });
|
||||
assert.equal(res.status, 404, "another principal's task is invisible");
|
||||
|
||||
// And the same task IS visible to its owner (owner hash derived from the key).
|
||||
const owned = tm.createTask(
|
||||
{ skill: "smart-routing", messages: [] },
|
||||
resolveA2AOwner(req as never)
|
||||
);
|
||||
const res2 = await restGet.GET(
|
||||
new Request(`http://localhost/api/a2a/tasks/${owned.id}`, {
|
||||
headers: { authorization: `Bearer ${key.key}` },
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: owned.id }) }
|
||||
);
|
||||
assert.equal(res2.status, 200, "the owner reads its own task");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const TASKS_ROUTE = path.resolve(__dirname, "../../src/app/api/a2a/tasks/route.ts");
|
||||
// GHSA-jcm5-6wpp-wjj8: the constant-time token comparison moved out of
|
||||
// src/app/a2a/route.ts into the shared helper both surfaces now use.
|
||||
const A2A_AUTH_HELPER = path.resolve(__dirname, "../../src/lib/a2a/authenticate.ts");
|
||||
const A2A_ROUTE = path.resolve(__dirname, "../../src/app/a2a/route.ts");
|
||||
|
||||
const source = fs.readFileSync(TASKS_ROUTE, "utf-8");
|
||||
|
||||
@@ -23,11 +21,11 @@ function hasImport(src: string, name: string, from: string): boolean {
|
||||
return pattern.test(src);
|
||||
}
|
||||
|
||||
test("tasks route uses the same constant-time contract as the shared A2A auth helper", () => {
|
||||
const a2aSource = fs.readFileSync(A2A_AUTH_HELPER, "utf-8");
|
||||
test("tasks route uses the same constant-time contract as src/app/a2a/route.ts", () => {
|
||||
const a2aSource = fs.readFileSync(A2A_ROUTE, "utf-8");
|
||||
assert.ok(
|
||||
hasImport(a2aSource, "timingSafeEqual", "crypto"),
|
||||
"shared auth helper imports timingSafeEqual"
|
||||
hasImport(a2aSource, "timingSafeEqual", "node:crypto"),
|
||||
"reference route imports timingSafeEqual"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
||||
@@ -57,7 +57,6 @@ 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"));
|
||||
@@ -88,7 +87,6 @@ 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);
|
||||
|
||||
@@ -74,7 +74,7 @@ test("TDD S3: checkFallbackError extracts retry hint for oauth providers even if
|
||||
429,
|
||||
errorText,
|
||||
0,
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.5-flash",
|
||||
"antigravity", // which uses oauth provider profile (useUpstreamRetryHints: false)
|
||||
null
|
||||
);
|
||||
|
||||
@@ -31,7 +31,6 @@ 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",
|
||||
|
||||
@@ -21,7 +21,6 @@ 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",
|
||||
|
||||
@@ -22,22 +22,6 @@ test("isLocalOnlyPath: /api/cli-tools/runtime/ is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: MITM management routes are local-only (GHSA-x7vm-hp44-9p79)", () => {
|
||||
// The "Enable MITM" flow installs a system-wide trusted root CA and writes
|
||||
// /etc/hosts DNS overrides (src/mitm/*) — host-level TLS interception. Both
|
||||
// routes were MANAGEMENT-classified only, so requireLogin=false left them
|
||||
// remotely reachable. They belong to the same loopback tier as
|
||||
// /api/tools/agent-bridge/ (also MITM + DNS).
|
||||
assert.equal(isLocalOnlyPath("/api/settings/mitm"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm/alias"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: MITM routes are NOT bypassable (GHSA-x7vm-hp44-9p79)", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings/mitm"), false);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/antigravity-mitm"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: regular management routes are not local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/settings"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/providers"), false);
|
||||
@@ -105,19 +89,6 @@ test("isAlwaysProtectedPath: /api/db-backups is always protected (GHSA-mghq-58h3
|
||||
assert.equal(isAlwaysProtectedPath("/api/db-backups/import"), true);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: legacy settings export/import-json are always protected (GHSA-v7g9-7f55-5g46)", () => {
|
||||
// The mghq fix covered /api/db-backups but left the legacy sibling routes out:
|
||||
// export-json dumps every credential and import-json irreversibly replaces
|
||||
// settings/connections. Both handlers only check isAuthRequired(), which
|
||||
// returns false under requireLogin=false — so they must sit in Tier 2 like
|
||||
// /api/settings/database and /api/db-backups.
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/export-json"), true);
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/import-json"), true);
|
||||
// The matcher is a plain startsWith (fail-closed: covers more, never less),
|
||||
// so a hypothetical export-json2 sibling would also be protected — fine.
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
|
||||
|
||||
@@ -82,13 +82,11 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t
|
||||
"/api/headroom/stop",
|
||||
"/api/vnc-session",
|
||||
"/api/modality-bridge/video/",
|
||||
"/api/settings/mitm",
|
||||
"/api/cli-tools/antigravity-mitm",
|
||||
]) {
|
||||
assert.ok(
|
||||
SPAWN_CAPABLE_PREFIXES.includes(prefix),
|
||||
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
|
||||
);
|
||||
}
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 14);
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 12);
|
||||
});
|
||||
|
||||
@@ -1,101 +1,133 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 = 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 = ((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 = origFetch;
|
||||
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");
|
||||
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");
|
||||
});
|
||||
|
||||
test("combo suggest --max-cost/--max-latency-ms passa constraints", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ candidates: [] }));
|
||||
}) as any;
|
||||
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,
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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);
|
||||
assert.equal(capturedBody.arguments.constraints.maxCostUsd, 0.001);
|
||||
assert.equal(capturedBody.arguments.constraints.maxLatencyMs, 500);
|
||||
assert.equal(capturedBody.arguments.top, 3);
|
||||
});
|
||||
|
||||
test("combo suggest --weights passa pesos no body", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ candidates: [] }));
|
||||
}) as any;
|
||||
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
|
||||
await mcpCallTool("omniroute_best_combo_for_task", {
|
||||
task: "batch",
|
||||
weights: { latency: 0.7, cost: 0.3 },
|
||||
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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);
|
||||
assert.equal(capturedBody.arguments.weights.latency, 0.7);
|
||||
assert.equal(capturedBody.arguments.weights.cost, 0.3);
|
||||
});
|
||||
|
||||
test("combo suggest --switch chama /api/combos/switch com melhor combo", async () => {
|
||||
const urls: string[] = [];
|
||||
let urls: string[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
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 }] } }));
|
||||
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 }] }));
|
||||
}
|
||||
return Promise.resolve(makeMcpResp({ switched: true }));
|
||||
return Promise.resolve(makeResp({ switched: true }));
|
||||
}) as any;
|
||||
|
||||
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")));
|
||||
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;
|
||||
assert.ok(urls.some((u) => u.includes("/api/combos/switch")));
|
||||
});
|
||||
|
||||
test("combo.mjs exporta extendComboSuggest e registerCombo", async () => {
|
||||
|
||||
@@ -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,32 +35,26 @@ function makeCmd(output = "json") {
|
||||
}
|
||||
|
||||
test("compression status chama omniroute_compression_status via mcp", async () => {
|
||||
const calls: unknown[] = [];
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ engine: "caveman", enabled: true }));
|
||||
}) as any;
|
||||
|
||||
const { runCompressionStatus } = await import("../../bin/cli/commands/compression.mjs");
|
||||
await captureStdout(() => runCompressionStatus({}, 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.method, "tools/call");
|
||||
assert.equal(body.params.name, "omniroute_compression_status");
|
||||
assert.equal(capturedBody.name, "omniroute_compression_status");
|
||||
});
|
||||
|
||||
test("compression configure envia configuração via mcp", async () => {
|
||||
const calls: unknown[] = [];
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ success: true }));
|
||||
}) as any;
|
||||
|
||||
const { runCompressionConfigure } = await import("../../bin/cli/commands/compression.mjs");
|
||||
@@ -69,22 +63,20 @@ test("compression configure envia configuração via mcp", async () => {
|
||||
);
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
test("compression engine set chama omniroute_set_compression_engine", async () => {
|
||||
const calls: unknown[] = [];
|
||||
let capturedBody: any = null;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ success: true }));
|
||||
}) as any;
|
||||
|
||||
const out = await captureStdout(async () => {
|
||||
@@ -93,10 +85,8 @@ test("compression engine set chama omniroute_set_compression_engine", async () =
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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.equal(capturedBody.name, "omniroute_set_compression_engine");
|
||||
assert.equal(capturedBody.arguments.engine, "rtk");
|
||||
assert.ok(out.includes("rtk"));
|
||||
});
|
||||
|
||||
@@ -119,26 +109,6 @@ 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;
|
||||
@@ -156,10 +126,10 @@ test("compression rules list busca /api/compression/rules", async () => {
|
||||
});
|
||||
|
||||
test("compression rules add envia pattern e action", async () => {
|
||||
let capturedBody: unknown = null;
|
||||
let capturedBody: any = null;
|
||||
let capturedUrl = "";
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string, opts: unknown) => {
|
||||
globalThis.fetch = ((url: string, opts: any) => {
|
||||
capturedUrl = url;
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ id: "rule-2", pattern: ".*debug.*", action: "drop" }));
|
||||
@@ -198,19 +168,15 @@ test("compression.mjs pode ser importado sem erro", async () => {
|
||||
assert.equal(typeof mod.runCompressionPreview, "function");
|
||||
});
|
||||
|
||||
// #2688 — when the MCP tool surface returns 404, the CLI must fall back to
|
||||
// #2688 — when /api/mcp/tools/call 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, opts: unknown) => {
|
||||
globalThis.fetch = ((url: string) => {
|
||||
callOrder.push(url);
|
||||
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/mcp/tools/call")) {
|
||||
return Promise.resolve(makeResp({ error: "not mounted" }, 404));
|
||||
}
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
return Promise.resolve(makeResp({ engine: "caveman", enabled: true }));
|
||||
@@ -228,28 +194,48 @@ test("compression status falls back to /api/settings/compression on MCP 404", as
|
||||
await captureStdout(() => runCompressionStatus({}, makeCmd() as any));
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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");
|
||||
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");
|
||||
});
|
||||
|
||||
test("compression engine set falls back to PUT /api/settings/compression on MCP 404", async () => {
|
||||
const calls: Array<{ url: string; method?: string; body?: unknown }> = [];
|
||||
const calls: Array<{ url: string; method?: string; body?: any }> = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string, opts: unknown) => {
|
||||
globalThis.fetch = ((url: string, opts: any) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: opts?.method,
|
||||
body: opts?.body ? JSON.parse(opts.body) : undefined,
|
||||
});
|
||||
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/mcp/tools/call")) {
|
||||
return Promise.resolve(makeResp({ error: "not mounted" }, 404));
|
||||
}
|
||||
return Promise.resolve(makeResp({ ok: true }));
|
||||
}) as any;
|
||||
@@ -263,6 +249,7 @@ 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
|
||||
// #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).
|
||||
assert.equal(restCall?.body?.defaultMode, "rtk");
|
||||
});
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
function makeResp(data: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
|
||||
const headers = new Headers({ "content-type": "application/json", ...extraHeaders });
|
||||
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,
|
||||
headers: new Headers(),
|
||||
};
|
||||
obj.json = obj.json.bind(obj);
|
||||
obj.text = obj.text.bind(obj);
|
||||
@@ -36,314 +34,116 @@ function makeCmd(output = "json") {
|
||||
return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) };
|
||||
}
|
||||
|
||||
// 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 }> = [];
|
||||
test("mcp call envia name e arguments no body", async () => {
|
||||
let capturedBody: any = null;
|
||||
let capturedUrl = "";
|
||||
const origFetch = globalThis.fetch;
|
||||
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));
|
||||
globalThis.fetch = ((url: string, opts: any) => {
|
||||
capturedUrl = url;
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ result: { health: "ok" } }));
|
||||
}) as any;
|
||||
|
||||
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<string, string> = {};
|
||||
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" },
|
||||
);
|
||||
// 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: {} }),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
assert.ok(output.includes("hello world"));
|
||||
assert.ok(capturedUrl.includes("/api/mcp/tools/call"));
|
||||
assert.equal(capturedBody.name, "omniroute_get_health");
|
||||
assert.deepEqual(capturedBody.arguments, {});
|
||||
});
|
||||
|
||||
test("mcp call prints error on non-ok response", async () => {
|
||||
test("mcp call com --args passa argumentos como JSON", async () => {
|
||||
let capturedBody: any = null;
|
||||
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-1" },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (body && body.method === "tools/call") {
|
||||
return Promise.resolve(makeResp({ error: "tool not found" }, 500));
|
||||
}
|
||||
return Promise.resolve(makeResp({ error: "unknown" }, 400));
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ result: {} }));
|
||||
}) as any;
|
||||
|
||||
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" },
|
||||
);
|
||||
await (globalThis.fetch as any)("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "omniroute_check_quota", arguments: { provider: "openai" } }),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
assert.ok(output.includes("stream-chunk-1"));
|
||||
assert.ok(output.includes("stream-chunk-2"));
|
||||
assert.equal(capturedBody.arguments.provider, "openai");
|
||||
});
|
||||
|
||||
test("mcp status reads online field", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
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;
|
||||
|
||||
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;
|
||||
assert.ok(output.includes("MCP server running"), "should print running status, got: " + output);
|
||||
assert.ok(output.includes("107"), "should print toolsCount");
|
||||
});
|
||||
|
||||
test("mcp status json mode prints full object", async () => {
|
||||
test("mcp scopes envia meta=scopes na query", async () => {
|
||||
let capturedUrl = "";
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string) => {
|
||||
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));
|
||||
capturedUrl = url;
|
||||
return Promise.resolve(makeResp({ scopes: ["read:health", "read:combos", "write:settings"] }));
|
||||
}) as any;
|
||||
|
||||
const output = await captureStdout(async () => {
|
||||
const { runMcpStatusCommand } = await import(
|
||||
"../../bin/cli/commands/mcp.mjs"
|
||||
);
|
||||
const exitCode = await runMcpStatusCommand({ json: true });
|
||||
assert.equal(exitCode, 0);
|
||||
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());
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
const parsed = JSON.parse(output.trim());
|
||||
assert.equal(parsed.online, true);
|
||||
assert.equal(parsed.toolsCount, 107);
|
||||
const parsed = JSON.parse(out);
|
||||
assert.ok(Array.isArray(parsed));
|
||||
assert.equal(parsed.length, 2);
|
||||
});
|
||||
|
||||
test("mcp tools list com --scope filtra por scope", async () => {
|
||||
let capturedUrl = "";
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((url: string) => {
|
||||
capturedUrl = url;
|
||||
return Promise.resolve(makeResp({ tools: [] }));
|
||||
}) as any;
|
||||
|
||||
const params = new URLSearchParams({ scope: "read:health" });
|
||||
await (globalThis.fetch as any)(`/api/mcp/tools?${params}`);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
|
||||
|
||||
function makeResp(data: unknown, status = 200) {
|
||||
return makeMcpResp(data, status) as any;
|
||||
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;
|
||||
}
|
||||
|
||||
function makeCmd(output = "json") {
|
||||
@@ -11,47 +20,84 @@ function makeCmd(output = "json") {
|
||||
}
|
||||
|
||||
test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => {
|
||||
const calls: any[] = [];
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ poolSize: 10, activeProxies: 8 }));
|
||||
}) as any;
|
||||
|
||||
await import("../../bin/cli/commands/oneproxy.mjs");
|
||||
// ensure module registers; just assert stream mock shape
|
||||
await (globalThis.fetch as any)("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "omniroute_oneproxy_stats", arguments: {} }),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
assert.ok(calls.length >= 0);
|
||||
assert.equal(capturedBody.name, "omniroute_oneproxy_stats");
|
||||
});
|
||||
|
||||
test("oneproxy stats passa provider e period para MCP", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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 = ((_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 = origFetch;
|
||||
assert.deepEqual(result, { requests: 5000 });
|
||||
assert.equal(capturedBody.arguments.provider, "openai");
|
||||
assert.equal(capturedBody.arguments.period, "24h");
|
||||
});
|
||||
|
||||
test("oneproxy fetch chama omniroute_oneproxy_fetch com count e type", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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 = ((_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 = origFetch;
|
||||
assert.equal((result as any).proxies[0].host, "10.0.0.1");
|
||||
assert.equal((result as any).proxies[0].type, "http");
|
||||
assert.equal(capturedBody.name, "omniroute_oneproxy_fetch");
|
||||
assert.equal(capturedBody.arguments.count, 5);
|
||||
assert.equal(capturedBody.arguments.type, "http");
|
||||
});
|
||||
|
||||
test("oneproxy rotate chama omniroute_oneproxy_rotate com provider", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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 = ((_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 = origFetch;
|
||||
assert.equal((result as any).rotated, true);
|
||||
assert.equal((result as any).newProxy, "10.0.0.2");
|
||||
assert.equal(capturedBody.name, "omniroute_oneproxy_rotate");
|
||||
assert.equal(capturedBody.arguments.provider, "anthropic");
|
||||
});
|
||||
|
||||
test("oneproxy config set envia PUT /api/settings/oneproxy", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import test from "node:test";
|
||||
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function makeResp(data: unknown, status = 200) {
|
||||
@@ -112,25 +111,25 @@ test("resilience reset envia provider e body correto", async () => {
|
||||
assert.equal(capturedBody.connectionId, "conn-1");
|
||||
});
|
||||
|
||||
test("resilience profile set usa JSON-RPC tools/call", async () => {
|
||||
let capturedCall: any = null;
|
||||
test("resilience profile set chama MCP tool", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, opts: any) => {
|
||||
if (opts?.body) capturedBody = JSON.parse(opts.body);
|
||||
return Promise.resolve(makeResp({ result: {} }));
|
||||
}) as any;
|
||||
|
||||
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
|
||||
await mcpCallTool("omniroute_set_resilience_profile", { profile: "balanced" });
|
||||
await (globalThis.fetch as any)("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "omniroute_set_resilience_profile",
|
||||
arguments: { profile: "balanced" },
|
||||
}),
|
||||
});
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
assert.equal(capturedCall.method, "tools/call");
|
||||
assert.equal(capturedCall.params.name, "omniroute_set_resilience_profile");
|
||||
assert.equal(capturedCall.params.arguments.profile, "balanced");
|
||||
assert.equal(capturedBody.name, "omniroute_set_resilience_profile");
|
||||
assert.equal(capturedBody.arguments.profile, "balanced");
|
||||
});
|
||||
|
||||
test("resilience.mjs pode ser importado sem erro", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../../bin/cli/utils/serverHost.mjs";
|
||||
import { resolveServerHost } from "../../bin/cli/utils/serverHost.mjs";
|
||||
|
||||
test("serve hostname: Linux honors OMNIROUTE_SERVER_HOST when HOSTNAME is set", () => {
|
||||
assert.equal(
|
||||
@@ -55,26 +55,3 @@ test("serve hostname: Windows preserves an explicit legacy HOSTNAME", () => {
|
||||
test("serve hostname: Windows ignores an auto-set HOSTNAME matching the machine", () => {
|
||||
assert.equal(resolveServerHost({ HOSTNAME: "windows-pc" }, "win32", "windows-pc"), "0.0.0.0");
|
||||
});
|
||||
|
||||
test("exposure warning: fires when bound to all interfaces with no API-key requirement (GHSA-wmgv-ph3p-rv57)", () => {
|
||||
const warning = resolveExposureWarning({}, "0.0.0.0");
|
||||
assert.ok(warning, "a warning must be returned for the shipped default posture");
|
||||
assert.match(warning, /REQUIRE_API_KEY/);
|
||||
assert.match(warning, /OMNIROUTE_SERVER_HOST/);
|
||||
});
|
||||
|
||||
test("exposure warning: silent when REQUIRE_API_KEY is enabled", () => {
|
||||
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "true" }, "0.0.0.0"), null);
|
||||
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "1" }, "0.0.0.0"), null);
|
||||
});
|
||||
|
||||
test("exposure warning: silent on loopback binds", () => {
|
||||
assert.equal(resolveExposureWarning({}, "127.0.0.1"), null);
|
||||
assert.equal(resolveExposureWarning({}, "localhost"), null);
|
||||
assert.equal(resolveExposureWarning({}, "::1"), null);
|
||||
});
|
||||
|
||||
test("exposure warning: fires for a LAN bind too (any non-loopback interface)", () => {
|
||||
assert.ok(resolveExposureWarning({}, "192.168.0.17"));
|
||||
assert.ok(resolveExposureWarning({}, "::"));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import test from "node:test";
|
||||
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const SKILLS_DATA = [
|
||||
@@ -115,37 +114,34 @@ test("runSkillsGet busca /api/skills/:id", async () => {
|
||||
assert.equal(parsed.id, "sk_pdf");
|
||||
});
|
||||
|
||||
test("runSkillsEnable usa JSON-RPC tools/call", async () => {
|
||||
const calls: unknown[] = [];
|
||||
test("runSkillsEnable envia POST para tools/call", async () => {
|
||||
let capturedUrl = "";
|
||||
let capturedInit: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((url: string, init: any) => {
|
||||
capturedUrl = url;
|
||||
capturedInit = init;
|
||||
return Promise.resolve(makeResp({ ok: true }));
|
||||
}) 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(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(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(out.includes("sk_pdf"));
|
||||
});
|
||||
|
||||
test("runSkillsExecute usa JSON-RPC tools/call", async () => {
|
||||
const calls: unknown[] = [];
|
||||
test("runSkillsExecute envia POST com skillId e input", async () => {
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
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);
|
||||
globalThis.fetch = ((_url: string, init: any) => {
|
||||
capturedBody = JSON.parse(init.body);
|
||||
return Promise.resolve(makeResp({ result: "ok", output: "parsed" }));
|
||||
}) as any;
|
||||
|
||||
const { runSkillsExecute } = await import("../../bin/cli/commands/skills.mjs");
|
||||
@@ -154,11 +150,9 @@ test("runSkillsExecute usa JSON-RPC tools/call", async () => {
|
||||
);
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
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" });
|
||||
assert.equal(capturedBody.name, "omniroute_skills_execute");
|
||||
assert.equal(capturedBody.arguments.skillId, "sk_pdf");
|
||||
assert.deepEqual(capturedBody.arguments.input, { file: "doc.pdf" });
|
||||
});
|
||||
|
||||
test("runSkillsExecutions filtra por skill e status", async () => {
|
||||
@@ -203,9 +197,9 @@ test("runMarketplaceSearch retorna pacotes com query e filtros", async () => {
|
||||
});
|
||||
|
||||
test("runMarketplaceInstall --yes envia POST sem confirmação", async () => {
|
||||
let capturedBody: unknown = null;
|
||||
let capturedBody: any = null;
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((_url: string, init: unknown) => {
|
||||
globalThis.fetch = ((_url: string, init: any) => {
|
||||
capturedBody = JSON.parse(init?.body ?? "{}");
|
||||
return Promise.resolve(makeResp({ skillId: "sk_pdf_installed" }));
|
||||
}) as any;
|
||||
|
||||
@@ -23,11 +23,8 @@ export function unescapeWindowsShellArg(arg) {
|
||||
s = s.replace(/\^(.)/g, "$1").replace(/\^(.)/g, "$1");
|
||||
// 2. drop the wrapping quotes added by the CRT argv layer
|
||||
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1);
|
||||
// 3. undo the doubled backslashes and the escaped embedded quotes in a single
|
||||
// left-to-right pass — two sequential global replaces would let the first
|
||||
// pass's output feed the second (e.g. an escaped-backslash-then-quote
|
||||
// sequence could be misread), which is exactly what js/double-escaping flags.
|
||||
s = s.replace(/\\\\|\\"/g, (m) => (m === "\\\\" ? "\\" : '"'));
|
||||
// 3. undo the doubled backslashes and the escaped embedded quotes
|
||||
s = s.replace(/\\\\/g, "\\").replace(/\\"/g, '"');
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../../open-sse/executors/conol-web.ts";
|
||||
import {
|
||||
CONOL_FALLBACK_MODELS,
|
||||
CONOL_FALLBACK_MODEL_PRESETS,
|
||||
clampConolEffort,
|
||||
parseConolAgentServers,
|
||||
resolveConolModelSelection,
|
||||
@@ -28,11 +27,6 @@ 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(
|
||||
|
||||
@@ -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.7-flash"]) {
|
||||
for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) {
|
||||
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
@@ -8,10 +8,6 @@ function modelIds(): Set<string> {
|
||||
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) {
|
||||
|
||||
@@ -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.7-flash" || m.id.includes("gemini")));
|
||||
assert.ok(catalog.some((m) => m.id === "gemini-3.5-flash" || m.id.includes("gemini")));
|
||||
assert.ok(catalog.some((m) => m.id.includes("gpt-5.6") || m.id.includes("fable")));
|
||||
});
|
||||
});
|
||||
@@ -209,10 +209,7 @@ describe("PromptQl — helpers", () => {
|
||||
});
|
||||
|
||||
it("resolves model slugs and prefixes", () => {
|
||||
assert.equal(
|
||||
models.clientFacingPromptQlModelId("promptql/gemini-3.7-flash"),
|
||||
"gemini-3.7-flash"
|
||||
);
|
||||
assert.equal(models.clientFacingPromptQlModelId("promptql/gemini-3.5-flash"), "gemini-3.5-flash");
|
||||
assert.equal(models.clientFacingPromptQlModelId("pql/gpt-5.6-sol"), "gpt-5.6-sol");
|
||||
const r = models.resolvePromptQlModel("Claude Fable 5");
|
||||
assert.ok(r);
|
||||
@@ -268,7 +265,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.7-flash",
|
||||
model: "gemini-3.5-flash",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
@@ -282,7 +279,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.7-flash",
|
||||
model: "gemini-3.5-flash",
|
||||
body: { messages: [{ role: "assistant", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: sampleJwt },
|
||||
@@ -617,7 +614,7 @@ describe("PromptQlExecutor — mocked GraphQL turn", () => {
|
||||
try {
|
||||
const executor = new mod.PromptQlExecutor();
|
||||
const result = await executor.execute({
|
||||
model: "gemini-3.7-flash",
|
||||
model: "gemini-3.5-flash",
|
||||
body: { messages: [{ role: "user", content: "ping" }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: sampleJwt },
|
||||
@@ -631,7 +628,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.7-flash");
|
||||
assert.equal(json.model, "gemini-3.5-flash");
|
||||
assert.ok(call >= 2);
|
||||
assert.equal(result.response.headers.get("X-PromptQL-Thread-Id"), "thread-1");
|
||||
} finally {
|
||||
|
||||
76
tests/unit/gemini-3-5-flash-thinking.test.ts
Normal file
76
tests/unit/gemini-3-5-flash-thinking.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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<string, unknown> = {}) {
|
||||
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");
|
||||
});
|
||||
@@ -71,7 +71,7 @@ test("OpenAI -> Gemini request strips encrypted from Codex collaboration tool pa
|
||||
],
|
||||
};
|
||||
|
||||
const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as {
|
||||
const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as {
|
||||
tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,16 +14,6 @@ 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",
|
||||
@@ -58,12 +48,6 @@ 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");
|
||||
|
||||
@@ -56,7 +56,7 @@ test("OpenAI -> Gemini request strips strict from OpenAI-style function tool par
|
||||
],
|
||||
};
|
||||
|
||||
const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as {
|
||||
const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as {
|
||||
tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.7-flash", true, 0, credentials),
|
||||
executor.buildUrl("gemini-3.5-flash", true, 0, credentials),
|
||||
"https://ghe.company.com/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,11 +40,7 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
|
||||
"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,
|
||||
// #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/embeddings/service.ts": 2,
|
||||
"src/lib/memory/embedding/index.ts": 1,
|
||||
"src/lib/search/executeWebSearch.ts": 2,
|
||||
"src/lib/skills/webFetchExecution.ts": 1,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
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<string, string> = {}) {
|
||||
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;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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<string, string> };
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -79,49 +79,3 @@ test("findKiroConnectionByIdentity never overwrites a different authentication t
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
// #10815 — a profile ARN identifies the CodeWhisperer profile, not the account: two
|
||||
// distinct social (Google/GitHub) Builder ID accounts share the same ARN, so matching
|
||||
// on it alone made the second login overwrite the first connection.
|
||||
const SHARED_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:1:profile/SHARED";
|
||||
|
||||
const firstSocialAccount = {
|
||||
id: "social-account-1",
|
||||
authType: "oauth",
|
||||
name: null,
|
||||
email: null,
|
||||
providerSpecificData: {
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
authMethod: "imported",
|
||||
provider: "Github",
|
||||
},
|
||||
};
|
||||
|
||||
test("findKiroConnectionByIdentity does not match a shared profile ARN without an account identifier", () => {
|
||||
const match = findKiroConnectionByIdentity([firstSocialAccount], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: null,
|
||||
});
|
||||
assert.equal(match, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByIdentity treats diverging emails on a shared profile ARN as distinct accounts", () => {
|
||||
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
|
||||
const match = findKiroConnectionByIdentity([stored], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: "b@example.com",
|
||||
});
|
||||
assert.equal(match, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByIdentity still matches the same account on a shared profile ARN", () => {
|
||||
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
|
||||
const match = findKiroConnectionByIdentity([stored], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: "a@example.com",
|
||||
});
|
||||
assert.equal(match?.id, "social-a");
|
||||
});
|
||||
|
||||
@@ -279,7 +279,6 @@ 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" },
|
||||
],
|
||||
});
|
||||
@@ -290,13 +289,8 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async (
|
||||
const mitmMappings = await modelsDb.getMitmAlias("antigravity");
|
||||
console.log("MITM MAPPINGS IN TEST:", mitmMappings);
|
||||
|
||||
// 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");
|
||||
// Should contain standard mapping
|
||||
assert.equal(mitmMappings["gemini-3.5-flash"], "antigravity/gemini-3.5-flash");
|
||||
assert.equal(mitmMappings["custom-antigravity-model"], "antigravity/custom-antigravity-model");
|
||||
|
||||
// Removed Antigravity 2.0 preview/agent aliases must not be reintroduced.
|
||||
|
||||
@@ -154,14 +154,21 @@ test("unknown models keep maxOutputTokens null instead of using a generic defaul
|
||||
);
|
||||
});
|
||||
|
||||
test("retired Gemini 3.5 Flash IDs have no provider-neutral model specs", () => {
|
||||
test("provider-neutral Gemini 3.5 tier IDs retain their non-thinking capabilities", () => {
|
||||
for (const modelId of [
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.5-flash-extra-low",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-3-flash-agent",
|
||||
]) {
|
||||
assert.equal(MODEL_SPECS[modelId], undefined, modelId);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -702,7 +702,6 @@ 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user