mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
Compare commits
1 Commits
fix/10348-
...
fix/9617-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c126108a15 |
@@ -1 +0,0 @@
|
||||
- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)
|
||||
1
changelog.d/fixes/9617-gemini-uniqueitems-strip.md
Normal file
1
changelog.d/fixes/9617-gemini-uniqueitems-strip.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): strip uniqueItems from Gemini tool schemas (Gemini rejects it with 400 'Unknown name uniqueItems') (#9617)
|
||||
@@ -58,6 +58,11 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
|
||||
"contains",
|
||||
"minContains",
|
||||
"maxContains",
|
||||
// #9617: array uniqueness keyword — agentic-CLI tool schemas (JSON-Schema
|
||||
// generators) set this routinely and Gemini's schema parser has no field for
|
||||
// it, rejecting the whole request with "Unknown name \"uniqueItems\"".
|
||||
// Upstream 9router already strips it alongside `contains` for the same error.
|
||||
"uniqueItems",
|
||||
// Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf)
|
||||
"anyOf",
|
||||
"oneOf",
|
||||
|
||||
@@ -105,45 +105,6 @@ function loadFromDb() {
|
||||
|
||||
loadFromDb();
|
||||
|
||||
// Default-off override that restores the verbose [ProxyEgress] console line (raw
|
||||
// client/egress IPs + account prefix). Kept OFF by default so the process log leaks
|
||||
// neither IPs nor the account prefix. Deliberately NOT coupled to debugMode
|
||||
// (src/lib/db/settings.ts defaults debugMode to true) — this verbosity is opt-in only.
|
||||
// Storage (in-memory ring buffer + SQLite) is untouched and always keeps full IPs.
|
||||
const PROXY_LOG_INCLUDE_IPS =
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
|
||||
process.env.PROXY_LOG_INCLUDE_IPS === "1";
|
||||
|
||||
/**
|
||||
* Pure formatter for the [ProxyEgress] process-log line (#10348). At the default level it
|
||||
* emits a short, IP/prefix-free summary; when details are opted in it restores the full
|
||||
* verbose line including client/egress IPs and the account. Extracted as a separate
|
||||
* function so it is unit-testable without patching console.log and so the change never
|
||||
* grows logProxyEvent itself.
|
||||
*/
|
||||
export function formatProxyEgressConsoleLine(params: {
|
||||
provider: string | null;
|
||||
account: string | null;
|
||||
clientIp: string | null;
|
||||
egressIp: string | null;
|
||||
level: string;
|
||||
proxyHost: string | null | undefined;
|
||||
status: string;
|
||||
includeDetails?: boolean;
|
||||
}): string {
|
||||
const provider = params.provider || "-";
|
||||
const status = params.status;
|
||||
if (!params.includeDetails) {
|
||||
return `[ProxyEgress] ${provider} status=${status}`;
|
||||
}
|
||||
const proxy = params.proxyHost ? `:${params.proxyHost}` : "";
|
||||
return (
|
||||
`[ProxyEgress] ${provider}/${params.account || "-"} ` +
|
||||
`in=${params.clientIp || "?"} out=${params.egressIp || "?"} ` +
|
||||
`proxy=${params.level}${proxy} status=${status}`
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── Log a proxy event ────────────────
|
||||
|
||||
export function logProxyEvent(entry: ProxyLogInput) {
|
||||
@@ -170,16 +131,9 @@ export function logProxyEvent(entry: ProxyLogInput) {
|
||||
// IP each account is entering (clientIp) and leaving (egressIp) by.
|
||||
if (log.proxy || log.egressIp) {
|
||||
console.log(
|
||||
formatProxyEgressConsoleLine({
|
||||
provider: log.provider,
|
||||
account: log.account,
|
||||
clientIp: log.clientIp,
|
||||
egressIp: log.egressIp,
|
||||
level: log.level,
|
||||
proxyHost: log.proxy?.host,
|
||||
status: log.status,
|
||||
includeDetails: PROXY_LOG_INCLUDE_IPS,
|
||||
})
|
||||
`[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` +
|
||||
`in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` +
|
||||
`proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
77
tests/unit/9617-gemini-uniqueitems.test.ts
Normal file
77
tests/unit/9617-gemini-uniqueitems.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts";
|
||||
|
||||
// Issue #9617: Gemini rejects `uniqueItems` in function_declarations parameter schemas
|
||||
// with HTTP 400 "Unknown name \"uniqueItems\" ... Cannot find field" (Gemini's protobuf-JSON
|
||||
// schema parser only accepts a subset of JSON Schema/OpenAPI 3.0 — the same class of error
|
||||
// already fixed for `multipleOf`, `minItems`, `maxItems`, `strict`, `encrypted` in
|
||||
// GEMINI_UNSUPPORTED_SCHEMA_KEYS, open-sse/translator/helpers/geminiHelper.ts).
|
||||
test("buildGeminiTools strips uniqueItems from array schemas (issue #9617)", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "exit_worktree",
|
||||
description: "test tool with an array-of-objects parameter",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
action: { type: "string" },
|
||||
},
|
||||
required: ["name", "action"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["items"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const geminiTools = buildGeminiTools(tools);
|
||||
const serialized = JSON.stringify(geminiTools);
|
||||
|
||||
assert.ok(geminiTools, "expected buildGeminiTools to return a tools array");
|
||||
assert.equal(
|
||||
serialized.includes("uniqueItems"),
|
||||
false,
|
||||
`uniqueItems leaked into the Gemini payload (would trigger upstream 400 "Unknown name \\"uniqueItems\\""): ${serialized}`
|
||||
);
|
||||
});
|
||||
|
||||
// Companion: a top-level (non-nested) array property with uniqueItems is also stripped —
|
||||
// matches the reporter's deeply-nested case with extra path coverage.
|
||||
test("buildGeminiTools strips uniqueItems from a top-level array parameter schema (issue #9617)", () => {
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_worktrees",
|
||||
description: "test tool with a top-level array parameter",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
paths: {
|
||||
type: "array",
|
||||
uniqueItems: true,
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["paths"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const serialized = JSON.stringify(buildGeminiTools(tools));
|
||||
assert.equal(serialized.includes("uniqueItems"), false);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Regression guard for #10348 — default process logs must not leak client/egress IPs
|
||||
// or the raw account prefix. Storage (in-memory ring buffer + SQLite) stays intact;
|
||||
// only the process-log emission changes.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-10348-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
|
||||
|
||||
function resetStorage() {
|
||||
proxyLogger.clearProxyLogs();
|
||||
core.closeDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => resetStorage());
|
||||
test.after(() => resetStorage());
|
||||
|
||||
test("[10348] default ProxyEgress console line redacts client IP, egress IP, and account prefix", () => {
|
||||
const captured: string[] = [];
|
||||
const origConsole = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
captured.push(args.map(String).join(" "));
|
||||
};
|
||||
try {
|
||||
proxyLogger.logProxyEvent({
|
||||
status: "error",
|
||||
provider: "codex",
|
||||
clientIp: "198.51.100.7",
|
||||
egressIp: "203.0.113.9",
|
||||
account: "aabbccdd",
|
||||
level: "account",
|
||||
});
|
||||
} finally {
|
||||
console.log = origConsole;
|
||||
}
|
||||
const line = captured.find((l) => l.includes("[ProxyEgress]"));
|
||||
assert.ok(line, "expected a [ProxyEgress] console line");
|
||||
assert.ok(line!.includes("codex"), "expected provider in the line");
|
||||
assert.ok(line!.includes("status=error"), "expected status=error in the line");
|
||||
assert.ok(
|
||||
!line!.includes("198.51.100.7"),
|
||||
"client IP must be redacted from the console line by default"
|
||||
);
|
||||
assert.ok(
|
||||
!line!.includes("203.0.113.9"),
|
||||
"egress IP must be redacted from the console line by default"
|
||||
);
|
||||
assert.ok(
|
||||
!line!.includes("aabbccdd"),
|
||||
"account prefix must be redacted from the console line by default"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user