mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
7 Commits
release/v3
...
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 @@
|
||||
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming
|
||||
@@ -1 +0,0 @@
|
||||
- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 1–1,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018))
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6866,11 +6866,7 @@ paths:
|
||||
Returns a structured JSON catalog parsed from this `openapi.yaml`,
|
||||
including info, servers, tags, schemas, and a flat list of endpoints
|
||||
(method, path, tags, summary, security, parameters, responses).
|
||||
Used by the in-app API explorer. When `requireLogin` is enabled, this
|
||||
management endpoint requires an authenticated dashboard session;
|
||||
otherwise it is available without authentication.
|
||||
security:
|
||||
- ManagementSessionAuth: []
|
||||
Used by the in-app API explorer.
|
||||
responses:
|
||||
"200":
|
||||
description: Parsed OpenAPI catalog
|
||||
@@ -6924,10 +6920,6 @@ paths:
|
||||
type: string
|
||||
"404":
|
||||
description: openapi.yaml file not found on disk
|
||||
"401":
|
||||
$ref: "#/components/responses/ManagementAuthenticationRequired"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagementInvalidToken"
|
||||
"500":
|
||||
description: Failed to parse OpenAPI spec
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Database Schema & Operations Guide"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-23
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
---
|
||||
|
||||
# Database Schema & Operations Guide
|
||||
@@ -43,17 +43,12 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("busy_timeout = 2000");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
|
||||
// Settings > System & Storage > Cache Size is applied as KiB.
|
||||
db.pragma("cache_size = -16384");
|
||||
```
|
||||
|
||||
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
|
||||
|
||||
The default cache size is **65,536 KiB (64 MiB)**. SQLite interprets a negative
|
||||
`cache_size` as an approximate upper bound in KiB and allocates pages on demand.
|
||||
**Settings > System & Storage > Cache Size** accepts integer values from **1 to
|
||||
1,000,000 KiB**; saving the setting applies it to the live database connection,
|
||||
and OmniRoute restores the persisted value at startup.
|
||||
|
||||
---
|
||||
|
||||
## Database Location
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -20,7 +20,6 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
|
||||
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
|
||||
import {
|
||||
shouldDefaultAllowClassifier,
|
||||
detectClassifierFormat,
|
||||
buildDefaultAllowClaudeMessage,
|
||||
} from "./chatCore/claudeClassifierCompat.ts";
|
||||
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
|
||||
@@ -380,7 +379,6 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
||||
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
|
||||
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
@@ -780,12 +778,11 @@ export async function handleChatCore({
|
||||
classifierSettings.claudeClassifierCompat as string | undefined
|
||||
)
|
||||
) {
|
||||
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
|
||||
log?.warn?.(
|
||||
"CHAT",
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
|
||||
);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4913,14 +4910,12 @@ export async function handleChatCore({
|
||||
|
||||
// Translate response to client's expected format (usually OpenAI)
|
||||
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
|
||||
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
|
||||
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
|
||||
? translateNonStreamingResponse(
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
clientResponseFormat,
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
responseToolNameMap
|
||||
)
|
||||
: responseBody;
|
||||
const memoryExtractionResponse = translatedResponse;
|
||||
@@ -4947,8 +4942,7 @@ export async function handleChatCore({
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
responseToolNameMap
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
@@ -5471,8 +5465,7 @@ export async function handleChatCore({
|
||||
streamBody,
|
||||
clientResponseFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap,
|
||||
extractToolSchemaMap(finalBody || translatedBody || body)
|
||||
responseToolNameMap
|
||||
) as Record<string, unknown>)
|
||||
: streamBody;
|
||||
const choices = cacheStreamBody.choices as
|
||||
|
||||
@@ -24,19 +24,14 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
|
||||
|
||||
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
|
||||
|
||||
/** The two synthetic-response shapes Claude Code's classifier can expect. */
|
||||
export type ClaudeClassifierFormat = "block" | "severity";
|
||||
|
||||
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
|
||||
const system = body?.system;
|
||||
if (typeof system === "string") return [system];
|
||||
if (Array.isArray(system)) {
|
||||
return system
|
||||
.map((part) =>
|
||||
part && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
|
||||
? ((part as { text: string }).text)
|
||||
: ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
@@ -65,29 +60,6 @@ export function shouldDefaultAllowClassifier(
|
||||
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which synthetic-response shape the classifier request expects.
|
||||
*
|
||||
* Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
|
||||
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
|
||||
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
|
||||
* stages and then fails closed — the same "blocking it for safety" failure this compat
|
||||
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
|
||||
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
|
||||
* the request is the classifier (via the system-prompt marker), so an unrelated app
|
||||
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
|
||||
*/
|
||||
export function detectClassifierFormat(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
): ClaudeClassifierFormat {
|
||||
const stopSequences = body?.stop_sequences;
|
||||
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
|
||||
return "severity";
|
||||
}
|
||||
return "block";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
|
||||
* body (matching the upstream reference implementation) — Claude Code's classifier
|
||||
@@ -95,10 +67,7 @@ export function detectClassifierFormat(
|
||||
* satisfies both streaming and non-streaming callers without needing to plumb a
|
||||
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
|
||||
*/
|
||||
export function buildDefaultAllowClaudeMessage(
|
||||
model?: string | null,
|
||||
format: ClaudeClassifierFormat = "block"
|
||||
): {
|
||||
export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
success: true;
|
||||
response: Response;
|
||||
} {
|
||||
@@ -107,12 +76,7 @@ export function buildDefaultAllowClaudeMessage(
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: model || "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: "<block>no</block>" }],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
|
||||
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
|
||||
import { stripEmptyOptionalToolArgs } from "../translator/response/openai-responses/pureHelpers.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -136,28 +135,24 @@ function findBestMessageText(output: unknown[]): {
|
||||
* Handles different provider response formats (Gemini, Claude, etc.)
|
||||
*
|
||||
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
|
||||
* @param toolSchemas - Optional Map<toolName, parametersSchema> for schema-aware optional-arg cleanup
|
||||
*/
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: JsonRecord,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
toolNameMap?: Map<string, string> | null
|
||||
): JsonRecord;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
toolNameMap?: Map<string, string> | null
|
||||
): unknown;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
toolNameMap?: Map<string, string> | null
|
||||
): unknown {
|
||||
// If already in source format, return as-is
|
||||
if (targetFormat === sourceFormat) {
|
||||
@@ -224,11 +219,6 @@ export function translateNonStreamingResponse(
|
||||
toString(itemObj.id) ||
|
||||
`call_${Date.now()}_${toolCalls.length}`;
|
||||
let argsToEmit = itemObj.arguments;
|
||||
const rawName = toString(itemObj.name);
|
||||
const toolSchema = toolSchemas?.get(rawName);
|
||||
if (toolSchema) {
|
||||
argsToEmit = stripEmptyOptionalToolArgs(argsToEmit, rawName, toolSchema);
|
||||
}
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
@@ -239,6 +229,7 @@ export function translateNonStreamingResponse(
|
||||
|
||||
const fnArgs =
|
||||
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
|
||||
const rawName = toString(itemObj.name);
|
||||
// Strip Claude OAuth proxy_ prefix using toolNameMap
|
||||
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
|
||||
toolCalls.push({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -12,21 +12,7 @@
|
||||
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
|
||||
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
|
||||
*
|
||||
* `clampToLearned` implements nearest-tier clamping: smallest accepted >= demand,
|
||||
* falling back to the greatest accepted when demand exceeds every accepted value.
|
||||
* (#11295 — unified with the static "declared" clamp in
|
||||
* `executors/base/reasoningEffort.ts`, which already used nearest-tier semantics.
|
||||
* Before #11295, this learned clamp was downgrade-only — greatest accepted <=
|
||||
* demand — so the SAME accepted set {low,high,max} produced medium→low here but
|
||||
* medium→high via the declared path: identical inputs, opposite outputs,
|
||||
* depending only on whether the model had a static registry entry. #11274's
|
||||
* DeepSeek native mapping is the precedent for nearest-tier. This also fixes a
|
||||
* standalone bug: a request BELOW the learned floor (e.g. none/minimal on a
|
||||
* model that only ever advertised {low,high,max}) used to return null — no
|
||||
* clamp — so the too-low value passed straight through to the upstream, which
|
||||
* 400'd again on every subsequent request without ever learning a lower floor.
|
||||
* Nearest-tier naturally fixes this too: the smallest accepted value is always
|
||||
* >= any demand below the floor, so it is returned instead of null.
|
||||
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
|
||||
*
|
||||
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
|
||||
* restart resets, the first request after a restart may re-learn at the cost of
|
||||
@@ -146,39 +132,25 @@ export function recordLearnedReasoningEffort(
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the nearest-tier accepted value for effortStr: the smallest accepted
|
||||
* value with rank >= effortStr's rank, or — when effortStr's rank exceeds every
|
||||
* accepted value (demand above the learned ceiling) — the greatest accepted
|
||||
* value. Returns null only when effortStr is already accepted (no clamp
|
||||
* needed), empty, or not a recognized member of REASONING_EFFORT_ORDER.
|
||||
*
|
||||
* Mirrors the declared-capability clamp in `executors/base/reasoningEffort.ts`
|
||||
* (#11295): both now use nearest-tier semantics so the same accepted set
|
||||
* produces the same mapping regardless of whether the model has a static
|
||||
* registry entry or was only learned reactively from an upstream 4xx.
|
||||
* Return the greatest accepted value <= effortStr (downgrade only), or null
|
||||
* if effortStr is already accepted, below the minimum, or not in ORDER.
|
||||
*/
|
||||
export function clampToLearned(effortStr: string, accepted: Set<string>): string | null {
|
||||
if (!effortStr || accepted.has(effortStr)) return null;
|
||||
const rank = rankOf(effortStr);
|
||||
if (rank === -1) return null;
|
||||
|
||||
let nearestAbove: string | null = null;
|
||||
let nearestAboveRank = Infinity;
|
||||
let highest: string | null = null;
|
||||
let highestRank = -1;
|
||||
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
|
||||
if (rank < minRank) return null;
|
||||
let best: string | null = null;
|
||||
let bestRank = -1;
|
||||
for (const v of accepted) {
|
||||
const r = rankOf(v);
|
||||
if (r < 0) continue;
|
||||
if (r >= rank && r < nearestAboveRank) {
|
||||
nearestAboveRank = r;
|
||||
nearestAbove = v;
|
||||
}
|
||||
if (r > highestRank) {
|
||||
highestRank = r;
|
||||
highest = v;
|
||||
if (r <= rank && r > bestRank) {
|
||||
bestRank = r;
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return nearestAbove ?? highest;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -290,31 +290,6 @@ export function coerceToolSchemas(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
const NULL_OMISSION_NOTE = "null = omit this parameter";
|
||||
|
||||
function schemaTypeIncludes(type: unknown, wanted: string): boolean {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function isPlainStringType(type: unknown): boolean {
|
||||
return type === "string" || (Array.isArray(type) && type.length === 1 && type[0] === "string");
|
||||
}
|
||||
|
||||
function appendNullOmissionMarker(description: unknown): string {
|
||||
if (typeof description === "string" && description.length > 0) {
|
||||
return description.includes(NULL_OMISSION_NOTE)
|
||||
? description
|
||||
: `${description} (${NULL_OMISSION_NOTE})`;
|
||||
}
|
||||
return NULL_OMISSION_NOTE;
|
||||
}
|
||||
|
||||
function widenTypeWithNull(type: unknown): unknown {
|
||||
if (typeof type === "string") return [type, "null"];
|
||||
if (Array.isArray(type) && !type.includes("null")) return [...type, "null"];
|
||||
return type;
|
||||
}
|
||||
|
||||
// #7023 — Responses API strict mode forces every "optional" tool property into
|
||||
// `required`, so a model that intends to OMIT an optional enum property (no declared
|
||||
// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither
|
||||
@@ -324,11 +299,7 @@ function widenTypeWithNull(type: unknown): unknown {
|
||||
// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level
|
||||
// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches
|
||||
// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro).
|
||||
function shouldInjectNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set<string>): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
@@ -341,38 +312,19 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
const widened: JsonRecord = { ...propSchema };
|
||||
const enumValues = propSchema.enum as unknown[];
|
||||
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
|
||||
widened.type = widenTypeWithNull(propSchema.type);
|
||||
widened.description = appendNullOmissionMarker(propSchema.description);
|
||||
if (typeof propSchema.type === "string") {
|
||||
widened.type = [propSchema.type, "null"];
|
||||
} else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) {
|
||||
widened.type = [...propSchema.type, "null"];
|
||||
}
|
||||
const note = "null = omit this parameter";
|
||||
widened.description =
|
||||
typeof propSchema.description === "string" && propSchema.description.length > 0
|
||||
? `${propSchema.description} (${note})`
|
||||
: note;
|
||||
return widened;
|
||||
}
|
||||
|
||||
// OpenCode `subagent.sessionID` (and any other optional default-less plain string) has
|
||||
// the same strict-mode omission problem as #7023 enums, but no enum to widen. Inject
|
||||
// the same nullable-union sentinel on top-level `properties[key]` only — do not recurse
|
||||
// into `items`/`anyOf`/`$defs`, and do not touch enums (owned by the helper above).
|
||||
function shouldInjectStringNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
!Array.isArray(propSchema.enum) &&
|
||||
isPlainStringType(propSchema.type) &&
|
||||
!schemaTypeIncludes(propSchema.type, "null") &&
|
||||
!required.has(key) &&
|
||||
!hasOwn(propSchema, "default")
|
||||
);
|
||||
}
|
||||
|
||||
function widenStringPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
return {
|
||||
...propSchema,
|
||||
type: widenTypeWithNull(propSchema.type),
|
||||
description: appendNullOmissionMarker(propSchema.description),
|
||||
};
|
||||
}
|
||||
|
||||
export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
@@ -404,43 +356,6 @@ export function injectOptionalEnumOmissionForTools(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
let changed = false;
|
||||
const nextProperties: JsonRecord = { ...schema.properties };
|
||||
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
if (!shouldInjectStringNullOmission(key, propSchema, required)) continue;
|
||||
nextProperties[key] = widenStringPropertyForNullOmission(propSchema as JsonRecord);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return schema;
|
||||
return { ...schema, properties: nextProperties };
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionForTools(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
if (isPlainObject(result.function) && "parameters" in result.function) {
|
||||
result.function = {
|
||||
...result.function,
|
||||
parameters: injectOptionalStringOmissionSentinel(result.function.parameters),
|
||||
};
|
||||
}
|
||||
if ("parameters" in result && !isPlainObject(result.function)) {
|
||||
result.parameters = injectOptionalStringOmissionSentinel(result.parameters);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeToolDescriptions(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => sanitizeToolDescription(tool));
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
injectOptionalEnumOmissionForTools,
|
||||
injectOptionalStringOmissionForTools,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
@@ -596,12 +595,6 @@ export function translateRequest(
|
||||
}
|
||||
|
||||
if (result.tools !== undefined) {
|
||||
// Plain-string omission must run before coerceToolSchemas() strips `default`,
|
||||
// so defaulted optional strings stay unsentinelled. Enum injection stays after
|
||||
// coercion to preserve the #7023 pipeline.
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
result.tools = injectOptionalStringOmissionForTools(result.tools);
|
||||
}
|
||||
result.tools = coerceToolSchemas(result.tools);
|
||||
result.tools = sanitizeToolDescriptions(result.tools);
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
|
||||
@@ -866,13 +866,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (!chunk) {
|
||||
// Iterate every still-open call with a buffered argument payload — argument
|
||||
// deltas are buffered for every tool, so an incomplete stream must flush every
|
||||
// buffered call, not only the historical uppercase Agent path.
|
||||
// Iterate every still-open call needing schema-aware normalization, not just a
|
||||
// single one — multiple parallel calls can each be pending here if the stream
|
||||
// ends before their output_item.done arrives.
|
||||
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
|
||||
if (state.toolCallByCallId instanceof Map) {
|
||||
for (const entry of state.toolCallByCallId.values()) {
|
||||
if (entry.argsBuffer) {
|
||||
if (entry.needsNormalization && entry.argsBuffer) {
|
||||
const toolSchema = state.toolSchemas?.get(entry.name);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
|
||||
pendingNormalized.push({
|
||||
|
||||
@@ -56,35 +56,21 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
|
||||
return allowlisted || (propSchema != null && !required.has(key));
|
||||
}
|
||||
|
||||
function schemaTypeIncludes(type, wanted) {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function hasOmissionSentinel(propSchema) {
|
||||
if (!propSchema || typeof propSchema !== "object") return false;
|
||||
if (
|
||||
typeof propSchema.description !== "string" ||
|
||||
!propSchema.description.includes("null = omit this parameter")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
schemaTypeIncludes(propSchema.type, "null") ||
|
||||
(Array.isArray(propSchema.enum) && propSchema.enum.includes(null))
|
||||
);
|
||||
}
|
||||
|
||||
// #7023 — the request-side counterpart widens no-default optional properties to accept
|
||||
// `null`, meaning "omitted" (OpenAI's own nullable-union idiom for Responses-API strict
|
||||
// mode). Enums use injectOptionalEnumOmissionSentinel; plain strings use
|
||||
// injectOptionalStringOmissionSentinel. Drop the key when the model follows that idiom
|
||||
// for a non-required, schema-declared property, or when OmniRoute's marker is present
|
||||
// even after an upstream strictifies the field into `required`.
|
||||
// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens
|
||||
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
|
||||
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
|
||||
// follows that idiom for a non-required, schema-declared property.
|
||||
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
|
||||
if (entry !== null) return false;
|
||||
if (toolName === "Agent") return true;
|
||||
if (propSchema == null) return false;
|
||||
return !required.has(key) || hasOmissionSentinel(propSchema);
|
||||
const omissionSentinel =
|
||||
typeof propSchema === "object" &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
propSchema.enum.includes(null) &&
|
||||
typeof propSchema.description === "string" &&
|
||||
propSchema.description.includes("null = omit this parameter");
|
||||
return !required.has(key) || omissionSentinel;
|
||||
}
|
||||
|
||||
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
|
||||
@@ -124,11 +110,7 @@ export function stripEmptyOptionalToolArgs(value, toolName, schema) {
|
||||
// supplied (schema-aware normalization is not restricted to the allowlist).
|
||||
// "Agent" also passes without a schema: isDroppableNullEntry drops its null
|
||||
// omission sentinels even when the strict schema snapshot is unavailable (#9423).
|
||||
if (
|
||||
!hasUsableSchema(schema) &&
|
||||
!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) &&
|
||||
toolName !== "Agent"
|
||||
) {
|
||||
if (!hasUsableSchema(schema) && !STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) && toolName !== "Agent") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -75,3 +75,76 @@ omniroute mcp call <tool> [argsJson]
|
||||
```bash
|
||||
omniroute mcp scopes
|
||||
```
|
||||
|
||||
### `mcp tools`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tools
|
||||
```
|
||||
|
||||
### `mcp list`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--scope <s>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp list
|
||||
```
|
||||
|
||||
### `mcp info <name>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp info <name>
|
||||
```
|
||||
|
||||
### `mcp schema <name>`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--io <kind>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp schema <name>
|
||||
```
|
||||
|
||||
### `mcp audit`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp audit
|
||||
```
|
||||
|
||||
### `mcp tail`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--follow`
|
||||
- `--limit <n>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tail
|
||||
```
|
||||
|
||||
### `mcp stats`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--period <p>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp stats
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -439,22 +439,13 @@ type ProviderConnectionLike = {
|
||||
* whose stored `providerSpecificData.profileArn` matches the given ARN.
|
||||
* Returns null when profileArn is undefined/null or no match is found.
|
||||
*
|
||||
* #10815 hardened `findKiroConnectionByIdentity` to require an account-level
|
||||
* identifier (email or clientId) alongside a matching profileArn before
|
||||
* trusting the match — distinct Builder ID accounts (Google/GitHub social
|
||||
* login) can share the same CodeWhisperer profile ARN, and matching on ARN
|
||||
* alone let a second social login silently overwrite the first connection.
|
||||
* `email`/`clientId` here let a caller supply that account identifier; the
|
||||
* real `saveAndRespond()` call sites already do (see below).
|
||||
*
|
||||
* Exported for unit tests (#3615).
|
||||
*/
|
||||
export function findKiroConnectionByProfileArn(
|
||||
connections: ProviderConnectionLike[],
|
||||
profileArn: string | undefined,
|
||||
accountIdentity?: { email?: string | null; clientId?: string | null }
|
||||
profileArn: string | undefined
|
||||
): ProviderConnectionLike | null {
|
||||
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
|
||||
return findKiroConnectionByIdentity(connections, { profileArn });
|
||||
}
|
||||
|
||||
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import { canUpdateProviderApiKey } from "@/shared/providers/webSessionCredential
|
||||
import {
|
||||
refreshConnectionRateLimits,
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
} from "@/../open-sse/services/rateLimitManager";
|
||||
import {
|
||||
finalizeValidatedChatGptWebCodexSecrets,
|
||||
@@ -343,18 +342,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
// If rateLimitOverrides was included in the request, refresh the in-memory
|
||||
// rate limiter state so the change takes effect without a server restart.
|
||||
// Only (re)enable enforcement when rate limit protection is actually
|
||||
// persisted for this connection — this route never lets a caller flip
|
||||
// `rateLimitProtection` itself, so any drift here would silently start
|
||||
// queuing requests through Bottleneck for a connection whose DB row (and
|
||||
// the dashboard toggle reading it) both still say "off" (#11278).
|
||||
// Also ensure rate limit protection is active so the limiter is enforced.
|
||||
if (rateLimitOverrides !== undefined) {
|
||||
refreshConnectionRateLimits(id, updated?.rateLimitOverrides ?? null);
|
||||
if (updated?.rateLimitProtection === true) {
|
||||
enableRateLimitProtection(id);
|
||||
} else {
|
||||
disableRateLimitProtection(id);
|
||||
}
|
||||
enableRateLimitProtection(id);
|
||||
}
|
||||
|
||||
// Hide sensitive fields
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1267,7 +1267,6 @@
|
||||
"agentBridgeSubtitle": "Interceptar tráfego de agentes IDE",
|
||||
"trafficInspector": "Inspector de Tráfego",
|
||||
"trafficInspectorSubtitle": "Monitorar chamadas LLM + debugar tráfego HTTPS",
|
||||
"trafficInspectorPurpose": "Veja exatamente o que sua aplicação envia e recebe dos provedores de IA. Funciona com qualquer cliente compatível com OpenAI.",
|
||||
"cliCode": "CLI Code's",
|
||||
"cliCodeSubtitle": "Ferramentas de código que apontam para o OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1869,16 +1868,7 @@
|
||||
"directDownloadHint": "Ou baixe o formato do instalador respectivo diretamente:",
|
||||
"releaseNotes": "Notas de Lançamento",
|
||||
"readMore": "Leia Mais",
|
||||
"noAuthLabel": "Sem Autenticação",
|
||||
"readinessEyebrow": "Prepare-se para rotear",
|
||||
"readinessTitle": "Envie sua primeira requisição",
|
||||
"readinessSubtitle": "Quatro pequenos passos. O OmniRoute verifica a prontidão conforme você avança.",
|
||||
"readinessStep1": "Conecte um provedor",
|
||||
"readinessStep2": "Configure a autenticação do endpoint",
|
||||
"readinessStep3": "Copie seu endpoint",
|
||||
"readinessStep4": "Envie uma requisição de teste",
|
||||
"readinessContinue": "Continuar configuração",
|
||||
"readinessDismiss": "Dispensar por agora"
|
||||
"noAuthLabel": "Sem Autenticação"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Análises",
|
||||
@@ -6710,18 +6700,6 @@
|
||||
"sidebarVisibility": "Hide sidebar items",
|
||||
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter.",
|
||||
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when a...",
|
||||
"presetAll": "Tudo",
|
||||
"presetAllDesc": "Mostrar tudo",
|
||||
"presetEssentials": "Essenciais",
|
||||
"presetEssentialsDesc": "Caminho para iniciantes - Ferramentas avançadas continuam pesquisáveis",
|
||||
"presetMinimal": "Mínimo",
|
||||
"presetMinimalDesc": "Apenas páginas principais",
|
||||
"presetDeveloper": "Desenvolvedor",
|
||||
"presetDeveloperDesc": "Ferramentas de dev & proxy",
|
||||
"presetAdmin": "Admin",
|
||||
"presetAdminDesc": "Monitoramento & auditoria",
|
||||
"settingsSidebarTitle": "Personalização da Barra Lateral",
|
||||
"settingsSidebarDesc": "Escolha quais itens da barra lateral exibir. Essenciais mantém as ferramentas avançadas pesquisáveis.",
|
||||
"hideHealthLogs": "Ocultar Logs de Health Check",
|
||||
"hideHealthLogsDesc": "Quando ATIVADO, suprime mensagens [HealthCheck] no console do servidor",
|
||||
"themeAccent": "Cor do tema",
|
||||
|
||||
@@ -1267,7 +1267,6 @@
|
||||
"agentBridgeSubtitle": "Chặn lưu lượng agent IDE",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"trafficInspectorSubtitle": "Giám sát lệnh gọi LLM + gỡ lỗi mọi lưu lượng HTTPS",
|
||||
"trafficInspectorPurpose": "Xem chính xác những gì ứng dụng của bạn gửi đến và nhận từ các nhà cung cấp AI. Hoạt động với bất kỳ ứng dụng khách nào tương thích với OpenAI.",
|
||||
"cliCode": "CLI Code",
|
||||
"cliCodeSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1869,16 +1868,7 @@
|
||||
"directDownloadHint": "Hoặc tải trực tiếp định dạng trình cài đặt phù hợp:",
|
||||
"releaseNotes": "Ghi chú phát hành",
|
||||
"readMore": "Đọc thêm",
|
||||
"noAuthLabel": "Không xác thực",
|
||||
"readinessEyebrow": "Chuẩn bị định tuyến",
|
||||
"readinessTitle": "Gửi yêu cầu đầu tiên của bạn",
|
||||
"readinessSubtitle": "Bốn bước nhỏ. OmniRoute kiểm tra mức độ sẵn sàng khi bạn thực hiện.",
|
||||
"readinessStep1": "Kết nối một nhà cung cấp",
|
||||
"readinessStep2": "Định cấu hình xác thực endpoint",
|
||||
"readinessStep3": "Sao chép endpoint của bạn",
|
||||
"readinessStep4": "Gửi một yêu cầu thử nghiệm",
|
||||
"readinessContinue": "Tiếp tục thiết lập",
|
||||
"readinessDismiss": "Bỏ qua lúc này"
|
||||
"noAuthLabel": "Không xác thực"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Phân tích",
|
||||
@@ -6710,18 +6700,6 @@
|
||||
"sidebarVisibility": "Ẩn các mục trên thanh bên",
|
||||
"sidebarVisibilityDesc": "Ẩn bất kỳ mục điều hướng nào trên thanh bên để giảm bớt sự lộn xộn về mặt trực quan mà không vô hiệu hóa bất kỳ tính năng nào",
|
||||
"sidebarVisibilityHint": "Bất kỳ phần nào trên thanh bên sẽ tự động bị ẩn khi tất cả các mục bên trong nó đều bị ẩn",
|
||||
"presetAll": "Tất cả",
|
||||
"presetAllDesc": "Hiển thị mọi thứ",
|
||||
"presetEssentials": "Thiết yếu",
|
||||
"presetEssentialsDesc": "Lộ trình cho người mới bắt đầu - Công cụ nâng cao vẫn có thể tìm kiếm",
|
||||
"presetMinimal": "Tối giản",
|
||||
"presetMinimalDesc": "Chỉ các trang cốt lõi",
|
||||
"presetDeveloper": "Nhà phát triển",
|
||||
"presetDeveloperDesc": "Công cụ dev & proxy",
|
||||
"presetAdmin": "Quản trị",
|
||||
"presetAdminDesc": "Giám sát & kiểm toán",
|
||||
"settingsSidebarTitle": "Tùy chỉnh thanh bên",
|
||||
"settingsSidebarDesc": "Chọn các mục trên thanh bên sẽ hiển thị. Thiết yếu giữ cho các công cụ nâng cao vẫn có thể tìm kiếm.",
|
||||
"hideHealthLogs": "Ẩn nhật ký kiểm tra sức khỏe",
|
||||
"hideHealthLogsDesc": "Khi BẬT, sẽ chặn các thông báo [HealthCheck] trong bảng điều khiển máy chủ",
|
||||
"themeAccent": "Màu chủ đề",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -188,32 +188,6 @@ describe("injectMemory — edge cases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectMemory — Claude-family cache-safe splice gate (#11290)", () => {
|
||||
test("does not splice mid-array on anthropic when the last turn before the splice point is plain assistant text", () => {
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{ role: "assistant", content: "turn 1 answer" },
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
});
|
||||
const memories = [makeMemory("dark mode")];
|
||||
|
||||
const result = injectMemory(request, memories, "anthropic", { cacheSafe: true });
|
||||
|
||||
// The plain-text assistant turn must stay immediately followed by the final user
|
||||
// turn — no system message spliced between them (that shape is what Opus 5 rejects
|
||||
// with HTTP 400, #11290). Memory is merged into the leading system message instead.
|
||||
expect(result.messages).toHaveLength(4);
|
||||
expect(result.messages[0].role).toBe("system");
|
||||
expect(result.messages[0].content).toContain("Memory context: dark mode");
|
||||
expect(result.messages[0].content).toContain("SYSTEM PROMPT");
|
||||
expect(result.messages[2]).toEqual({ role: "assistant", content: "turn 1 answer" });
|
||||
expect(result.messages[3]).toEqual({ role: "user", content: "turn 2 question" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInjectMemory", () => {
|
||||
test("returns true when messages are present and enabled not set", () => {
|
||||
const request = makeRequest();
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
|
||||
import { Memory } from "./types";
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
import {
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "../../shared/constants/providers";
|
||||
|
||||
const log = logger("MEMORY_INJECTION");
|
||||
|
||||
@@ -174,43 +170,6 @@ function injectSystemFirst(
|
||||
return { ...request, messages: [memorySystemMessage, ...messages] };
|
||||
}
|
||||
|
||||
/**
|
||||
* #11290: providers in the Claude family (direct Anthropic, and any
|
||||
* anthropic-compatible / Claude-Code-compatible passthrough connection) — the
|
||||
* ones affected by the stricter Opus 5 message-ordering validation described
|
||||
* below. Deliberately narrower than `systemMessageMustBeFirst()`'s strict-set:
|
||||
* this only gates the cache-safe mid-array splice, not the leading-system-message
|
||||
* requirement, so non-Claude providers keep the #3890 cache-hit optimization
|
||||
* unconditionally.
|
||||
*/
|
||||
function isClaudeFamilyProvider(provider: string | null | undefined): boolean {
|
||||
if (!provider) return false;
|
||||
const normalized = provider.toLowerCase().trim();
|
||||
return (
|
||||
normalized === "claude" ||
|
||||
normalized === "anthropic" ||
|
||||
isClaudeCodeCompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an assistant message's content ends in a server-side tool result
|
||||
* block (e.g. `web_search_tool_result`, `code_execution_tool_result`,
|
||||
* `mcp_tool_result` — any Anthropic content block whose type ends in
|
||||
* `_tool_result`, produced by a server-executed tool rather than a
|
||||
* client-executed one). `content` is typed as `string` on `ChatMessage` for
|
||||
* the common case, but the Claude-native wire shape carries an array of
|
||||
* content blocks — this only recognizes that richer shape.
|
||||
*/
|
||||
function endsWithServerToolResult(message: ChatMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") return false;
|
||||
const content = message.content as unknown;
|
||||
if (!Array.isArray(content) || content.length === 0) return false;
|
||||
const lastBlock = content[content.length - 1] as { type?: unknown } | null | undefined;
|
||||
return typeof lastBlock?.type === "string" && lastBlock.type.endsWith("_tool_result");
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a memory message at the #3890 cache-safe anchor (just before the last
|
||||
* user turn) when one exists, else prepend it. Shared by the system and user
|
||||
@@ -263,24 +222,6 @@ export function injectMemory(
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// #11290: Claude Opus 5 tightened server-side validation of the cache-safe
|
||||
// mid-array splice — a system message spliced right after a plain-text assistant
|
||||
// turn is rejected with HTTP 400 (the immediately preceding message must end in a
|
||||
// server-side tool result for a following system message to be accepted). Rather
|
||||
// than adding "claude"/"anthropic" outright to `systemMessageMustBeFirst()` (which
|
||||
// would revert the #3890 cache-hit optimization for every Claude request, including
|
||||
// the ones that work fine today), only fall back to the leading-system-message
|
||||
// placement for the specific requests where the turn right before the splice point
|
||||
// isn't a server tool result.
|
||||
if (
|
||||
supportsSystem &&
|
||||
cacheSafeIndex >= 0 &&
|
||||
isClaudeFamilyProvider(provider) &&
|
||||
!endsWithServerToolResult(messages[cacheSafeIndex - 1])
|
||||
) {
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// Strategy 1 (system): prepend before existing system messages, preserving the
|
||||
// caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user
|
||||
// message. Both honor the #3890 cache-safe anchor via placeMessage.
|
||||
|
||||
@@ -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,15 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_DATABASE_SETTINGS } from "../../src/types/databaseSettings.ts";
|
||||
|
||||
const guide = readFileSync(new URL("../../docs/ops/DATABASE_GUIDE.md", import.meta.url), "utf8");
|
||||
|
||||
test("database guide keeps cache tuning aligned with runtime settings (#11018)", () => {
|
||||
const defaultCacheSize = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize;
|
||||
|
||||
assert.match(guide, new RegExp(`${defaultCacheSize.toLocaleString("en-US")} KiB`));
|
||||
assert.match(guide, /1 to\s+1,000,000 KiB/);
|
||||
assert.match(guide, /saving the setting applies it to the live database connection/);
|
||||
assert.match(guide, /restores the persisted value at startup/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -25,8 +25,9 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { updateSettings } = await import("../../src/lib/db/settings.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
|
||||
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
|
||||
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
|
||||
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
|
||||
);
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -57,14 +58,6 @@ const CLASSIFIER_BODY = {
|
||||
max_tokens: 8,
|
||||
};
|
||||
|
||||
// Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
|
||||
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
|
||||
const SEVERITY_CLASSIFIER_BODY = {
|
||||
...CLASSIFIER_BODY,
|
||||
stop_sequences: ["</severity>"],
|
||||
};
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
@@ -130,12 +123,7 @@ test("detector: always does NOT fire for normal chat without classifier marker (
|
||||
|
||||
test("detector: always fires when classifier marker is present", () => {
|
||||
const classifier = {
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
|
||||
},
|
||||
],
|
||||
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
|
||||
stop_sequences: ["</block>"],
|
||||
};
|
||||
assert.equal(
|
||||
@@ -145,21 +133,6 @@ test("detector: always fires when classifier marker is present", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
|
||||
|
||||
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
|
||||
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
|
||||
});
|
||||
|
||||
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
|
||||
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
|
||||
});
|
||||
|
||||
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
|
||||
assert.equal(detectClassifierFormat({}), "block");
|
||||
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
|
||||
});
|
||||
|
||||
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
|
||||
|
||||
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
|
||||
@@ -182,16 +155,6 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
|
||||
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
|
||||
});
|
||||
|
||||
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
|
||||
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
|
||||
assert.equal(built.success, true);
|
||||
const payload = (await built.response.json()) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(text, "<severity>0</severity>");
|
||||
});
|
||||
|
||||
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
|
||||
|
||||
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
|
||||
@@ -233,44 +196,3 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
|
||||
await updateSettings({ claudeClassifierCompat: "auto" });
|
||||
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalls++;
|
||||
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/messages",
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
|
||||
assert.equal(result.success, true, "handleChatCore must report success");
|
||||
const payload = (await (result as { response: Response }).response.json()) as {
|
||||
type: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
assert.equal(payload.type, "message");
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(
|
||||
text,
|
||||
"<severity>0</severity>",
|
||||
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user