diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b1d7ae6e..5ca6704d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/open-sse/services/toolSchemaSanitizer.ts b/open-sse/services/toolSchemaSanitizer.ts index 76a25ef28f..60853e213c 100644 --- a/open-sse/services/toolSchemaSanitizer.ts +++ b/open-sse/services/toolSchemaSanitizer.ts @@ -110,8 +110,31 @@ function sanitizeSchema(value: unknown, depth = 0): Record { 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): 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 }; } diff --git a/tests/unit/tool-schema-sanitizer.test.mjs b/tests/unit/tool-schema-sanitizer.test.mjs index 904d657dcd..f01c06aed1 100644 --- a/tests/unit/tool-schema-sanitizer.test.mjs +++ b/tests/unit/tool-schema-sanitizer.test.mjs @@ -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"); + }); +});