Enforce the API contract: JSON mode, sampling params, image input - #39
Conversation
Pre-merge checks · ✅ 3 · ⚠ 0 · ❌ 0 · ⏭ 0
|
|
🔒 Sebastion Code Security — couldn't run the audit on this push. This installation has reached its daily LLM token quota. We've logged the failure and audits will resume automatically after the daily reset. If you keep seeing this, get in touch via foundationmachines.ai/contact?topic=sebastion-audit and include this PR URL. |
There was a problem hiding this comment.
🟡 Changes recommended
There are contract-honesty regressions (e.g., prompt treated as “honoured” but ignored on chat completions, and streaming responses don’t emit x_openwire reports), which can reintroduce silent no-ops.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens OpenWire’s OpenAI-compatible gateway so request parameters are either enforced, rejected, or explicitly reported, and extends the contract with JSON-mode enforcement, image input support, capability discovery, and correct legacy /v1/completions shaping.
Changes:
- Adds server-side
response_formatenforcement (json_object/json_schema) with extraction, validation, and bounded repair retries. - Forwards and validates sampling parameters via
modelOptions, adds strict/no-op reporting, and introduces/v1/capabilities. - Implements image
data:URI handling (VS Code 1.125+), fixes SSE error emission, and maps/v1/completionsto the promisedtext_completionshapes.
File summaries
| File | Description |
|---|---|
| vitest.config.ts | Adds Vitest setup and aliases vscode to an in-memory mock for HTTP E2E tests. |
| tsconfig.json | Includes test files in TypeScript compilation (removes test exclusion). |
| src/types/vscode-lm.d.ts | Declares LanguageModelDataPart typing for image input support. |
| src/test/vscode-mock.ts | Implements a minimal vscode mock + scripted model for tests. |
| src/test/gateway.test.ts | Adds extensive E2E HTTP tests covering params, JSON mode, images, streaming, legacy endpoint. |
| src/server/gateway.ts | Adds /v1/capabilities, fixes SSE timeout/error behavior, maps legacy completions correctly, makes body limit configurable. |
| src/server/config.ts | Adds strictParams, jsonModeMaxRetries, maxRequestBodyMb settings. |
| src/routes/tool-calls.ts | Centralizes XML tool-call parsing and tool_choice planning/application. |
| src/routes/tool-calls.test.ts | Unit tests for tool parsing/mapping and tool_choice behavior. |
| src/routes/schema.ts | Implements JSON Schema subset support + unsupported-keyword detection. |
| src/routes/schema.test.ts | Unit tests for schema keyword detection and value validation semantics. |
| src/routes/params.ts | Implements request param classification + sampling option validation/forwarding. |
| src/routes/params.test.ts | Unit tests for param classification, validation, and stream usage detection. |
| src/routes/legacy.ts | Maps chat responses/chunks to legacy text_completion shape and rewrites SSE frames. |
| src/routes/legacy.test.ts | Unit tests for legacy mapping and SSE line rewriting. |
| src/routes/json-mode.ts | Implements JSON-mode instruction building, JSON extraction, and validation/repair helpers. |
| src/routes/json-mode.test.ts | Unit + performance tests for JSON extraction/validation and instruction building. |
| src/routes/errors.ts | Introduces typed RequestError with HTTP status for pure modules. |
| src/routes/content.ts | Normalizes messages/content parts, adds image data: URI parsing/validation. |
| src/routes/content.test.ts | Unit tests for content normalization, data URIs, image handling, and helpers. |
| src/routes/chat.ts | Refactors chat pipeline: request prep, JSON enforcement, tool_choice, image parts, streaming behavior, SSE error helper. |
| src/routes/chat.test.ts | Removes duplicated “pure function copies” tests in favor of new modular tests. |
| README.md | Documents the enforced contract (params, JSON mode, images, capabilities) and updated endpoints/structure. |
| package.json | Bumps version to 0.4.0 and adds new server settings to contribution schema. |
| package-lock.json | Updates lockfile version metadata to 0.4.0. |
Review details
- Files reviewed: 22/25 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
🔒 Sebastion Code Security — couldn't run the audit on this push. This installation has reached its daily LLM token quota. We've logged the failure and audits will resume automatically after the daily reset. If you keep seeing this, get in touch via foundationmachines.ai/contact?topic=sebastion-audit and include this PR URL. |
|
🔒 Sebastion Code Security — couldn't run the audit on this push. This installation has reached its daily LLM token quota. We've logged the failure and audits will resume automatically after the daily reset. If you keep seeing this, get in touch via foundationmachines.ai/contact?topic=sebastion-audit and include this PR URL. |
OpenWire accepted most of the OpenAI request body and silently discarded it. `response_format`, `temperature`, `top_p`, `seed`, `stop` and penalties had no references in src/ at all, and `max_tokens` was aliased in the gateway then never read. Because unknown keys were simply parsed and dropped, callers got HTTP 200 for parameters that did nothing, with no way to tell "unsupported" from "ignored". Every request field now lands in one of three buckets: honoured, rejected, or reported back. Nothing is dropped in silence. - response_format is enforced server-side. The VS Code LM path has no provider-level JSON mode, so prompt discipline cannot be a contract: OpenWire appends a strict instruction, recovers JSON from prose or ```json fences, validates it, retries once with a harsher instruction, and returns 502 rather than prose claiming to be JSON. JSON-mode streaming buffers, since validity cannot be judged mid-stream. - Sampling params are forwarded via modelOptions. Invalid values are rejected, not clamped, so an altered request is never mistaken for an honoured one. - JSON Schema is validated by a dependency-free subset validator. Keywords outside the subset return 400 instead of being accepted and left unenforced. - Image input maps image_url parts to LanguageModelDataPart. Only base64 data: URIs are accepted; fetching remote URLs would let a caller drive requests from the user's machine into their own network. Closes #17. - LanguageModelDataPart only reached stable typings in 1.125, so the engine floor stays at ^1.95.0 and the capability is feature-detected, returning 501 when unavailable. Gating the contract fixes behind a two-month-old VS Code would have cost far more than it bought. - Request timeouts no longer corrupt an open SSE stream. sendError wrote a bare JSON body mid-stream and truncated it without [DONE]. - tool_choice is honoured properly: a named function narrows the tool list and forces Required; "none" drops tools entirely. - /v1/completions returns the text_completion shape it promises. - stream_options.include_usage is supported. - Removes the unreachable needsToolParsing branch. Closes #34. - Adds GET /v1/capabilities so callers never have to probe empirically. Testing: chat.test.ts held copies of the source functions, so the suite validated duplicated logic rather than shipped code, and tsconfig excluded test files from type checking. Pure logic moves into vscode-free modules that tests import directly, and a mocked vscode module lets the real gateway be exercised over HTTP. 18 tests become 186, including 49 end-to-end. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9704ff31-19db-4f8b-8553-8cfb5ba9aba4
All four undercut the guarantee the release is built on: a constraint the
server said it enforced but did not.
- schema.ts used `key in value`, which resolves through Object.prototype. A
schema property named `constructor` or `toString` appeared present on every
object, so it could never validate and every response burned the repair
retries into a 502. Conversely `required: ["toString"]` was satisfied by an
object without it. Uses hasOwnProperty now, in deepEqual too.
- additionalProperties was only enforced inside the `properties` branch, so
`{"type":"object","additionalProperties":false}` alone was ignored entirely.
It is now enforced independently, and the sub-schema form is validated rather
than merely accepted. Tuple `items` and unsupported keywords nested under
additionalProperties are rejected instead of silently unchecked.
- json_object accepted any JSON value, so `null`, `5`, `"hi"` and `[1,2]` all
passed as honoured JSON-mode responses and broke `JSON.parse(x).field` for
the caller. It now requires an object and routes anything else through the
repair retry.
- balancedCandidates did not advance past a failed scan, making extractJson
quadratic on unbalanced output and blocking the extension host's single
thread. Skipping ahead is not sound, since a later brace can still close, so
failures are capped instead: 50k unmatched braces goes from 2807ms to 5ms.
Adds regression tests for each. 186 tests to 202.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9704ff31-19db-4f8b-8553-8cfb5ba9aba4
Copilot's review found four issues, two of which were real holes in the guarantee this branch is built on. - Streaming never attached the x_openwire report, so `stream: true` was a way to turn unsupported params back into silent no-ops unless strictParams was on. A metadata-only frame (choices: []) now precedes the first content delta, matching the shape OpenAI already uses for include_usage. - `prompt` was classified as honoured, but only /v1/completions consumes it and it is deleted before the chat path runs. Sending it to /v1/chat/completions did nothing and was not reported. It is no longer structural, so the chat path reports it; the legacy path still consumes it first and stays quiet. - README claimed OpenWire "never accepts a parameter it does not honour", which contradicts the Reported bucket directly beneath it. It never silently ignores one; that is the accurate claim. - partsToText was documented as preserving an image placeholder when it drops non-text parts. Comment corrected to match. Adds regression tests for each, including that the metadata frame arrives first and is absent when everything was honoured. 202 tests to 206. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9704ff31-19db-4f8b-8553-8cfb5ba9aba4
#38 converts the README examples to a model-agnostic <model-id> placeholder. The JSON mode and image sections added on this branch sit in regions #38 does not touch, so git auto-merges the two cleanly and neither PR reports a conflict -- but whichever landed second would leave the README half converted, with some examples using a placeholder and some naming a specific model. Only the two examples added by this branch are changed. The Quick start and OpenClaw sections stay as they are, since those are #38's to convert, and touching them here would manufacture the textual conflict this avoids. Until #38 merges the README is briefly mixed. That is the intended order: #38 first, then this. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9704ff31-19db-4f8b-8553-8cfb5ba9aba4
63b5dac to
c8f484e
Compare
WalkthroughEnforces the OpenAI API contract by honouring response_format with server-side JSON mode, forwarding sampling parameters, supporting image input via data URIs, and rejecting or reporting unsupported parameters instead of silently ignoring them. Adds a capabilities endpoint, fixes SSE error handling mid-stream, corrects the legacy completions shape, and rewrites the test infrastructure so tests import real shipped code through a vscode mock. Test count goes from 18 to 202. Changes
🎯 Effort: 5 (Complex) · ⏱ ~180 minutes Generated by Sebastion Code Security · docs |
There was a problem hiding this comment.
🔒 Sebastion Code Security review
1 finding on this PR.
Sources: 1 LLM
Inline comments are posted on changed lines below.
Other findings (in scanned files but outside this diff)
- HIGH
hardcoded-secret· CWE-798 —package.json:100— The default API key is hardcoded as 'change-me-openwire-key'. Users who don't change this value are exposed to unauthenticated access from any local process. A predictable default secret is equivalent to no secret.
Fix: Remove the default value or generate a random key on first activation and store it via VS Code's SecretStorage API.
Findings also visible in Security tab.
Audited by Sebastion Code Security · docs · install on more repos
Prompt for all review comments with AI agents
In package.json:
- Line 100: In
package.jsonat line100, the default API key is hardcoded as 'change-me-openwire-key'. Users who don't change this value are exposed to unauthenticated access from any local process. A predictable default secret is equivalent to no secret. Fix: Remove the default value or generate a random key on first activation and store it via VS Code's SecretStorage API. Context: rulehardcoded-secretand CWECWE-798.
🔧 Autofix (action required)
Sebastion Code Security couldn't open a draft autofix PR because the installed GitHub App is missing Contents: Read and write. A repository owner must approve the pending permission at https://github.com/settings/installations, then push another commit to retry. This permission only lets Sebastion create fix branches and draft PRs; Sebastion cannot approve or merge them.
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
There was a problem hiding this comment.
🔒 Sebastion Code Security review
1 finding on this PR.
Sources: 1 LLM
Inline comments are posted on changed lines below.
Other findings (in scanned files but outside this diff)
- HIGH
hardcoded-secret· CWE-798 —package.json:107— The default API key is a well-known string 'change-me-openwire-key'. Any user who does not change this value has an effectively unauthenticated server. Attackers aware of this default can access the API immediately.
Fix: Remove the default value or generate a random key on first activation and store it in VS Code's SecretStorage. At minimum, warn the user on startup if the key is still the default.
Findings also visible in Security tab.
Audited by Sebastion Code Security · docs · install on more repos
Prompt for all review comments with AI agents
In package.json:
- Line 107: In
package.jsonat line107, the default API key is a well-known string 'change-me-openwire-key'. Any user who does not change this value has an effectively unauthenticated server. Attackers aware of this default can access the API immediately. Fix: Remove the default value or generate a random key on first activation and store it in VS Code's SecretStorage. At minimum, warn the user on startup if the key is still the default. Context: rulehardcoded-secretand CWECWE-798.
🔧 Autofix (action required)
Sebastion Code Security couldn't open a draft autofix PR because the installed GitHub App is missing Contents: Read and write. A repository owner must approve the pending permission at https://github.com/settings/installations, then push another commit to retry. This permission only lets Sebastion create fix branches and draft PRs; Sebastion cannot approve or merge them.
Why
OpenWire relayed messages but not parameters.
response_format,temperature,top_p,seed,stopand the penalties had zero references insrc/, andmax_tokenswas aliased in the gateway then never read. BecausereadBodyjustJSON.parsed the body, unknown keys were indistinguishable from honoured ones — so callers got HTTP 200 for parameters that did nothing, with no way to tell "unsupported" from "ignored".That is what cost a full debugging cycle downstream: a
response_format: {"type":"json_object"}probe returned 200 and fenced prose, and the only way to find out was to read the source.Verified against the live v0.3.0 server with
claude-opus-4.8:response_format: json_object{"risk":"high"}image_url(32×32 red PNG)RedGET /v1/capabilitiesWhat changed
Every request field now lands in one of four buckets — honoured, forwarded to the provider, rejected, or reported via
x_openwire.unsupported_params. Nothing is dropped in silence.openWire.server.strictParamsturns reported fields into 400s, andGET /v1/capabilitiesdistinguishes OpenWire-enforced capabilities from provider-dependent forwarded options.response_formatenforced server-side. The VS Code LM path has no provider-level JSON mode, so prompt discipline cannot be a contract. OpenWire appends a strict instruction, recovers JSON from prose or```jsonfences, validates it, retries once with a harsher instruction quoting the bad output, and returns 502 rather than prose claiming to be JSON.json_objectguarantees an object, not any JSON value.options.modelOptions. Invalid values are rejected, not clamped —temperature: 9is a 400, it does not quietly become 2.image_urlparts toLanguageModelDataPart. Closes Add support for uploading images in OpenAI-compatible formats #17.sendErrorpreviously wrote a bare JSON body mid-stream and truncated it without[DONE].tool_choicehonoured properly — a named function narrows the tool list and forcesRequired;"none"drops tools entirely./v1/completionsreturns thetext_completionshape it promises.stream_options.include_usagesupported.needsToolParsingbranch. Closes Streaming XML tool call fallback is unreachable #34.Decisions worth reviewing
Engine floor stays at
^1.95.0.LanguageModelDataPartonly reached stable typings in 1.125 (published 2026-06-17), not 1.100 — verified by bisecting published@types/vscodetarballs. A 1.125 floor would have gated the contract fixes behind a two-month-old VS Code, so image support is runtime feature-detected and returns a clear 501 when unavailable, reported via/v1/capabilities.Images accept
data:URIs only. Fetching remotehttp(s)URLs would let any caller drive requests from the user's machine into their own network. Remote URLs return 400.JSON mode buffers streaming. Validity cannot be judged mid-stream, so a JSON-mode stream emits one content delta then
[DONE]. Documented rather than hidden.Streaming XML tool fallback stays unsupported. #34 was filed as unreachable code; the fix is deleting it and documenting the limit, not growing a speculative parser.
Testing
chat.test.tsheld copies ofnormalizeContentandparseXmlToolCalls, andtsconfigexcluded test files from type-checking — so the suite validated duplicated logic, not shipped code. Pure logic now lives in vscode-free modules that tests import directly, and a mockedvscodemodule lets the real gateway run over HTTP.18 tests → 218, including 51 end-to-end. Plus a live run in an Extension Development Host against real Claude models:
json_schemaconformance (int range,maxLength, no extra keys), 6/6 rejection guardrails, streaming with usage chunk, buffered JSON-mode streaming, and the legacy endpoint shape.A final review also tightened malformed JSON Schema validation, rejected invalid
tools/tool_choicecombinations, and separated provider-dependent forwarded sampling options from OpenWire-enforced capabilities.A review pass caught four further gaps, all fixed with regression tests:
key in valuewalked the prototype chain (a schema property namedconstructorcould never validate);additionalProperties: falsewas ignored without a siblingproperties;json_objectacceptednull/5/"hi"/[1,2]; andextractJsonwas quadratic on unbalanced output (50k braces: 2807ms → 5ms).Not verified
Whether the Copilot backend actually acts on
modelOptions. OpenWire demonstrably passes them (an integration test asserts the exact object reachingsendRequest), but provider honouring is outside our control and worth a manual check.Note for reviewers
This branch rewrites large parts of
README.md, which also overlaps with #38. I tested themerge both ways (
git merge-treeand a real throwaway merge) and it is clean — no textualconflict, git auto-merges
README.mdfine.There is a semantic overlap git cannot see, though. #38 replaces hardcoded
claude-sonnet-4.6with<model-id>to make the examples model-agnostic. The new JSON-modeand image sections added here still use
claude-sonnet-4.6in their examples, so whichevermerges second leaves the README half-converted. Worth a follow-up pass to align the new
examples with #38's convention.