fix(sse): coerce tool-call function schema root type:null to "object" (#6359) (#6375)

Clients like the Codex app emit parameters:{type:null,...} for some tools;
OpenAI-compatible upstreams reject with '400 Invalid schema for function ...:
schema must be a JSON Schema of type object, got type null'. toolSchemaSanitizer
already dropped the null; it now re-adds the mandatory root object type (plus
empty properties / open additionalProperties when absent). Combinator roots
(anyOf/oneOf/allOf) and explicit root types are preserved.

Regression guard: 5 new cases in tests/unit/tool-schema-sanitizer.test.mjs.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-06 14:58:21 -03:00
committed by GitHub
parent 0776f83fd0
commit a69547a7f5
3 changed files with 85 additions and 1 deletions

View File

@@ -34,6 +34,8 @@
- fix(sse): Responses API passthrough now drops internal commentary-phase output before forwarding to clients (gated by RESPONSES_PASSTHROUGH_DROP_COMMENTARY, default on) (#6199)
- **fix(sse):** tool-call function schemas with a root `type: null` are now coerced to `type: "object"` before dispatch ([#6359](https://github.com/diegosouzapw/OmniRoute/issues/6359)) — clients like the Codex app emit `parameters: { type: null, ... }` for some tools, which OpenAI-compatible upstreams reject with `400 Invalid schema for function '...': schema must be a JSON Schema of 'type: "object"', got 'type: null'`, failing the whole request. `toolSchemaSanitizer` already stripped the null; it now re-adds the mandatory root `"object"` type (and empty `properties`/open `additionalProperties` when absent). Combinator roots (`anyOf`/`oneOf`/`allOf`) and explicit root types are left untouched. Regression guard: 5 new cases in `tests/unit/tool-schema-sanitizer.test.mjs`.
- **fix(proxy):** stop the v3.8.44 proxy regression that leaked the real IP and disabled healthy proxies ([#6246](https://github.com/diegosouzapw/OmniRoute/issues/6246)). Two coupled defects from the new health scheduler: (1) **IP leak** — when a proxy assigned to a connection was marked `inactive`, resolution fell through to a **direct** egress instead of blocking, exposing the operator's real IP; (2) **over-deactivation** — the sweep flipped a proxy to `inactive` on the **first** failed probe and counted our own 5s timeout / a probe-target `5xx` as the proxy's fault, so healthy paid proxies vanished from egress selection ("my proxies are not being used anymore"). Fix: the sweep decision is extracted into a pure, network-free `decideProxyHealthAction` (`src/lib/proxyHealth/decision.ts`) — by default the health check now **only counts/logs and never downgrades status** (a proxy is downgraded/removed only with `PROXY_AUTO_REMOVE=true`, after `PROXY_AUTO_REMOVE_AFTER` **consecutive** conclusive failures); probes are classified tri-state so an inconclusive result (our timeout, or a `5xx` from the probe target) never penalizes the proxy, and the probe timeout is raised 5s→15s. Separately, `safeResolveProxy` now **fails closed** via the existing policy: a connection whose assigned proxy is dead is blocked instead of leaking direct (`hasBlockingProxyAssignment`), honoring the explicit `proxy off` toggles and the `PROXY_FAIL_OPEN=true` opt-out. Existing proxies stuck `inactive` by the old behavior need a one-time manual re-activate (the operator owns proxy status). Regression guards: `tests/unit/proxy-health-decide-action-6246.test.ts`, `tests/unit/proxy-assigned-unavailable-6246.test.ts`.
- **fix(proxy):** make "Test All" read-only and add bulk enable/disable ([#6246](https://github.com/diegosouzapw/OmniRoute/issues/6246)). Complements the core fail-closed / scheduler fix (#6296) with the two remaining reporter asks. (1) The **"Test All" button** (`POST /api/settings/proxies/auto-test`) used to flip a proxy to `inactive` on a failed reachability probe; since the egress selector excludes `inactive` proxies, a flaky probe (an unreachable `httpbin.org`, a proxy that blocks `HEAD`, or a slow paid proxy) silently disabled every proxy that failed — "Test All" is now **read-only by default** (only the operator sets a proxy active/inactive; opt back into the legacy test-and-set with `PROXY_HEALTH_AUTO_DEACTIVATE=true`). (2) Adds a **bulk enable/disable** proxies endpoint + toolbar action (`POST /api/settings/proxies/batch-activate`) so an operator can re-activate proxies in one click. Regression guard: `tests/unit/proxy-health-6246.test.ts`. (thanks @tenshiak)

View File

@@ -110,8 +110,31 @@ function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> {
return result;
}
/**
* OpenAI's Responses API strict validator requires the ROOT parameters schema to
* declare `type: "object"` explicitly. Clients like the Codex app emit
* `type: null` (rejected upstream as: schema must be a JSON Schema of
* 'type: "object"', got 'type: null' — issue #6359). sanitizeSchema drops the
* null, so at the root we re-add the mandatory "object". Combinator roots
* (anyOf/oneOf/allOf) are left alone — injecting a sibling `type` would change
* their meaning — and explicit root types are preserved as-is.
*/
function ensureRootObjectType(schema: Record<string, unknown>): void {
if (hasOwn(schema, "type")) return;
if (hasOwn(schema, "anyOf") || hasOwn(schema, "oneOf") || hasOwn(schema, "allOf")) return;
schema.type = "object";
if (!isPlainObject(schema.properties)) {
schema.properties = {};
if (!hasOwn(schema, "additionalProperties")) schema.additionalProperties = true;
}
}
function normalizeParameters(parameters: unknown): unknown {
if (isPlainObject(parameters)) return sanitizeSchema(parameters);
if (isPlainObject(parameters)) {
const sanitized = sanitizeSchema(parameters);
ensureRootObjectType(sanitized);
return sanitized;
}
if (parameters === null || parameters === undefined) {
return { type: "object", properties: {}, additionalProperties: true };
}

View File

@@ -456,3 +456,62 @@ describe("toolSchemaSanitizer", () => {
});
});
});
describe("root type coercion (#6359)", () => {
it("coerces root `type: null` to \"object\" (Codex codex_app__automation_update reproduction)", () => {
const tool = {
type: "function",
function: {
name: "codex_app__automation_update",
parameters: {
type: null,
properties: { schedule: { type: "string" } },
required: ["schedule"],
},
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
assert.deepEqual(out.function.parameters.properties.schedule, { type: "string" });
});
it("adds `type: \"object\"` when the root schema has properties but no type", () => {
const tool = {
type: "function",
function: { name: "x", parameters: { properties: { a: { type: "number" } } } },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
});
it("coerces root type on Responses-shape tools too", () => {
const tool = {
type: "function",
name: "y",
parameters: { type: null, properties: {} },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.parameters.type, "object");
});
it("does not inject a root type when the root is a combinator (anyOf)", () => {
const tool = {
type: "function",
function: {
name: "z",
parameters: { anyOf: [{ type: "object", properties: {} }] },
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, undefined);
});
it("leaves an explicit root `type: \"object\"` untouched", () => {
const tool = {
type: "function",
function: { name: "w", parameters: { type: "object", properties: { b: { type: "boolean" } } } },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
});
});