Skip to content

Enforce the API contract: JSON mode, sampling params, image input - #39

Merged
lewiswigmore merged 6 commits into
mainfrom
lewiswigmore-openwire-feature-gap-review
Aug 30, 2026
Merged

lewiswigmore merged 6 commits into
mainfrom
lewiswigmore-openwire-feature-gap-review

Conversation

@lewiswigmore

@lewiswigmore lewiswigmore commented Aug 26, 2026 •

Copy link
Copy Markdown
Owner

Why

OpenWire relayed messages but not parameters. response_format, temperature, top_p, seed, stop and the penalties had zero references in src/, and max_tokens was aliased in the gateway then never read. Because readBody just JSON.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:

Probe v0.3.0 this PR
response_format: json_object HTTP 200, markdown prose, unparseable {"risk":"high"}
image_url (32×32 red PNG) "I can't see any image in our conversation" Red
GET /v1/capabilities 404 full capability report

What 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.strictParams turns reported fields into 400s, and GET /v1/capabilities distinguishes OpenWire-enforced capabilities from provider-dependent forwarded options.

  • response_format 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 quoting the bad output, and returns 502 rather than prose claiming to be JSON. json_object guarantees an object, not any JSON value.
  • Sampling params forwarded via options.modelOptions. Invalid values are rejected, not clamped — temperature: 9 is a 400, it does not quietly become 2.
  • JSON Schema validated by a dependency-free subset validator. Keywords outside the documented subset return 400 instead of being accepted and left unenforced.
  • Image input maps image_url parts to LanguageModelDataPart. Closes Add support for uploading images in OpenAI-compatible formats #17.
  • SSE timeouts no longer corrupt the stream. sendError previously wrote a bare JSON body mid-stream and truncated it without [DONE].
  • tool_choice 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 supported.
  • Removes the unreachable needsToolParsing branch. Closes Streaming XML tool call fallback is unreachable #34.

Decisions worth reviewing

Engine floor stays at ^1.95.0. LanguageModelDataPart only reached stable typings in 1.125 (published 2026-06-17), not 1.100 — verified by bisecting published @types/vscode tarballs. 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 remote http(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.ts held copies of normalizeContent and parseXmlToolCalls, and tsconfig excluded 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 mocked vscode module 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_schema conformance (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_choice combinations, 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 value walked the prototype chain (a schema property named constructor could never validate); additionalProperties: false was ignored without a sibling properties; json_object accepted null/5/"hi"/[1,2]; and extractJson was 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 reaching sendRequest), 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 the
merge both ways (git merge-tree and a real throwaway merge) and it is clean — no textual
conflict, git auto-merges README.md fine.

There is a semantic overlap git cannot see, though. #38 replaces hardcoded
claude-sonnet-4.6 with <model-id> to make the examples model-agnostic. The new JSON-mode
and image sections added here still use claude-sonnet-4.6 in their examples, so whichever
merges second leaves the README half-converted. Worth a follow-up pass to align the new
examples with #38's convention.

Copilot AI lite review requested due to automatic review settings August 26, 2026 17:05
@andesyte-code-security

andesyte-code-security Bot commented Aug 26, 2026 •

Copy link
Copy Markdown
Pre-merge checks · ✅ 3 · ⚠ 0 · ❌ 0 · ⏭ 0
Check Status Reason
PR title ✅ The title specifically describes enforcing JSON mode, sampling params and image input in the API contract.
Description ✅ The body thoroughly explains what changed, why it was needed and key design decisions across a 25-file PR.
Linked issue ✅ The diff adds image input support (closing #17) and removes the unreachable XML tool parsing branch (closing #34).

@andesyte-code-security

Copy link
Copy Markdown

🔒 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_format enforcement (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/completions to the promised text_completion shapes.
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.

Comment thread src/routes/params.ts
Comment thread src/routes/chat.ts
Comment thread README.md Outdated
Comment thread src/routes/content.ts Outdated
@andesyte-code-security

Copy link
Copy Markdown

🔒 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.

@andesyte-code-security

Copy link
Copy Markdown

🔒 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 and others added 5 commits August 30, 2026 15:43
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
@lewiswigmore
lewiswigmore force-pushed the lewiswigmore-openwire-feature-gap-review branch from 63b5dac to c8f484e Compare August 30, 2026 14:45
@andesyte-code-security

andesyte-code-security Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

Walkthrough

Enforces 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

File Summary
JSON mode enforcement src/routes/json-mode.ts, src/routes/json-mode.test.ts, src/routes/schema.ts, src/routes/schema.test.ts Adds server-side response_format parsing, JSON extraction and repair, and a dependency-free JSON Schema subset validator that rejects unenforced keywords.
Content normalisation and image input src/routes/content.ts, src/routes/content.test.ts, src/types/vscode-lm.d.ts Extracts content handling into a vscode-free module with support for image_url parts via data URIs and runtime detection of LanguageModelDataPart.
Sampling parameter forwarding src/routes/params.ts, src/routes/params.test.ts Classifies every request field as honoured, forwarded, unsupported or unknown, validates ranges, and builds modelOptions for the VS Code LM API.
Tool call handling src/routes/tool-calls.ts, src/routes/tool-calls.test.ts Extracts tool mapping, XML parsing and tool_choice planning into a standalone module with named-function narrowing and none/required modes.
Legacy completions endpoint src/routes/legacy.ts, src/routes/legacy.test.ts Adds proper text_completion shape mapping for both streaming and non-streaming responses on /v1/completions.
Error handling src/routes/errors.ts Introduces a RequestError class so pure modules can signal HTTP-level failures without depending on vscode or server layers.
Gateway and config src/server/gateway.ts, src/server/config.ts Adds capabilities endpoint, SSE-safe timeout and error handling, legacy endpoint routing, and three new config options for strict params, JSON retries and body size.
Chat pipeline src/routes/chat.ts Refactors the chat handler to use the new extracted modules for content, params, JSON mode, tools and legacy rewriting.
Test infrastructure src/test/gateway.test.ts, src/test/vscode-mock.ts, vitest.config.ts, tsconfig.json, src/routes/chat.test.ts Replaces the old duplicated-logic tests with a comprehensive suite using a vscode mock and real HTTP, removing test files from tsconfig exclusion.
Docs and versioning README.md, package.json, package-lock.json Updates README feature list and compatibility table, bumps version to 0.4.0, and adds new configuration entries.

🎯 Effort: 5 (Complex) · ⏱ ~180 minutes

Generated by Sebastion Code Security · docs

@andesyte-code-security andesyte-code-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Sebastion Code Security review

1 finding on this PR. ⚠️ 1 high

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.json at line 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. Context: rule hardcoded-secret and CWE CWE-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.

@github-advanced-security

Copy link
Copy Markdown

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:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@lewiswigmore
lewiswigmore merged commit b8a5d2c into main Aug 30, 2026
5 checks passed
@lewiswigmore
lewiswigmore deleted the lewiswigmore-openwire-feature-gap-review branch August 30, 2026 14:49

@andesyte-code-security andesyte-code-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Sebastion Code Security review

1 finding on this PR. ⚠️ 1 high

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.json at line 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. Context: rule hardcoded-secret and CWE CWE-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Streaming XML tool call fallback is unreachable Add support for uploading images in OpenAI-compatible formats

3 participants