mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
44 Commits
fix/releas
...
fix/better
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cac12f5e | ||
|
|
dafb4ae808 | ||
|
|
338c05dc6a | ||
|
|
6945bbaaba | ||
|
|
690f684bfc | ||
|
|
c3cd1f94c0 | ||
|
|
c21460f22a | ||
|
|
9b14896a6c | ||
|
|
29f26293c3 | ||
|
|
cb11592441 | ||
|
|
5ee646e68e | ||
|
|
6984676d95 | ||
|
|
79f8ae9d1e | ||
|
|
04b2c47940 | ||
|
|
24ac71465e | ||
|
|
8d6f91b558 | ||
|
|
c3698eedcb | ||
|
|
adca3b881c | ||
|
|
ac02c5b42f | ||
|
|
07d1816a45 | ||
|
|
3192eb88d5 | ||
|
|
8969526437 | ||
|
|
5518916725 | ||
|
|
3daa455e1f | ||
|
|
d282ec7ad6 | ||
|
|
1e81e521c0 | ||
|
|
a3c3117254 | ||
|
|
d9a883ec53 | ||
|
|
7913447bf0 | ||
|
|
67fba53190 | ||
|
|
9cb91dee74 | ||
|
|
92f58603f9 | ||
|
|
7cec8e32fd | ||
|
|
d137368fb5 | ||
|
|
2904cf849d | ||
|
|
6158c9aeec | ||
|
|
2764812ee4 | ||
|
|
29caad9f3d | ||
|
|
14d70a755b | ||
|
|
3abbb60ec6 | ||
|
|
6d4c4843e9 | ||
|
|
12986c44c9 | ||
|
|
ab150e1f2b | ||
|
|
9adeb3b673 |
13
.env.example
13
.env.example
@@ -1027,6 +1027,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
|
||||
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
|
||||
#OMNI_COMPRESSION_WORKERS=2
|
||||
# Per-job worker timeout (ms). A timed-out worker is terminated and the request fails open.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 120000.
|
||||
#OMNI_COMPRESSION_WORKER_TIMEOUT_MS=120000
|
||||
# Terminate idle compression workers after this many milliseconds.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 60000.
|
||||
#OMNI_COMPRESSION_WORKER_IDLE_MS=60000
|
||||
|
||||
# T02 stacked-pipeline engine circuit-breaker (OPT-IN, default off). When enabled, a compression
|
||||
# engine that throws repeatedly across requests is skipped (fail-open) for a cooldown.
|
||||
# Used by: open-sse/services/compression/pipelineEngineBreaker.ts.
|
||||
@@ -2011,6 +2021,9 @@ APP_LOG_TO_FILE=true
|
||||
# CLIPROXYAPI_HOST=127.0.0.1
|
||||
# CLIPROXYAPI_PORT=5544
|
||||
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
|
||||
# Management key for an externally managed instance. Embedded instances use
|
||||
# OmniRoute's encrypted service key.
|
||||
# CLIPROXYAPI_MANAGEMENT_KEY=
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
|
||||
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/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, release/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -104,7 +104,10 @@ 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.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
// #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"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -159,11 +162,15 @@ 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.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
// #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"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `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");
|
||||
// `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");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
|
||||
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
|
||||
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
||||
- **cli**: route provider test commands through configured connection test endpoints (#10570)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { printHeading } from "../io.mjs";
|
||||
import { withRuntime } from "../runtime.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { resolveComboModels, collectModel } from "./comboModels.mjs";
|
||||
|
||||
@@ -63,15 +64,7 @@ export function extendComboSuggest(combo) {
|
||||
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
|
||||
top: opts.top,
|
||||
};
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_best_combo_for_task", arguments: body },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
|
||||
const candidates = data.candidates ?? data;
|
||||
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
|
||||
rank: i + 1,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -78,18 +79,17 @@ async function restComboStats(period) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args, restFallback) {
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
// 404 = MCP tool surface not mounted on this build; 501 = not implemented.
|
||||
// Anything else is a genuine error and we surface it.
|
||||
if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
try {
|
||||
return await mcpCallTool(name, args);
|
||||
} catch (err) {
|
||||
// Keep the REST fallback behavior for builds where the MCP surface
|
||||
// is unreachable / not mounted. Anything else rethrows as an error.
|
||||
const status = err?.status || err?.cause?.status;
|
||||
if ((status === 404 || status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function confirm(q) {
|
||||
|
||||
@@ -61,27 +61,12 @@ export function registerMcp(program) {
|
||||
? JSON.parse(argsPositional)
|
||||
: {};
|
||||
|
||||
if (opts.stream) {
|
||||
await runMcpStream(tool, args, globalOpts);
|
||||
return;
|
||||
}
|
||||
const exitCode = await runMcpCallCommand(tool, args, {
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
}, globalOpts);
|
||||
|
||||
const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {};
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: tool, arguments: args },
|
||||
headers: extraHeaders,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
process.stderr.write("Scope denied\n");
|
||||
process.exit(4);
|
||||
}
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data, globalOpts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -99,112 +84,132 @@ export function registerMcp(program) {
|
||||
const data = await res.json();
|
||||
emit(data.scopes ?? data, cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
// 5.2 — mcp tools + mcp audit
|
||||
const tools = mcp.command("tools").description(t("mcp.tools.description"));
|
||||
|
||||
tools
|
||||
.command("list")
|
||||
.description(t("mcp.tools.list.description"))
|
||||
.option("--scope <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());
|
||||
});
|
||||
}
|
||||
|
||||
async function runMcpStream(tool, args, globalOpts) {
|
||||
/**
|
||||
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
|
||||
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
|
||||
*
|
||||
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
|
||||
* When `stream` is false, returns the parsed JSON-RPC result.
|
||||
*
|
||||
* Returns the exit code (0 = success, non-zero = failure).
|
||||
*/
|
||||
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
|
||||
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
|
||||
const apiKey = globalOpts.apiKey ?? "";
|
||||
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
|
||||
const streamUrl = `${baseUrl}/api/mcp/stream`;
|
||||
|
||||
const hdrs = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
|
||||
// Step 1 — initialize
|
||||
const initRes = await fetch(streamUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ name: tool, arguments: args }),
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "omniroute-cli", version: "1.0" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`HTTP ${res.status}\n`);
|
||||
process.exit(1);
|
||||
|
||||
if (!initRes.ok) {
|
||||
const text = await initRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
|
||||
const sessionId = initRes.headers.get("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2 — tools/call
|
||||
const callHeaders = {
|
||||
...hdrs,
|
||||
"mcp-session-id": sessionId,
|
||||
};
|
||||
|
||||
const callRes = await fetch(streamUrl, {
|
||||
method: "POST",
|
||||
headers: callHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: tool, arguments: args },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!callRes.ok) {
|
||||
const text = await callRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
return readMcpSseStream(callRes.body);
|
||||
}
|
||||
|
||||
// Non-stream: parse JSON-RPC response
|
||||
const data = await callRes.json();
|
||||
if (data.error) {
|
||||
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
// Print the result content
|
||||
const content = data.result?.content;
|
||||
if (content) {
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
process.stdout.write(item.text + "\n");
|
||||
} else if (item.type === "resource") {
|
||||
process.stdout.write(JSON.stringify(item.resource) + "\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(item) + "\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function readMcpSseStream(body) {
|
||||
if (!body) return 1;
|
||||
const reader = body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
}
|
||||
}
|
||||
const lines = buf.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
|
||||
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
|
||||
}
|
||||
|
||||
export async function runMcpStatusCommand(opts = {}) {
|
||||
@@ -233,7 +238,8 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
}
|
||||
|
||||
const transport = status.transport || "stdio";
|
||||
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
const online = status.online ?? status.running;
|
||||
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
||||
if (status.scopes?.length) {
|
||||
console.log(" Scopes:");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -8,15 +9,7 @@ function fmtTs(v) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args) {
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`MCP error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return res.json();
|
||||
return mcpCallTool(name, args);
|
||||
}
|
||||
|
||||
const proxySchema = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { Argument } from "commander";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -166,14 +167,7 @@ export function registerResilience(program) {
|
||||
])
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
|
||||
process.stdout.write(`Profile: ${name}\n`);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost } from "../utils/serverHost.mjs";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -162,6 +162,15 @@ 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,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -106,14 +107,7 @@ export async function runSkillsInstall(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runSkillsEnable(id, opts, cmd) {
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true });
|
||||
process.stdout.write(`Enabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -122,14 +116,7 @@ export async function runSkillsDisable(id, opts, cmd) {
|
||||
const ok = await confirm(`Disable ${id}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false });
|
||||
process.stdout.write(`Disabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -153,16 +140,11 @@ export async function runSkillsExecute(id, opts, cmd) {
|
||||
: opts.inputFile
|
||||
? JSON.parse(readFileSync(opts.inputFile, "utf8"))
|
||||
: {};
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } },
|
||||
timeout: opts.timeout ?? 30000,
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const data = await mcpCallTool(
|
||||
"omniroute_skills_execute",
|
||||
{ skillId: id, input },
|
||||
{ timeout: opts.timeout ?? 30000 },
|
||||
);
|
||||
emit(data, globalOpts);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"testFailed": "Teste do provedor falhou: {error}",
|
||||
"loginEnabled": "Login: habilitado (senha atualizada)",
|
||||
"loginDisabled": "Login: desabilitado",
|
||||
"providerInfo": "Provedor: {info}"
|
||||
"providerInfo": "Provedor: {info}",
|
||||
"opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute Doctor",
|
||||
@@ -254,7 +255,9 @@
|
||||
"no_recovery": "Desabilitar reinício automático em crash (modo debug)",
|
||||
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
|
||||
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema"
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema",
|
||||
"tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
|
||||
127
bin/cli/mcpClient.mjs
Normal file
127
bin/cli/mcpClient.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Shared MCP JSON-RPC client for CLI commands.
|
||||
*
|
||||
* The server exposes MCP through /api/mcp/stream (Streamable HTTP transport).
|
||||
* Calling a tool requires:
|
||||
* 1. POST initialize → get Mcp-Session-Id response header
|
||||
* 2. POST tools/call with that session header
|
||||
*
|
||||
* Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is
|
||||
* not a registered route, so every MCP-backed command was broken.
|
||||
*
|
||||
* These functions route through apiFetch so CLI auth, remote contexts and
|
||||
* timeouts are handled the same way as every other management API call.
|
||||
*/
|
||||
import { apiFetch } from "./api.mjs";
|
||||
|
||||
function mcpError(message, status) {
|
||||
const err = new Error(message);
|
||||
if (status) err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
async function callMcpEndpoint(payload, { timeout, stream }) {
|
||||
const res = await apiFetch("/api/mcp/stream", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
timeout,
|
||||
acceptNotOk: true,
|
||||
headers: stream ? { Accept: "text/event-stream" } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw mcpError(
|
||||
`${payload.method} ${payload.id}: HTTP ${res.status}${text ? ` — ${text}` : ""}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP tool over /api/mcp/stream.
|
||||
*
|
||||
* Non-stream: returns the JSON-RPC result payload.
|
||||
* Stream: writes SSE `data:` chunks to stdout and returns null on success.
|
||||
*/
|
||||
export async function mcpCallTool(name, args = {}, options = {}) {
|
||||
const { timeout, scope } = options;
|
||||
const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {};
|
||||
|
||||
const initRes = await callMcpEndpoint(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "omniroute-cli", version: "1.0" },
|
||||
},
|
||||
},
|
||||
{ timeout, stream: options.stream },
|
||||
);
|
||||
|
||||
const sessionId = initRes.headers.get("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500);
|
||||
}
|
||||
|
||||
const callRes = await callMcpEndpoint(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
{ timeout, stream: options.stream },
|
||||
);
|
||||
|
||||
if (options.stream) {
|
||||
return consumeSse(callRes.body, options.onChunk);
|
||||
}
|
||||
|
||||
const data = await callRes.json();
|
||||
if (data.error) {
|
||||
const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`);
|
||||
err.code = data.error.code;
|
||||
throw err;
|
||||
}
|
||||
if (data.result?.isError) {
|
||||
const msg = data.result?.content?.[0]?.text || "unknown tool error";
|
||||
throw mcpError(`MCP error: ${msg}`, 500);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
async function consumeSse(body, onChunk) {
|
||||
if (!body) throw mcpError("MCP stream returned no body", 500);
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const flushLines = () => {
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw);
|
||||
}
|
||||
}
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
flushLines();
|
||||
}
|
||||
buf += decoder.decode();
|
||||
flushLines();
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeStdout(raw) {
|
||||
process.stdout.write(raw + "\n");
|
||||
}
|
||||
@@ -24,3 +24,34 @@ 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
changelog.d/features/10556-elevenlabs-native-routes.md
Normal file
1
changelog.d/features/10556-elevenlabs-native-routes.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556).
|
||||
1
changelog.d/features/10590-google-ai-studio-tts.md
Normal file
1
changelog.d/features/10590-google-ai-studio-tts.md
Normal file
@@ -0,0 +1 @@
|
||||
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`.
|
||||
3
changelog.d/features/11023-compression-worker-pool.md
Normal file
3
changelog.d/features/11023-compression-worker-pool.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- Run synchronous RTK and Caveman request compression in a bounded worker-thread pool, keeping
|
||||
large `/v1/responses` compression heaps outside the HTTP isolate while preserving strict
|
||||
fail-open behavior and per-engine telemetry.
|
||||
1
changelog.d/features/11282-first-run-readiness-card.md
Normal file
1
changelog.d/features/11282-first-run-readiness-card.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282))
|
||||
@@ -0,0 +1 @@
|
||||
- **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
changelog.d/features/11286-essentials-sidebar-preset.md
Normal file
1
changelog.d/features/11286-essentials-sidebar-preset.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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/features/6342-cliproxy-account-health.md
Normal file
1
changelog.d/features/6342-cliproxy-account-health.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(services): show sanitized CLIProxyAPI account health from its authenticated management API without exposing credentials, file paths, or raw account metadata (#6342)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)
|
||||
1
changelog.d/fixes/10352-github-access-token-health.md
Normal file
1
changelog.d/fixes/10352-github-access-token-health.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(github):** proactive credential health now verifies GitHub access tokens through the existing Copilot token exchange, marks only a confirmed `401 Unauthorized` as expired, and leaves rate limits, permission failures, upstream failures, and network errors routable ([#10352](https://github.com/diegosouzapw/OmniRoute/issues/10352)) — thanks @RaviTharuma
|
||||
1
changelog.d/fixes/10815-kiro-social-multi-account.md
Normal file
1
changelog.d/fixes/10815-kiro-social-multi-account.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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
changelog.d/fixes/10851-openapi-spec-auth-contract.md
Normal file
1
changelog.d/fixes/10851-openapi-spec-auth-contract.md
Normal file
@@ -0,0 +1 @@
|
||||
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.
|
||||
1
changelog.d/fixes/11271-ollama-capability-routing.md
Normal file
1
changelog.d/fixes/11271-ollama-capability-routing.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen
|
||||
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db):** group model patterns escape regex metacharacters, so `gpt-4.1*` no longer matches `gpt-4o1-preview` and a pattern like `gpt-4(*` no longer throws `SyntaxError` out of the completion and `/v1/models` paths ([#11311](https://github.com/diegosouzapw/OmniRoute/pull/11311))
|
||||
1
changelog.d/fixes/11319-upstream-proxy-host-spelling.md
Normal file
1
changelog.d/fixes/11319-upstream-proxy-host-spelling.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(db):** the upstream proxy URL check judges the host by address instead of by spelling, so `http://[::ffff:169.254.169.254]`, `[::ffff:10.0.0.5]`, ULA/link-local and CGNAT targets are refused like their dotted equivalents ([#11319](https://github.com/diegosouzapw/OmniRoute/pull/11319))
|
||||
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325))
|
||||
1
changelog.d/fixes/11326-kie-market-google-imagen-ids.md
Normal file
1
changelog.d/fixes/11326-kie-market-google-imagen-ids.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(kie):** map the remaining `google-imagen/*` KIE Market catalog ids (`nano-banana`, `nano-banana-pro`, `nano-banana-edit`) to their real, KIE-documented upstream `model` values — `#11225`'s fix only covered `nano-banana-2` ([#11326](https://github.com/diegosouzapw/OmniRoute/pull/11326)).
|
||||
1
changelog.d/fixes/11328-upstream-headers-proxy-auth.md
Normal file
1
changelog.d/fixes/11328-upstream-headers-proxy-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(security):** `proxy-authorization` and `proxy-authenticate` are refused as upstream/custom headers, so a proxy credential is no longer forwarded to the model provider — the canonical denylist now matches the RFC 7230 §6.1 set the rest of the codebase already strips ([#11328](https://github.com/diegosouzapw/OmniRoute/pull/11328))
|
||||
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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))
|
||||
@@ -0,0 +1,3 @@
|
||||
- **fix(deps):** prevent pnpm from auto-installing the unused `@lobehub/ui` peer subtree of
|
||||
`@lobehub/icons`, keeping six unneeded packages with incompatible or unverifiable license
|
||||
metadata out of production installs ([#11342](https://github.com/diegosouzapw/OmniRoute/pull/11342)).
|
||||
@@ -3445,7 +3445,7 @@
|
||||
},
|
||||
"tests/integration/qdrant-routes.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 19
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"tests/integration/quota-pools-usage.test.ts": {
|
||||
@@ -4029,10 +4029,10 @@
|
||||
},
|
||||
"tests/unit/cli-combo-suggest-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 16
|
||||
"count": 14
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-completion-dynamic.test.ts": {
|
||||
@@ -4042,7 +4042,7 @@
|
||||
},
|
||||
"tests/unit/cli-compression-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 32
|
||||
"count": 20
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-context-eng-commands.test.ts": {
|
||||
@@ -4099,7 +4099,7 @@
|
||||
},
|
||||
"tests/unit/cli-mcp-call-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 16
|
||||
"count": 10
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-memory-commands.test.ts": {
|
||||
@@ -4130,7 +4130,7 @@
|
||||
},
|
||||
"tests/unit/cli-oneproxy-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 22
|
||||
"count": 14
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -4203,9 +4203,6 @@
|
||||
"tests/unit/cli-resilience-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 16
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-runtime-extended.test.ts": {
|
||||
@@ -4238,7 +4235,7 @@
|
||||
},
|
||||
"tests/unit/cli-skills-commands.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 22
|
||||
"count": 16
|
||||
}
|
||||
},
|
||||
"tests/unit/cli-stop-supervisor-respawn-9455.test.ts": {
|
||||
@@ -6620,4 +6617,4 @@
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ lastUpdated: 2026-07-31
|
||||
|
||||
# OmniRoute Antigravity (Google One AI) Onboarding Guide
|
||||
|
||||
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
|
||||
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.7 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
|
||||
|
||||
**Official references**:
|
||||
|
||||
@@ -45,7 +45,7 @@ Both providers share the **same Google backend** — identical OAuth client, tok
|
||||
|
||||
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
|
||||
|
||||
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
|
||||
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.7-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6866,7 +6866,11 @@ 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.
|
||||
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: []
|
||||
responses:
|
||||
"200":
|
||||
description: Parsed OpenAPI catalog
|
||||
@@ -6920,6 +6924,10 @@ 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.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-23
|
||||
---
|
||||
|
||||
# Database Schema & Operations Guide
|
||||
@@ -43,12 +43,17 @@ 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");
|
||||
// Settings > System & Storage > Cache Size is applied as KiB.
|
||||
db.pragma("cache_size = -16384");
|
||||
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -531,6 +531,9 @@ detection above).
|
||||
| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. |
|
||||
| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). |
|
||||
| `OMNIROUTE_RTK_TRUST_PROJECT_FILTERS` | `0` | `open-sse/services/compression/engines/rtk/filterLoader.ts` | Trust user-managed RTK project filter rules without strict signature checks. |
|
||||
| `OMNI_COMPRESSION_WORKERS` | `2` | `open-sse/services/compression/compressionWorkerPool.ts` | Maximum concurrent synchronous RTK/Caveman workers; excess jobs wait FIFO. |
|
||||
| `OMNI_COMPRESSION_WORKER_TIMEOUT_MS` | `120000` | `open-sse/services/compression/compressionWorkerPool.ts` | Per-job timeout in milliseconds. Timed-out workers are terminated and the request fails open unchanged. |
|
||||
| `OMNI_COMPRESSION_WORKER_IDLE_MS` | `60000` | `open-sse/services/compression/compressionWorkerPool.ts` | Idle lifetime in milliseconds before an unused compression worker is terminated. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_ENABLED` | `false` | `open-sse/services/compression/pipelineEngineBreaker.ts` | T02 stacked-pipeline per-engine circuit-breaker master switch. **Opt-in (default off)** — when on, an engine that throws repeatedly across requests is skipped (fail-open) for a cooldown; off = byte-identical legacy behavior. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. |
|
||||
| `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. |
|
||||
@@ -1041,6 +1044,7 @@ desktop install.
|
||||
| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. |
|
||||
| `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). |
|
||||
| `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. |
|
||||
| `CLIPROXYAPI_MANAGEMENT_KEY` | _(empty)_ | `src/lib/services/cliproxyAccountHealth.ts` | Management key for account-health reads from an externally managed CLIProxyAPI instance. |
|
||||
| `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. |
|
||||
| `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). |
|
||||
| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). |
|
||||
|
||||
@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { createMDX } from "fumadocs-mdx/next";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
|
||||
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
|
||||
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
|
||||
import {
|
||||
@@ -138,10 +139,14 @@ const nextConfig = {
|
||||
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
|
||||
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
|
||||
...mitmManagerAliasFor(process.env),
|
||||
// Build-time stub so the bundler never traces the native better-sqlite3
|
||||
// addon into a build worker (SIGABRT at worker teardown). Runtime still
|
||||
// uses the real package via serverExternalPackages. (#10060)
|
||||
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
|
||||
// better-sqlite3 → build-time stub ONLY where the build worker actually
|
||||
// aborts while tracing the native addon (SIGABRT at worker teardown,
|
||||
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
|
||||
// be unconditional on the premise that serverExternalPackages still won
|
||||
// at runtime — it does not: resolveAlias rewrites the request before the
|
||||
// externals check, so the stub was bundled and EVERY route answered 500
|
||||
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
|
||||
...betterSqlite3AliasFor(process.env),
|
||||
...minimalBuildAliases,
|
||||
},
|
||||
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime
|
||||
|
||||
@@ -113,6 +113,7 @@ const AGY_RETIRED_MODEL_IDS = new Set([
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.5-flash-extra-low",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-3.5-flash-high",
|
||||
|
||||
@@ -179,6 +179,7 @@ const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3-flash-agent",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.5-flash-extra-low",
|
||||
"gemini-3.5-flash-low",
|
||||
"gemini-3.5-flash-high",
|
||||
|
||||
@@ -287,6 +287,19 @@ export const AUDIO_TRANSLATION_PROVIDERS: Record<string, AudioProvider> = {
|
||||
};
|
||||
|
||||
export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
google: {
|
||||
id: "google",
|
||||
credentialProviderId: "gemini",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
authType: "apikey",
|
||||
authHeader: "x-goog-api-key",
|
||||
format: "gemini-tts",
|
||||
models: [
|
||||
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS" },
|
||||
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS" },
|
||||
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS" },
|
||||
],
|
||||
},
|
||||
vertex: {
|
||||
id: "vertex",
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"gemma-4-26b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
|
||||
"gemma-4-31b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
|
||||
"gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
|
||||
"gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
|
||||
"gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 },
|
||||
"gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 },
|
||||
|
||||
@@ -186,6 +186,9 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
|
||||
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
|
||||
// providers shipping *-pro ids keep their own endpoint semantics.
|
||||
if (alias === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
|
||||
// ponytail: Claude models on Vertex use rawPredict with Anthropic Messages format,
|
||||
// not the Gemini generateContent format. Mirrors executor isClaudeModel() check.
|
||||
if ((alias === "vertex" || alias === "vp") && /^claude-/i.test(bareModelId)) return "claude";
|
||||
// Model-level targetFormat is provider-scoped: a catalog entry declares how THIS
|
||||
// provider's endpoint serves the model — do NOT import another provider's tag.
|
||||
// #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless
|
||||
|
||||
@@ -181,6 +181,29 @@ export function getRegistryEntry(provider: string): RegistryEntry | null {
|
||||
return REGISTRY[provider] || _byAlias.get(provider) || null;
|
||||
}
|
||||
|
||||
/** Resolve only a model's explicit reasoning vocabulary. */
|
||||
export function getRegistryModelThinkingEfforts(
|
||||
provider: string,
|
||||
modelId: string
|
||||
): readonly string[] | undefined {
|
||||
const entry = getRegistryEntry(provider);
|
||||
if (!entry) return undefined;
|
||||
const model = entry.models.find((candidate) => candidate.id === modelId);
|
||||
return model?.supportedThinkingEfforts;
|
||||
}
|
||||
|
||||
/** Resolve a model's explicit reasoning vocabulary before its provider fallback. */
|
||||
export function getRegistryThinkingEfforts(
|
||||
provider: string,
|
||||
modelId: string
|
||||
): readonly string[] | undefined {
|
||||
const entry = getRegistryEntry(provider);
|
||||
if (!entry) return undefined;
|
||||
const modelEfforts = getRegistryModelThinkingEfforts(provider, modelId);
|
||||
if (modelEfforts !== undefined) return modelEfforts;
|
||||
return entry.defaultSupportedThinkingEfforts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a non-empty live catalog may exclude omitted static models
|
||||
* during request routing and wildcard expansion.
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
modelsUrl: "https://ollama.com/api/tags",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
defaultSupportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
|
||||
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
|
||||
// Users can generate API keys at https://ollama.com/settings/keys
|
||||
models: [
|
||||
@@ -24,23 +25,20 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
// #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across
|
||||
// its reasoning-capable models (see supportsMaxEffortForProvider's
|
||||
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) —
|
||||
// declare supportedThinkingEfforts so appendSyncedEffortVariants() (which
|
||||
// runs before static-model capability enrichment) can synthesize the
|
||||
// catalog's selectable -low/-high/-max variant ids for these models.
|
||||
// #10788: these models accept none|low|medium|high|max. Keep their explicit
|
||||
// declarations aligned with the provider fallback so the static and synced
|
||||
// catalog paths expose the same native vocabulary.
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
|
||||
},
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
|
||||
@@ -50,14 +48,14 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
name: "GLM 5.1",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
supportedThinkingEfforts: ["none", "low", "medium", "high", "max"],
|
||||
},
|
||||
// #3110: MiniMax M3 via Ollama
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
|
||||
@@ -219,5 +219,18 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportedThinkingEfforts: ["none", "low", "high", "max"],
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
// Console Go free GLM-tier model (live-verified 2026-08-23): the upstream
|
||||
// rejects every reasoning_effort outside {low, high, max} whenever tools
|
||||
// are present — "[1210] This model always engages in thinking and cannot
|
||||
// be disabled; please use low, high, or max" — which broke clients that
|
||||
// default to reasoning_effort:"medium" (Hermes). Declaring the exact
|
||||
// vocabulary lets sanitizeReasoningEffortForProvider clamp off-vocabulary
|
||||
// requests to the nearest accepted tier instead of burning a 400.
|
||||
{
|
||||
id: "ox-alpha-free",
|
||||
name: "ox-alpha (free)",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "high", "max"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -27,8 +27,17 @@ export const vertexProvider: RegistryEntry = {
|
||||
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" },
|
||||
{ id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" },
|
||||
{ id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" },
|
||||
{ id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Vertex)", targetFormat: "claude" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Vertex)", targetFormat: "claude" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
};
|
||||
|
||||
@@ -13,10 +13,17 @@ export const vertex_partnerProvider: RegistryEntry = {
|
||||
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
|
||||
{ id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" },
|
||||
{ id: "GLM-5.1-FP8", name: "GLM 5.1" },
|
||||
// Sweep 2026-06-19: + Claude Opus on Vertex (Anthropic partner models).
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", targetFormat: "claude" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", targetFormat: "claude" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", targetFormat: "claude" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", targetFormat: "claude" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -139,6 +139,9 @@ export interface RegistryEntry {
|
||||
requestDefaults?: ProviderRequestDefaults;
|
||||
oauth?: RegistryOAuth;
|
||||
models: RegistryModel[];
|
||||
/** Provider-native reasoning vocabulary for reasoning-capable passthrough models
|
||||
* that do not have an explicit per-model declaration. */
|
||||
defaultSupportedThinkingEfforts?: readonly string[];
|
||||
modelsUrl?: string;
|
||||
/** Prefix to prepend to model IDs before upstream API calls (e.g. "accounts/fireworks/models/") */
|
||||
modelIdPrefix?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
getLearnedReasoningEffort,
|
||||
clampToLearned,
|
||||
REASONING_EFFORT_ORDER,
|
||||
} from "../../services/learnedReasoningEffortCaps.ts";
|
||||
|
||||
/**
|
||||
@@ -357,6 +358,43 @@ export function sanitizeReasoningEffortForProvider(
|
||||
}
|
||||
}
|
||||
|
||||
// ── explicit per-model capability clamp ──────────────────────────────────
|
||||
// When the registry declares supportedThinkingEfforts for this exact model
|
||||
// and the requested effort falls outside that vocabulary, remap to the
|
||||
// nearest declared tier: the smallest ranked value ≥ the request, else the
|
||||
// highest declared (a request above the ceiling lands on the ceiling).
|
||||
// Live case: opencode-go/ox-alpha-free (Console Go) only accepts
|
||||
// {low, high, max} — a client's reasoning_effort:"medium" reached the
|
||||
// upstream verbatim and 400'd every turn ("[1210] This model always engages
|
||||
// in thinking and cannot be disabled; please use low, high, or max"). The
|
||||
// learned-caps path can't help here (it only clamps down from xhigh/max,
|
||||
// and this error text isn't a parseable enum), so the declaration is the
|
||||
// only source of truth. Models without an explicit declaration keep
|
||||
// #8057's trust-the-upstream pass-through.
|
||||
const providerModelIdForClamp = modelStr.startsWith(`${provider}/`)
|
||||
? modelStr.slice(provider.length + 1)
|
||||
: modelStr;
|
||||
const declaredEfforts = getProviderModels(provider).find(
|
||||
(entry) => entry.id === providerModelIdForClamp || entry.aliases?.includes(providerModelIdForClamp)
|
||||
)?.supportedThinkingEfforts;
|
||||
const declaredRanked = (
|
||||
Array.isArray(declaredEfforts) ? declaredEfforts : []
|
||||
)
|
||||
.map((tier) => ({ tier, rank: REASONING_EFFORT_ORDER.indexOf(tier) }))
|
||||
.filter((x) => x.rank >= 0)
|
||||
.sort((a, b) => a.rank - b.rank);
|
||||
if (declaredRanked.length > 0 && !declaredEfforts!.includes(effortStr)) {
|
||||
const requestedRank = REASONING_EFFORT_ORDER.indexOf(effortStr);
|
||||
const nearest =
|
||||
declaredRanked.find((x) => x.rank >= requestedRank) ??
|
||||
declaredRanked[declaredRanked.length - 1];
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr} → ${nearest.tier} (model accepts ${declaredEfforts!.join("/")})`
|
||||
);
|
||||
return writeEffortValue(b, nearest.tier, c);
|
||||
}
|
||||
|
||||
const supportsXHigh = supportsXHighEffort(provider, modelStr);
|
||||
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
|
||||
|
||||
|
||||
81
open-sse/executors/geminiTts.ts
Normal file
81
open-sse/executors/geminiTts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { extractInlineAudio, parsePcmSampleRate, pcmToWav } from "./vertexMedia.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
import { upstreamErrorResponse } from "../utils/audioResponse.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
|
||||
type GeminiTtsCredentials = {
|
||||
apiKey?: string | null;
|
||||
accessToken?: string | null;
|
||||
};
|
||||
|
||||
export class GeminiTtsUpstreamError extends Error {
|
||||
constructor(
|
||||
public readonly response: Response,
|
||||
public readonly body: string
|
||||
) {
|
||||
super(`Gemini TTS upstream error (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function geminiGenerateSpeech(
|
||||
credentials: GeminiTtsCredentials,
|
||||
options: { model: string; text: string; voice: string }
|
||||
): Promise<Buffer> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (credentials.apiKey) {
|
||||
headers["x-goog-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers.Authorization = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(options.model)}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: options.text }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: { voiceName: options.voice },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new GeminiTtsUpstreamError(response, await response.text());
|
||||
}
|
||||
|
||||
const inline = extractInlineAudio(await response.json());
|
||||
if (!inline) throw new Error("Gemini TTS response did not contain audio data");
|
||||
return pcmToWav(Buffer.from(inline.base64, "base64"), parsePcmSampleRate(inline.mimeType));
|
||||
}
|
||||
|
||||
export async function handleGeminiTtsSpeech(
|
||||
credentials: GeminiTtsCredentials,
|
||||
options: { model: string; text: string; voice?: unknown }
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const wav = await geminiGenerateSpeech(credentials, {
|
||||
model: options.model,
|
||||
text: options.text,
|
||||
voice:
|
||||
typeof options.voice === "string" && options.voice.trim() ? options.voice.trim() : "Kore",
|
||||
});
|
||||
return new Response(new Uint8Array(wav), {
|
||||
status: 200,
|
||||
headers: { ...CORS_HEADERS, "Content-Type": "audio/wav" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof GeminiTtsUpstreamError) {
|
||||
return upstreamErrorResponse(error.response, error.body);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse(500, `Speech request failed: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
ExecuteInput,
|
||||
@@ -13,6 +15,11 @@ 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.
|
||||
*
|
||||
@@ -329,7 +336,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-request-id":
|
||||
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
crypto.randomUUID?.() || randomIdFallback(),
|
||||
};
|
||||
|
||||
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
|
||||
@@ -338,7 +345,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?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
crypto.randomUUID?.() || randomIdFallback();
|
||||
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
|
||||
headers["x-client-session-id"] =
|
||||
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();
|
||||
|
||||
@@ -156,13 +156,13 @@ export function pcmToWav(
|
||||
return Buffer.concat([header, pcm]);
|
||||
}
|
||||
|
||||
function parseSampleRate(mimeType: string | undefined): number {
|
||||
export function parsePcmSampleRate(mimeType: string | undefined): number {
|
||||
if (!mimeType) return 24000;
|
||||
const match = /rate=(\d+)/i.exec(mimeType);
|
||||
return match ? parseInt(match[1], 10) : 24000;
|
||||
}
|
||||
|
||||
function extractInlineAudio(
|
||||
export function extractInlineAudio(
|
||||
data: unknown
|
||||
): { base64: string; mimeType: string } | null {
|
||||
const parts = (data as { candidates?: Array<{ content?: { parts?: unknown[] } }> })?.candidates?.[0]
|
||||
@@ -215,7 +215,7 @@ export async function vertexGenerateSpeech(
|
||||
const inline = extractInlineAudio(data);
|
||||
if (!inline) throw new Error("Vertex TTS returned no audio content");
|
||||
const pcm = Buffer.from(inline.base64, "base64");
|
||||
return { audio: pcmToWav(pcm, parseSampleRate(inline.mimeType)), contentType: "audio/wav" };
|
||||
return { audio: pcmToWav(pcm, parsePcmSampleRate(inline.mimeType)), contentType: "audio/wav" };
|
||||
}
|
||||
|
||||
/** Gemini transcription (audio → text). `audioBase64` is the raw file bytes, base64-encoded. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"
|
||||
import { buildAuthHeaders } from "../config/registryUtils.ts";
|
||||
import { kieExecutor } from "../executors/kie.ts";
|
||||
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
|
||||
import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts";
|
||||
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
|
||||
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
|
||||
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
|
||||
@@ -889,6 +890,13 @@ export async function handleAudioSpeech({
|
||||
headers: { ...CORS_HEADERS, "Content-Type": contentType },
|
||||
});
|
||||
}
|
||||
if (providerConfig.format === "gemini-tts") {
|
||||
return handleGeminiTtsSpeech(credentials, {
|
||||
model: modelId,
|
||||
text: body.input,
|
||||
voice: body.voice,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerConfig.format === "hyperbolic") {
|
||||
return handleHyperbolicSpeech(providerConfig, body, token);
|
||||
|
||||
@@ -20,6 +20,7 @@ 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";
|
||||
@@ -379,6 +380,7 @@ 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 {
|
||||
@@ -778,11 +780,12 @@ export async function handleChatCore({
|
||||
classifierSettings.claudeClassifierCompat as string | undefined
|
||||
)
|
||||
) {
|
||||
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
|
||||
log?.warn?.(
|
||||
"CHAT",
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
|
||||
);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4910,12 +4913,14 @@ 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
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const memoryExtractionResponse = translatedResponse;
|
||||
@@ -4942,7 +4947,8 @@ export async function handleChatCore({
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
@@ -5465,7 +5471,8 @@ export async function handleChatCore({
|
||||
streamBody,
|
||||
clientResponseFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
extractToolSchemaMap(finalBody || translatedBody || body)
|
||||
) as Record<string, unknown>)
|
||||
: streamBody;
|
||||
const choices = cacheStreamBody.choices as
|
||||
|
||||
@@ -24,14 +24,19 @@ 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 [];
|
||||
@@ -60,6 +65,29 @@ 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
|
||||
@@ -67,7 +95,10 @@ export function shouldDefaultAllowClassifier(
|
||||
* 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): {
|
||||
export function buildDefaultAllowClaudeMessage(
|
||||
model?: string | null,
|
||||
format: ClaudeClassifierFormat = "block"
|
||||
): {
|
||||
success: true;
|
||||
response: Response;
|
||||
} {
|
||||
@@ -76,7 +107,12 @@ export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: model || "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "<block>no</block>" }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
|
||||
@@ -91,8 +91,20 @@ interface KieImageOptions {
|
||||
} | null;
|
||||
}
|
||||
|
||||
// KIE Market catalog ids are namespaced for OmniRoute's catalog
|
||||
// (`google-imagen/<model>`), but the KIE Market createTask API expects
|
||||
// vendor-specific upstream ids that do not follow a single consistent
|
||||
// pattern (confirmed against docs.kie.ai/market/google/* — see #11225,
|
||||
// #11296): nano-banana-2 and nano-banana-pro drop the vendor namespace
|
||||
// entirely, while nano-banana and nano-banana-edit use a `google/` prefix
|
||||
// instead of `google-imagen/`. Every other KIE Market namespace (seedream,
|
||||
// flux, ideogram, qwen, wan, grok-imagine, gpt) already matches its real
|
||||
// upstream id byte-for-byte, so this map stays scoped to google-imagen.
|
||||
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
|
||||
["google-imagen/nano-banana", "google/nano-banana"],
|
||||
["google-imagen/nano-banana-2", "nano-banana-2"],
|
||||
["google-imagen/nano-banana-pro", "nano-banana-pro"],
|
||||
["google-imagen/nano-banana-edit", "google/nano-banana-edit"],
|
||||
]);
|
||||
|
||||
export function resolveKieMarketUpstreamModelId(publicModelId: string): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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>;
|
||||
|
||||
@@ -135,24 +136,28 @@ 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
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): JsonRecord;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown {
|
||||
// If already in source format, return as-is
|
||||
if (targetFormat === sourceFormat) {
|
||||
@@ -219,6 +224,11 @@ 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)) {
|
||||
@@ -229,7 +239,6 @@ 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,6 +31,7 @@ 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";
|
||||
@@ -313,9 +314,23 @@ function getProviderSettingString(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
|
||||
export function resolveSearchBaseUrl(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): string {
|
||||
const override = getProviderSettingString(params, "baseUrl");
|
||||
return (override || config.baseUrl).replace(/\/+$/, "");
|
||||
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(/\/+$/, "");
|
||||
}
|
||||
|
||||
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ResolvedComboTarget,
|
||||
} from "./types.ts";
|
||||
import { extractSessionAffinityKey } from "@/sse/services/auth";
|
||||
import { filterChatSelectableModels } from "../modelEndpointPolicy.ts";
|
||||
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
|
||||
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
|
||||
import {
|
||||
@@ -470,10 +471,13 @@ export async function expandAutoComboCandidatePool(
|
||||
// catalog only when the user has none. This keeps catalog-only models
|
||||
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
|
||||
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
|
||||
const [syncedModels, customModels] = await Promise.all([
|
||||
// #11088 (option 1): the synced store now persists non-chat models too —
|
||||
// chat combo pools must keep filtering them out at read time.
|
||||
const [syncedModelsRaw, customModels] = await Promise.all([
|
||||
getSyncedAvailableModels(providerId),
|
||||
getCustomModels(providerId),
|
||||
]);
|
||||
const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw);
|
||||
const hiddenModels = hiddenModelsMap.get(providerId);
|
||||
const userVisibleIds = new Set<string>();
|
||||
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
|
||||
|
||||
40
open-sse/services/compression/compressionWorker.ts
Normal file
40
open-sse/services/compression/compressionWorker.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import {
|
||||
applyCompression,
|
||||
applyStackedCompression,
|
||||
type StackedCompressionStep,
|
||||
} from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionWorkerJob,
|
||||
CompressionWorkerMessage,
|
||||
} from "./compressionWorkerProtocol.ts";
|
||||
|
||||
if (!parentPort) throw new Error("compressionWorker must run in a worker thread");
|
||||
parentPort.on("message", (job: CompressionWorkerJob) => {
|
||||
try {
|
||||
const onEngineStep = (step: StackedCompressionStep) =>
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "step",
|
||||
step,
|
||||
} satisfies CompressionWorkerMessage);
|
||||
const result =
|
||||
job.mode === "stacked"
|
||||
? applyStackedCompression(job.body, job.options?.config?.stackedPipeline, {
|
||||
...job.options,
|
||||
onEngineStep,
|
||||
})
|
||||
: applyCompression(job.body, job.mode, job.options);
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "result",
|
||||
result,
|
||||
} satisfies CompressionWorkerMessage);
|
||||
} catch (error) {
|
||||
parentPort.postMessage({
|
||||
id: job.id,
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CompressionWorkerMessage);
|
||||
}
|
||||
});
|
||||
164
open-sse/services/compression/compressionWorkerPool.ts
Normal file
164
open-sse/services/compression/compressionWorkerPool.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { CompressionResult } from "./types.ts";
|
||||
import type { StackedCompressionStep } from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionWorkerJob,
|
||||
CompressionWorkerMessage,
|
||||
CompressionWorkerOptions,
|
||||
} from "./compressionWorkerProtocol.ts";
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
function workerUrl(): URL {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
|
||||
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
|
||||
}
|
||||
return new URL("compressionWorker.js", import.meta.url);
|
||||
}
|
||||
function unchanged(body: Record<string, unknown>): CompressionResult {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
interface PendingJob extends CompressionWorkerJob {
|
||||
originalBody: Record<string, unknown>;
|
||||
resolve: (result: CompressionResult) => void;
|
||||
onEngineStep?: (step: StackedCompressionStep) => void;
|
||||
}
|
||||
interface PoolWorker {
|
||||
worker: Worker;
|
||||
job: PendingJob | null;
|
||||
timeout: NodeJS.Timeout | null;
|
||||
idle: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export class CompressionWorkerPool {
|
||||
private readonly queue: PendingJob[] = [];
|
||||
private readonly workers = new Set<PoolWorker>();
|
||||
private nextId = 1;
|
||||
private readonly size: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly idleMs: number;
|
||||
|
||||
constructor({
|
||||
size = positiveInteger(process.env.OMNI_COMPRESSION_WORKERS, 2),
|
||||
timeoutMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS, 120_000),
|
||||
idleMs = positiveInteger(process.env.OMNI_COMPRESSION_WORKER_IDLE_MS, 60_000),
|
||||
}: { size?: number; timeoutMs?: number; idleMs?: number } = {}) {
|
||||
this.size = Math.max(1, Math.floor(size));
|
||||
this.timeoutMs = Math.max(1, Math.floor(timeoutMs));
|
||||
this.idleMs = Math.max(1, Math.floor(idleMs));
|
||||
}
|
||||
|
||||
run(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionWorkerJob["mode"],
|
||||
options?: CompressionWorkerOptions,
|
||||
onEngineStep?: (step: StackedCompressionStep) => void
|
||||
): Promise<CompressionResult> {
|
||||
return new Promise((resolve) => {
|
||||
this.queue.push({
|
||||
id: this.nextId++,
|
||||
body,
|
||||
mode,
|
||||
options,
|
||||
originalBody: body,
|
||||
resolve,
|
||||
onEngineStep,
|
||||
});
|
||||
this.dispatch();
|
||||
});
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
for (const job of this.queue.splice(0)) job.resolve(unchanged(job.originalBody));
|
||||
await Promise.all([...this.workers].map((slot) => this.remove(slot, true)));
|
||||
}
|
||||
private spawn(): PoolWorker {
|
||||
const slot: PoolWorker = {
|
||||
worker: new Worker(workerUrl()),
|
||||
job: null,
|
||||
timeout: null,
|
||||
idle: null,
|
||||
};
|
||||
this.workers.add(slot);
|
||||
slot.worker.on("message", (message: CompressionWorkerMessage) =>
|
||||
this.handleMessage(slot, message)
|
||||
);
|
||||
slot.worker.on("error", () => this.fail(slot));
|
||||
slot.worker.on("exit", () => {
|
||||
if (this.workers.has(slot)) this.fail(slot);
|
||||
});
|
||||
return slot;
|
||||
}
|
||||
private dispatch(): void {
|
||||
while (this.queue.length) {
|
||||
let slot = [...this.workers].find((candidate) => !candidate.job);
|
||||
if (!slot && this.workers.size < this.size) slot = this.spawn();
|
||||
if (!slot) return;
|
||||
if (slot.idle) clearTimeout(slot.idle);
|
||||
const job = this.queue.shift();
|
||||
if (!job) return;
|
||||
slot.job = job;
|
||||
slot.timeout = setTimeout(() => this.fail(slot!), this.timeoutMs);
|
||||
slot.timeout.unref();
|
||||
const { originalBody: _body, resolve: _resolve, onEngineStep: _step, ...wireJob } = job;
|
||||
slot.worker.postMessage(wireJob);
|
||||
}
|
||||
}
|
||||
private handleMessage(slot: PoolWorker, message: CompressionWorkerMessage): void {
|
||||
const job = slot.job;
|
||||
if (!job || job.id !== message.id) return;
|
||||
if (message.type === "step") {
|
||||
try {
|
||||
job.onEngineStep?.(message.step);
|
||||
} catch {
|
||||
// Telemetry is best-effort.
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.finish(slot, message.type === "result" ? message.result : unchanged(job.originalBody));
|
||||
}
|
||||
private finish(slot: PoolWorker, result: CompressionResult): void {
|
||||
const job = slot.job;
|
||||
if (!job) return;
|
||||
if (slot.timeout) clearTimeout(slot.timeout);
|
||||
slot.timeout = null;
|
||||
slot.job = null;
|
||||
job.resolve(result);
|
||||
slot.idle = setTimeout(() => void this.remove(slot, false), this.idleMs);
|
||||
slot.idle.unref();
|
||||
this.dispatch();
|
||||
}
|
||||
private fail(slot: PoolWorker): void {
|
||||
const job = slot.job;
|
||||
if (job) job.resolve(unchanged(job.originalBody));
|
||||
slot.job = null;
|
||||
void this.remove(slot, true).finally(() => this.dispatch());
|
||||
}
|
||||
private async remove(slot: PoolWorker, terminate: boolean): Promise<void> {
|
||||
if (!this.workers.delete(slot)) return;
|
||||
if (slot.timeout) clearTimeout(slot.timeout);
|
||||
if (slot.idle) clearTimeout(slot.idle);
|
||||
if (terminate) await slot.worker.terminate().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let pool: CompressionWorkerPool | null = null;
|
||||
export function runCompressionInWorker(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionWorkerJob["mode"],
|
||||
options?: CompressionWorkerOptions,
|
||||
onEngineStep?: (step: StackedCompressionStep) => void
|
||||
): Promise<CompressionResult> {
|
||||
pool ??= new CompressionWorkerPool();
|
||||
return pool.run(body, mode, options, onEngineStep);
|
||||
}
|
||||
export async function closeCompressionWorkerPoolForTests(): Promise<void> {
|
||||
const active = pool;
|
||||
pool = null;
|
||||
await active?.close();
|
||||
}
|
||||
71
open-sse/services/compression/compressionWorkerProtocol.ts
Normal file
71
open-sse/services/compression/compressionWorkerProtocol.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts";
|
||||
import type { StackedCompressionStep } from "./strategySelector.ts";
|
||||
import type {
|
||||
CompressionStage,
|
||||
CompressionWireFormat,
|
||||
ImageTransportFidelity,
|
||||
} from "./engines/types.ts";
|
||||
|
||||
export interface CompressionWorkerOptions {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
providerTransport?: "direct" | "aggregator";
|
||||
provider?: string;
|
||||
imageTransportFidelity?: ImageTransportFidelity;
|
||||
sourceFormat?: CompressionWireFormat;
|
||||
targetFormat?: CompressionWireFormat;
|
||||
compressionStage?: CompressionStage;
|
||||
config?: CompressionConfig;
|
||||
}
|
||||
export interface CompressionWorkerJob {
|
||||
id: number;
|
||||
body: Record<string, unknown>;
|
||||
mode: CompressionMode;
|
||||
options?: CompressionWorkerOptions;
|
||||
}
|
||||
export type CompressionWorkerMessage =
|
||||
| { id: number; type: "step"; step: StackedCompressionStep }
|
||||
| { id: number; type: "result"; result: CompressionResult }
|
||||
| { id: number; type: "error"; error: string };
|
||||
|
||||
function isPlainObject(value: object): value is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
export function isStrictlySerializable(value: unknown, seen = new Set<object>()): boolean {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number"
|
||||
) {
|
||||
return typeof value !== "number" || Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== "object" || seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) return value.every((entry) => isStrictlySerializable(entry, seen));
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.values(value).every((entry) => isStrictlySerializable(entry, seen));
|
||||
}
|
||||
|
||||
const WORKER_STACK_ENGINES = new Set(["caveman", "rtk", "standard"]);
|
||||
export function isCompressionWorkerEligible(
|
||||
body: Record<string, unknown>,
|
||||
mode: CompressionMode,
|
||||
options?: CompressionWorkerOptions
|
||||
): boolean {
|
||||
if (mode !== "standard" && mode !== "rtk" && mode !== "stacked") return false;
|
||||
if (mode === "stacked") {
|
||||
const pipeline = options?.config?.stackedPipeline;
|
||||
if (!Array.isArray(pipeline) || pipeline.length === 0) return false;
|
||||
if (
|
||||
pipeline.some((step) => {
|
||||
const engine = typeof step === "string" ? step : step.engine;
|
||||
return !WORKER_STACK_ENGINES.has(engine);
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return isStrictlySerializable({ body, mode, ...(options ? { options } : {}) });
|
||||
}
|
||||
@@ -519,6 +519,28 @@ async function runCompressionAsync(
|
||||
cachingContext?: CachingDetectionContext;
|
||||
}
|
||||
): Promise<CompressionResult> {
|
||||
const workerOptions = options
|
||||
? {
|
||||
model: options.model,
|
||||
supportsVision: options.supportsVision,
|
||||
providerTransport: options.providerTransport,
|
||||
provider: options.provider,
|
||||
imageTransportFidelity: options.imageTransportFidelity,
|
||||
sourceFormat: options.sourceFormat,
|
||||
targetFormat: options.targetFormat,
|
||||
compressionStage: options.compressionStage,
|
||||
config: options.config,
|
||||
}
|
||||
: undefined;
|
||||
const { isCompressionWorkerEligible } = await import("./compressionWorkerProtocol.ts");
|
||||
if (isCompressionWorkerEligible(body, mode, workerOptions)) {
|
||||
try {
|
||||
const { runCompressionInWorker } = await import("./compressionWorkerPool.ts");
|
||||
return await runCompressionInWorker(body, mode, workerOptions, options?.onEngineStep);
|
||||
} catch {
|
||||
return { body, compressed: false, stats: null };
|
||||
}
|
||||
}
|
||||
if (
|
||||
options?.config?.memoizeCompressionResults === true &&
|
||||
// Only memoize for an explicit principal — a missing principalId would collapse
|
||||
|
||||
@@ -78,7 +78,7 @@ const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [
|
||||
|
||||
/** Presets exposed by the web client's model picker (id → text/multimodal model). */
|
||||
export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [
|
||||
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" },
|
||||
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.7-flash" },
|
||||
{ id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" },
|
||||
{ id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" },
|
||||
{ id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" },
|
||||
|
||||
@@ -12,7 +12,21 @@
|
||||
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
|
||||
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
|
||||
*
|
||||
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
|
||||
* `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.
|
||||
*
|
||||
* 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
|
||||
@@ -132,25 +146,39 @@ export function recordLearnedReasoningEffort(
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the greatest accepted value <= effortStr (downgrade only), or null
|
||||
* if effortStr is already accepted, below the minimum, or not in ORDER.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
|
||||
if (rank < minRank) return null;
|
||||
let best: string | null = null;
|
||||
let bestRank = -1;
|
||||
|
||||
let nearestAbove: string | null = null;
|
||||
let nearestAboveRank = Infinity;
|
||||
let highest: string | null = null;
|
||||
let highestRank = -1;
|
||||
for (const v of accepted) {
|
||||
const r = rankOf(v);
|
||||
if (r <= rank && r > bestRank) {
|
||||
bestRank = r;
|
||||
best = v;
|
||||
if (r < 0) continue;
|
||||
if (r >= rank && r < nearestAboveRank) {
|
||||
nearestAboveRank = r;
|
||||
nearestAbove = v;
|
||||
}
|
||||
if (r > highestRank) {
|
||||
highestRank = r;
|
||||
highest = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return nearestAbove ?? highest;
|
||||
}
|
||||
|
||||
// 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.5-flash). */
|
||||
/** Client-facing id (model_reference slug, e.g. gemini-3.7-flash). */
|
||||
id: string;
|
||||
/** Friendly picker label. */
|
||||
name: string;
|
||||
|
||||
@@ -28,12 +28,10 @@ export async function refreshCopilotToken(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
return { status: response.status };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -49,8 +47,8 @@ export async function refreshCopilotToken(
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message,
|
||||
errorType: error?.name || "Error",
|
||||
});
|
||||
return null;
|
||||
return { status: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,31 @@ 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
|
||||
@@ -299,7 +324,11 @@ export function coerceToolSchemas(tools: 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) &&
|
||||
@@ -312,19 +341,38 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
const widened: JsonRecord = { ...propSchema };
|
||||
const enumValues = propSchema.enum as unknown[];
|
||||
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
|
||||
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;
|
||||
widened.type = widenTypeWithNull(propSchema.type);
|
||||
widened.description = appendNullOmissionMarker(propSchema.description);
|
||||
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;
|
||||
|
||||
@@ -356,6 +404,43 @@ 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,6 +18,7 @@ import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
injectOptionalEnumOmissionForTools,
|
||||
injectOptionalStringOmissionForTools,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
@@ -595,6 +596,12 @@ 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 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.
|
||||
// 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.
|
||||
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
|
||||
if (state.toolCallByCallId instanceof Map) {
|
||||
for (const entry of state.toolCallByCallId.values()) {
|
||||
if (entry.needsNormalization && entry.argsBuffer) {
|
||||
if (entry.argsBuffer) {
|
||||
const toolSchema = state.toolSchemas?.get(entry.name);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
|
||||
pendingNormalized.push({
|
||||
|
||||
@@ -56,21 +56,35 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
|
||||
return allowlisted || (propSchema != null && !required.has(key));
|
||||
}
|
||||
|
||||
// #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 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`.
|
||||
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
|
||||
if (entry !== null) return false;
|
||||
if (toolName === "Agent") return true;
|
||||
if (propSchema == null) return false;
|
||||
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;
|
||||
return !required.has(key) || hasOmissionSentinel(propSchema);
|
||||
}
|
||||
|
||||
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
|
||||
@@ -110,7 +124,11 @@ 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 {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
- "open-sse"
|
||||
# Match `.npmrc`'s legacy-peer-deps posture. OmniRoute imports only the deep
|
||||
# icon modules from @lobehub/icons; auto-installing its unused @lobehub/ui peer
|
||||
# pulls a large UI subtree (including packages without distributable licenses).
|
||||
autoInstallPeers: false
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
|
||||
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decide whether the Next.js build should alias `better-sqlite3` to the
|
||||
* build-time stub (src/lib/db/better-sqlite3.stub.js).
|
||||
*
|
||||
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
|
||||
* tracing the native addon into a Next.js build worker, whose thread teardown
|
||||
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
|
||||
* leave the build without standalone output (#10060).
|
||||
*
|
||||
* The premise recorded next to that alias — "runtime still uses the real
|
||||
* package via serverExternalPackages" — does not hold. A Turbopack
|
||||
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
|
||||
* `better-sqlite3` becomes a relative path, no longer matches the
|
||||
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
|
||||
* artifact built from that config answered HTTP 500 on every route: the stub's
|
||||
* default export is not a constructor, the sync driver chain fell through to
|
||||
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
|
||||
*
|
||||
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
|
||||
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
|
||||
* opt-in, and a default build gets the real, externalized native package.
|
||||
*
|
||||
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
|
||||
* the SIGABRT worker teardown, and never for an artifact that will be run —
|
||||
* the resulting bundle cannot open a database.
|
||||
*/
|
||||
export function shouldStubBetterSqlite3(env = process.env) {
|
||||
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
|
||||
}
|
||||
|
||||
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
|
||||
export function betterSqlite3AliasFor(env = process.env) {
|
||||
return shouldStubBetterSqlite3(env)
|
||||
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
|
||||
: {};
|
||||
}
|
||||
@@ -33,6 +33,14 @@ const STANDALONE = process.env.OMNIROUTE_STANDALONE_DIR
|
||||
|
||||
const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js");
|
||||
const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts");
|
||||
const COMPRESSION_WORKER_REL = join("open-sse", "services", "compression", "compressionWorker.js");
|
||||
const COMPRESSION_WORKER_SRC = join(
|
||||
ROOT,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.ts"
|
||||
);
|
||||
const WORKER_REL = join(
|
||||
"open-sse",
|
||||
"services",
|
||||
@@ -107,9 +115,26 @@ function main() {
|
||||
);
|
||||
console.log("[colocate-standalone] ✅ call-log artifact worker bundled");
|
||||
|
||||
const compressionWorkerDest = join(STANDALONE, COMPRESSION_WORKER_REL);
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
COMPRESSION_WORKER_SRC,
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
`--outfile=${compressionWorkerDest}`,
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
console.log("[colocate-standalone] ✅ compression worker bundled");
|
||||
|
||||
// The call-log worker is always present; scope it to ESM immediately. The
|
||||
// optional LLMLingua worker dir is added below only when its deps are installed.
|
||||
const workerDirs = [dirname(callLogWorkerDest)];
|
||||
const workerDirs = [dirname(callLogWorkerDest), dirname(compressionWorkerDest)];
|
||||
|
||||
if (!hasOptionals) {
|
||||
console.log(
|
||||
|
||||
@@ -45,6 +45,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [
|
||||
// LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads
|
||||
// (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server.
|
||||
"open-sse/services/compression/engines/llmlingua/onnxWorker.js",
|
||||
"open-sse/services/compression/compressionWorker.js",
|
||||
"src/lib/usage/callLogArtifactWorker.js",
|
||||
"package.json",
|
||||
"peer-stamp.mjs",
|
||||
@@ -312,13 +313,27 @@ export const PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS: string[] = ["node_modules"];
|
||||
|
||||
export function findUnexpectedArtifactPaths(
|
||||
filePaths: string[],
|
||||
{ exactPaths = [], prefixPaths = [] }: { exactPaths?: string[]; prefixPaths?: string[] } = {}
|
||||
{
|
||||
exactPaths = [],
|
||||
prefixPaths = [],
|
||||
// #9985: the app-STAGING prune (prepublish Step 10.7) must be able to opt out
|
||||
// of the node_modules segment ban — the standalone server's runtime deps live
|
||||
// under dist/node_modules and Turbopack-hashed dirs (.build/next/node_modules/
|
||||
// sql.js-*/dist/sql-wasm.wasm, transformers ort-wasm). Pruning them 500'd every
|
||||
// DB-backed route in packaged boots while /api/monitoring/health stayed green.
|
||||
// The PUBLISH gate (validate-pack-artifact) keeps the strict default.
|
||||
neverAllowedSegments = PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS,
|
||||
}: {
|
||||
exactPaths?: string[];
|
||||
prefixPaths?: string[];
|
||||
neverAllowedSegments?: string[];
|
||||
} = {}
|
||||
): string[] {
|
||||
const normalizedExact = new Set(exactPaths.map(normalizeArtifactPath));
|
||||
const normalizedPrefixes = prefixPaths.map(normalizeArtifactPath);
|
||||
|
||||
const hasForbiddenSegment = (filePath: string): boolean =>
|
||||
filePath.split("/").some((segment) => PACK_ARTIFACT_NEVER_ALLOWED_SEGMENTS.includes(segment));
|
||||
filePath.split("/").some((segment) => neverAllowedSegments.includes(segment));
|
||||
|
||||
return filePaths
|
||||
.map(normalizeArtifactPath)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
import { assertSqlitePrebuildExists } from "./electronRebuildPlan.mjs";
|
||||
import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs";
|
||||
import { stageOptionalPacks } from "./optionalPackStaging.mjs";
|
||||
import { runBuildTool } from "./buildToolRunner.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -169,6 +170,27 @@ assembleStandalone({
|
||||
// app they would point at the build machine's absolute paths and break on install.
|
||||
materializeSymlinks: true,
|
||||
});
|
||||
const compressionWorkerDest = join(
|
||||
ELECTRON_STANDALONE_DIR,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.js"
|
||||
);
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
join(ROOT, "open-sse", "services", "compression", "compressionWorker.ts"),
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
`--outfile=${compressionWorkerDest}`,
|
||||
],
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
|
||||
const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR);
|
||||
if (docsPrune.removedFiles > 0) {
|
||||
|
||||
@@ -407,6 +407,40 @@ if (existsSync(llmWorkerSrc)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 8.6b: Bundle synchronous compression worker ──────────────────
|
||||
const compressionWorkerSrc = join(
|
||||
ROOT,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.ts"
|
||||
);
|
||||
const compressionWorkerDest = join(
|
||||
DIST_DIR,
|
||||
"open-sse",
|
||||
"services",
|
||||
"compression",
|
||||
"compressionWorker.js"
|
||||
);
|
||||
if (!existsSync(compressionWorkerSrc)) {
|
||||
throw new Error("Required compression worker source is missing");
|
||||
}
|
||||
console.log(" 🔨 Bundling compression worker...");
|
||||
mkdirSync(dirname(compressionWorkerDest), { recursive: true });
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
"open-sse/services/compression/compressionWorker.ts",
|
||||
"--bundle",
|
||||
"--platform=node",
|
||||
"--packages=external",
|
||||
"--format=esm",
|
||||
"--outfile=dist/open-sse/services/compression/compressionWorker.js",
|
||||
],
|
||||
{ cwd: ROOT, stdio: "inherit" }
|
||||
);
|
||||
|
||||
// ── Step 8.7: Bundle CLI Entrypoint ──────────────────────────
|
||||
const cliSrcFile = join(ROOT, "bin", "omniroute.ts");
|
||||
const cliDestFile = join(ROOT, "bin", "omniroute.mjs");
|
||||
@@ -639,10 +673,15 @@ for (const relativePath of APP_STAGING_REMOVAL_PATHS) {
|
||||
}
|
||||
|
||||
// ── Step 10.7: Prune any staged dist/ file outside the allowed runtime set ──
|
||||
// #9985: neverAllowedSegments is EMPTY here on purpose — unlike the publish
|
||||
// tarball gate, the staged dist/ legitimately contains node_modules (the
|
||||
// standalone server's runtime deps, including Turbopack-hashed packages whose
|
||||
// wasm files DB init requires). The allowlist prefixes above are the contract.
|
||||
const stagedFiles = walkFiles(DIST_DIR);
|
||||
const unexpectedStagedFiles = findUnexpectedArtifactPaths(stagedFiles, {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
|
||||
neverAllowedSegments: [],
|
||||
});
|
||||
|
||||
if (unexpectedStagedFiles.length > 0) {
|
||||
@@ -657,6 +696,7 @@ if (unexpectedStagedFiles.length > 0) {
|
||||
const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR), {
|
||||
exactPaths: APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
prefixPaths: APP_STAGING_ALLOWED_PATH_PREFIXES,
|
||||
neverAllowedSegments: [],
|
||||
});
|
||||
|
||||
if (remainingUnexpectedFiles.length > 0) {
|
||||
|
||||
@@ -90,13 +90,30 @@ export function baselineValue(metric, root = ROOT) {
|
||||
}
|
||||
}
|
||||
|
||||
// A line that is unambiguously a PASS. Test reporters print the file name on BOTH the
|
||||
// pass and the fail line, so a green line for a file whose NAME contains "fail"
|
||||
// (fail-fast-*.test.ts, failover-*.test.ts) must never be offered as a failure cause.
|
||||
const GREEN_LINE_RE = /^[✓✔√]/;
|
||||
|
||||
// Markers that are only meaningful at the START of a line: "FAIL" also occurs inside test
|
||||
// FILE NAMES and inside summary prose ("Test Files 1 failed"), so matching it anywhere —
|
||||
// and case-insensitively — reports a PASSING file as the cause of the red.
|
||||
const LINE_START_FAILURE_RE = /^(?:[✖✗×]|FAIL\b|not ok\b|REGRESS)/;
|
||||
|
||||
// Markers that are unambiguous ANYWHERE in the line: tsc and Node emit them mid-line
|
||||
// ("src/x.ts(10,5): error TS2322: ..."), so these stay unanchored. They are matched
|
||||
// case-SENSITIVELY because that is how the emitting tools actually spell them.
|
||||
const INLINE_FAILURE_RE = /\berror TS\d+\b|\bAssertionError\b|\bError:|\bREGRESS/;
|
||||
|
||||
/** Best-effort "first meaningful failure line" from captured command output. */
|
||||
export function firstFailureLine(out) {
|
||||
const lines = String(out || "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const hit = lines.find((l) => /✖|✗|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l));
|
||||
const hit = lines.find(
|
||||
(l) => !GREEN_LINE_RE.test(l) && (LINE_START_FAILURE_RE.test(l) || INLINE_FAILURE_RE.test(l))
|
||||
);
|
||||
return (hit || lines[lines.length - 1] || "failed").slice(0, 200);
|
||||
}
|
||||
|
||||
@@ -232,6 +249,36 @@ export function fullCiTimeoutFor(gateId) {
|
||||
return FULL_CI_TIMEOUT_OVERRIDES_MS[gateId] ?? FULL_CI_DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// ci.yml gate scripts whose result the CURATED pass already records under a DIFFERENT id.
|
||||
// Without this map the --full-ci pass re-records them unconditionally as kind:"hard" while
|
||||
// the curated pass recorded them as kind:"drift", and the SAME gate is printed in BOTH
|
||||
// verdict buckets of one report (file-size / compression-budget appeared as a hard failure
|
||||
// and as drift simultaneously in the #9985 verdict).
|
||||
export const FULL_CI_CURATED_ALIASES = {
|
||||
lint: "lint-errors",
|
||||
"check:workflows": "workflow-lint",
|
||||
"check:complexity-ratchets": "complexity",
|
||||
};
|
||||
|
||||
/** Curated-pass id equivalent to a ci.yml gate script id ("check:file-size" -> "file-size"). */
|
||||
export function curatedEquivalentId(scriptId) {
|
||||
const id = String(scriptId || "");
|
||||
if (Object.hasOwn(FULL_CI_CURATED_ALIASES, id)) return FULL_CI_CURATED_ALIASES[id];
|
||||
return id.startsWith("check:") ? id.slice("check:".length) : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket a --full-ci gate must be reported under: the classification the curated pass already
|
||||
* gave the equivalent gate, else "hard" (the --full-ci default for gates the curated list does
|
||||
* not cover). This only changes WHICH BUCKET a result is printed in — it never changes whether
|
||||
* a gate runs, nor whether it passed.
|
||||
*/
|
||||
export function fullCiKindFor(scriptId, results) {
|
||||
const equivalent = curatedEquivalentId(scriptId);
|
||||
const curated = (results || []).find((r) => r.id === scriptId || r.id === equivalent);
|
||||
return curated?.kind ?? "hard";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ci.yml text and return the ordered, de-duplicated list of gate commands to run.
|
||||
* Each entry: { id, job, args:["run", <script>, ...("--" + args)], env }.
|
||||
@@ -716,7 +763,10 @@ async function main() {
|
||||
record({
|
||||
id: g.id,
|
||||
label: `ci.yml:${g.job} → npm ${g.args.join(" ")}`,
|
||||
kind: "hard",
|
||||
// Respect the curated classification when the curated pass already ran an equivalent
|
||||
// gate under a different id — otherwise the same ratchet is reported as a HARD failure
|
||||
// here AND as drift above, in one self-contradicting verdict.
|
||||
kind: fullCiKindFor(g.id, results),
|
||||
ok: code === 0,
|
||||
detail: code === 0 ? "pass" : firstFailureLine(out),
|
||||
});
|
||||
|
||||
@@ -75,76 +75,3 @@ 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
|
||||
```
|
||||
|
||||
112
src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx
Normal file
112
src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } 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;
|
||||
};
|
||||
|
||||
// #9985: dismissal lives in localStorage, read via useSyncExternalStore — keeps
|
||||
// the component free of setState-in-effect cascades and hydration-safe (server
|
||||
// snapshot treats the card as dismissed; the client corrects after hydration).
|
||||
const readinessListeners = new Set<() => void>();
|
||||
|
||||
function subscribeReadiness(onStoreChange: () => void): () => void {
|
||||
readinessListeners.add(onStoreChange);
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => {
|
||||
readinessListeners.delete(onStoreChange);
|
||||
window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
}
|
||||
|
||||
function isReadinessDismissed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISMISS_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
// Storage unavailable (private mode etc.) — never show the nagging card.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 dismissed = useSyncExternalStore(subscribeReadiness, isReadinessDismissed, getServerSnapshot);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// ignore storage failures; still hide for this session
|
||||
}
|
||||
for (const listener of readinessListeners) listener();
|
||||
}, []);
|
||||
|
||||
if (setupComplete || dismissed) return null;
|
||||
|
||||
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,7 +2,6 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Card, Button, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
|
||||
@@ -643,38 +642,32 @@ export default function DefaultToolCard({
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
// Tool SVGs are non-square (e.g. opencode is 234×42, cursor is 467×532).
|
||||
// next/image's dev check warns whenever the rendered aspect-ratio size
|
||||
// differs from the square width/height attributes, so these render as a
|
||||
// plain <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 (
|
||||
<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";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return renderImg(tool.image);
|
||||
}
|
||||
if (tool.imageLight || tool.imageDark) {
|
||||
const themedSrc = isDark
|
||||
? tool.imageDark || tool.imageLight
|
||||
: tool.imageLight || tool.imageDark;
|
||||
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";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return renderImg(themedSrc);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { isPublicDisplayBaseUrl, useDisplayBaseUrl } from "@/shared/hooks";
|
||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||
import { getProviderDisplayName } from "@/lib/display/names";
|
||||
import { useTranslations } from "next-intl";
|
||||
import A2ADashboardPage from "./components/A2ADashboard";
|
||||
import McpDashboardPage from "./components/MCPDashboard";
|
||||
@@ -138,7 +135,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
// Endpoints / models state
|
||||
const [allModels, setAllModels] = useState([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState(null);
|
||||
|
||||
// Cloud sync state
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
@@ -151,8 +147,8 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
|
||||
const [cloudBaseUrl, setCloudBaseUrl] = useState(BUILD_TIME_CLOUD_URL); // dynamic cloud URL from API response
|
||||
const [cloudConfigured, setCloudConfigured] = useState(Boolean(BUILD_TIME_CLOUD_URL));
|
||||
const [mcpStatus, setMcpStatus] = useState<any>(null);
|
||||
const [a2aStatus, setA2aStatus] = useState<any>(null);
|
||||
const [_mcpStatus, setMcpStatus] = useState<any>(null);
|
||||
const [_a2aStatus, setA2aStatus] = useState<any>(null);
|
||||
const [searchProviders, setSearchProviders] = useState<any[]>([]);
|
||||
const [cloudflaredStatus, setCloudflaredStatus] = useState<CloudflaredTunnelStatus | null>(null);
|
||||
const [cloudflaredBusy, setCloudflaredBusy] = useState(false);
|
||||
@@ -1132,10 +1128,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
showNgrokTunnel && ngrokStatus?.running,
|
||||
].filter(Boolean).length;
|
||||
|
||||
const mcpOnline = Boolean(mcpStatus?.online);
|
||||
const a2aOnline = a2aStatus?.status === "ok";
|
||||
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
|
||||
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
|
||||
const cloudflaredPhase = cloudflaredStatus?.phase || "not_installed";
|
||||
const cloudflaredPhaseMeta: Record<CloudflaredTunnelPhase, { label: string; className: string }> =
|
||||
{
|
||||
@@ -1169,10 +1161,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
: cloudflaredStatus?.installed
|
||||
? translateOrFallback("cloudflaredEnable", "Enable Tunnel")
|
||||
: translateOrFallback("cloudflaredInstallAndEnable", "Install & Enable");
|
||||
const cloudflaredUrlNotice = translateOrFallback(
|
||||
"cloudflaredUrlNotice",
|
||||
"Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart."
|
||||
);
|
||||
const tailscalePhase = tailscaleStatus?.phase || "not_installed";
|
||||
const tailscalePhaseMeta: Record<TailscaleTunnelPhase, { label: string; className: string }> = {
|
||||
running: {
|
||||
@@ -2498,137 +2486,3 @@ function EndpointCard({
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointSection({
|
||||
icon,
|
||||
iconColor,
|
||||
iconBg,
|
||||
title,
|
||||
path,
|
||||
description,
|
||||
models,
|
||||
expanded,
|
||||
onToggle,
|
||||
copy,
|
||||
copied,
|
||||
baseUrl,
|
||||
modelsLoading = false,
|
||||
}: Readonly<{
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
title: string;
|
||||
path: string;
|
||||
description: string;
|
||||
models: EndpointModelSummary[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
copy: CopyHandler;
|
||||
copied?: string | null;
|
||||
baseUrl: string;
|
||||
modelsLoading?: boolean;
|
||||
}>) {
|
||||
const t = useTranslations("endpoint");
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
for (const m of models) {
|
||||
const owner = m.owned_by || "unknown";
|
||||
if (!map[owner]) map[owner] = [];
|
||||
map[owner].push(m);
|
||||
}
|
||||
return Object.entries(map).sort((a: any, b: any) => b[1].length - a[1].length);
|
||||
}, [models]);
|
||||
|
||||
const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id);
|
||||
const providerColor = (id) => resolveProvider(id)?.color || "#888";
|
||||
const providerName = (id) => getProviderDisplayName(id, resolveProvider(id));
|
||||
const copyId = `endpoint_${path}`;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{/* Header (always visible) */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
|
||||
>
|
||||
<div className={`flex items-center justify-center size-10 rounded-lg ${iconBg} shrink-0`}>
|
||||
<span className={`material-symbols-outlined text-xl ${iconColor}`}>{icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{title}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
|
||||
{modelsLoading ? "..." : t("modelsCount", { count: models.length })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-0.5">{description}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="border-t border-border px-4 pb-4">
|
||||
{/* Endpoint path + copy */}
|
||||
<div className="flex items-center gap-2 mt-3 mb-3">
|
||||
<code className="flex-1 text-xs font-mono text-text-muted bg-surface/80 px-3 py-1.5 rounded-lg truncate">
|
||||
{baseUrl.replace(/\/v1$/, "")}
|
||||
{path}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(`${baseUrl.replace(/\/v1$/, "")}${path}`, copyId)}
|
||||
className="p-1.5 hover:bg-surface rounded-lg text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{copied === copyId ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Models grouped by provider */}
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-surface/40 px-3 py-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">
|
||||
progress_activity
|
||||
</span>
|
||||
<span>{t("loadingModels")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{grouped.map(([providerId, providerModels]) => (
|
||||
<div key={providerId}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="size-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: providerColor(providerId) }}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-text-main">
|
||||
{providerName(providerId)}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
({(providerModels as any).length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-5 flex flex-wrap gap-1.5">
|
||||
{(providerModels as any).map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="text-xs px-2 py-0.5 rounded-md bg-surface/80 text-text-muted font-mono"
|
||||
title={m.id}
|
||||
>
|
||||
{m.root || m.id.split("/").pop()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ type AdaptaTutorialModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// The Adapta CTA href points at https://link.omniroute.online/adapta (our own
|
||||
// shortener, the `adapta` slug) so the click lands in our Kutt metrics. The visible
|
||||
// link text intentionally stays the real domain (agent.adapta.one/agentic-chat) so
|
||||
// users still see where they are going.
|
||||
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
|
||||
const t = useTranslations("providers.adaptaTutorial");
|
||||
|
||||
@@ -29,7 +33,7 @@ export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProp
|
||||
<p className="text-text-muted mt-0.5">
|
||||
{t("step1DescPrefix")}{" "}
|
||||
<a
|
||||
href="https://agent.adapta.one/agentic-chat"
|
||||
href="https://link.omniroute.online/adapta"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
|
||||
@@ -294,7 +294,9 @@ export default function EditConnectionModal({
|
||||
// external system on `isOpen`); remounting the 30+ field form per
|
||||
// connection id is a behavior-risking restructure out of scope here
|
||||
// (#11251 follow-up, #9985).
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
|
||||
// only fires on unconditional synchronous setState, and this one is
|
||||
// guarded by the isOpen/connection condition above.
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge, Button, Card } from "@/shared/components";
|
||||
import type {
|
||||
CliproxyAccountHealth,
|
||||
CliproxyAccountHealthResult,
|
||||
} from "@/lib/services/cliproxyAccountHealth";
|
||||
|
||||
const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
|
||||
ready: "Account health",
|
||||
disabled: "CLIProxyAPI is not installed",
|
||||
missing_key: "Management key is not configured",
|
||||
unreachable: "Management API is unreachable",
|
||||
unauthorized: "Management key was rejected",
|
||||
unsupported: "This CLIProxyAPI version does not expose account health",
|
||||
invalid_response: "Management API returned an unsupported response",
|
||||
};
|
||||
|
||||
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
|
||||
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
|
||||
return (
|
||||
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-text-main">
|
||||
{account.label || account.authIndex}
|
||||
</span>
|
||||
<Badge variant={account.disabled || account.unavailable ? "warning" : "success"}>
|
||||
{state || "Unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{[account.provider || account.type, account.label ? account.authIndex : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-text-muted">
|
||||
<div>{account.success.toLocaleString()} succeeded</div>
|
||||
<div>{account.failed.toLocaleString()} failed</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CliproxyAccountHealthCard() {
|
||||
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
setResult(await response.json());
|
||||
} catch {
|
||||
setResult({ state: "unreachable", accounts: [], version: null });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="CLIProxyAPI accounts"
|
||||
subtitle="Read-only status from the authenticated management API"
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{result?.state === "ready" ? (
|
||||
result.accounts.length > 0 ? (
|
||||
<ul aria-label="CLIProxyAPI account health">
|
||||
{result.accounts.map((account) => (
|
||||
<AccountRow key={account.authIndex} account={account} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No CLIProxyAPI accounts found.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">
|
||||
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { AutoStartToggle } from "../components/AutoStartToggle";
|
||||
import { AutoRestartAdoptedToggle } from "../components/AutoRestartAdoptedToggle";
|
||||
import { CliproxyConnectionPanel } from "../components/CliproxyConnectionPanel";
|
||||
import { CliproxyProviderExposureCard } from "../components/CliproxyProviderExposureCard";
|
||||
import { CliproxyAccountHealthCard } from "../components/CliproxyAccountHealthCard";
|
||||
|
||||
const NAME = "cliproxy";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function CliproxyServiceTab() {
|
||||
<AutoStartToggle name={NAME} />
|
||||
<AutoRestartAdoptedToggle name={NAME} />
|
||||
<CliproxyConnectionPanel />
|
||||
<CliproxyAccountHealthCard />
|
||||
<CliproxyProviderExposureCard />
|
||||
<CliproxyModelMappingEditor />
|
||||
<ServiceLogsPanel name={NAME} />
|
||||
|
||||
@@ -521,6 +521,7 @@ 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"),
|
||||
@@ -528,6 +529,10 @@ 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,7 +15,15 @@ import { HistoricSessionBanner } from "./components/session/HistoricSessionBanne
|
||||
|
||||
const BUFFER_MAX = 1000;
|
||||
|
||||
export function TrafficInspectorPageClient() {
|
||||
export function TrafficInspectorPageClient({
|
||||
title,
|
||||
subtitle,
|
||||
purpose,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
purpose?: string;
|
||||
} = {}) {
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
|
||||
@@ -91,6 +99,18 @@ 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,6 +9,7 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default function TrafficInspectorPage() {
|
||||
return <TrafficInspectorPageClient />;
|
||||
export default async function TrafficInspectorPage() {
|
||||
const t = await getTranslations("sidebar");
|
||||
return <TrafficInspectorPageClient title={t("trafficInspector")} subtitle={t("trafficInspectorSubtitle")} purpose={t("trafficInspectorPurpose")} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import HomePageClient from "../dashboard/HomePageClient";
|
||||
@@ -7,19 +6,18 @@ import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
|
||||
import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner";
|
||||
import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner";
|
||||
import NewsBanner from "../dashboard/NewsBanner";
|
||||
import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.setupComplete) {
|
||||
redirect("/dashboard/onboarding");
|
||||
}
|
||||
const machineId = await getMachineId();
|
||||
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
|
||||
return (
|
||||
<>
|
||||
{isBootstrapped && <BootstrapBanner />}
|
||||
<FirstRunReadinessCard setupComplete={Boolean(settings.setupComplete)} />
|
||||
<KimiSponsorBanner />
|
||||
<CheaperInferenceSponsorBanner />
|
||||
<VscodeCopilotBanner />
|
||||
|
||||
@@ -10,15 +10,13 @@
|
||||
* 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 { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate";
|
||||
|
||||
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
|
||||
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
|
||||
@@ -55,7 +53,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)
|
||||
@@ -124,39 +122,13 @@ 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). 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;
|
||||
// (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);
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -213,6 +185,9 @@ 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;
|
||||
@@ -236,7 +211,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
try {
|
||||
tm.updateTask(task.id, "working");
|
||||
const result = await handler(task);
|
||||
@@ -302,7 +277,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
tm.updateTask(task.id, "working");
|
||||
|
||||
const stream = createA2AStream(
|
||||
@@ -323,7 +298,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);
|
||||
const task = tm.getTask(taskId, callerOwner);
|
||||
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
|
||||
|
||||
return jsonRpcResult(id, { task });
|
||||
@@ -335,7 +310,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
try {
|
||||
const task = tm.cancelTask(taskId);
|
||||
const task = tm.cancelTask(taskId, callerOwner);
|
||||
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
51
src/app/api/a2a/_auth.ts
Normal file
51
src/app/api/a2a/_auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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,14 +1,23 @@
|
||||
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 }> }) {
|
||||
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;
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.cancelTask(id);
|
||||
const task = tm.cancelTask(id, auth.owner);
|
||||
return NextResponse.json({ task: { id: task.id, state: task.state } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
|
||||
const message = sanitizeErrorMessage(
|
||||
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,17 +1,30 @@
|
||||
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 }> }) {
|
||||
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;
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.getTask(id);
|
||||
const task = tm.getTask(id, auth.owner);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load A2A task";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to load A2A task"
|
||||
),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
@@ -22,6 +23,11 @@ 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");
|
||||
@@ -36,7 +42,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const tm = getTaskManager();
|
||||
const total = tm.countTasks({ state, skill });
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset });
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner);
|
||||
|
||||
return NextResponse.json({
|
||||
tasks,
|
||||
@@ -104,7 +110,10 @@ 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) {
|
||||
@@ -122,12 +131,18 @@ 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 }
|
||||
);
|
||||
}
|
||||
@@ -138,7 +153,9 @@ 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,13 +439,22 @@ 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
|
||||
profileArn: string | undefined,
|
||||
accountIdentity?: { email?: string | null; clientId?: string | null }
|
||||
): ProviderConnectionLike | null {
|
||||
return findKiroConnectionByIdentity(connections, { profileArn });
|
||||
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
|
||||
}
|
||||
|
||||
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user