Skip to content

feat(llm): compat endpoints declare their JSON mode - #145

Merged
hung12ct merged 2 commits into
mainfrom
feat/openai-compat-json-mode
Aug 15, 2026
Merged

feat(llm): compat endpoints declare their JSON mode#145
hung12ct merged 2 commits into
mainfrom
feat/openai-compat-json-mode

Conversation

@hung12ct

Copy link
Copy Markdown
Owner

Problem

Two overlapping defects in the OpenAI adapter's compatible-endpoint path.

1. json_schema was sent unconditionally. GenerateStream set response_format {type:"json_schema"} whenever a StructuredOutput was on the context. Endpoints that publish only the older {type:"json_object"} reject that outright, so every schema-constrained call 400'd while plain chat and tool calling kept working — the failure lands exactly on planner/extractor/judge stages and nowhere else.

2. Capabilities() claimed features on every gateway's behalf. It was a method on the shared *Provider type returning a hardcoded {ImageInput: true, StructuredOutput: true}, and NewCompat returns that same type. CapabilityProvider exists so a consumer can reject an unsuitable provider at construction instead of discovering the gap from a confident, wrong answer — for compatible endpoints it produced exactly that: a pre-flight check that passed, then a failure mid-run.

Change

JSONMode with three values, declared per endpoint:

Mode Wire behavior
JSONModeSchema (zero value) response_format {type:"json_schema"}, schema inline, enforced server-side
JSONModeObject response_format {type:"json_object"} + schema appended as a trailing system message
JSONModeNone Structured request fails with an error naming the option, before anything is sent

Capabilities() derives from configuration (StructuredOutput: p.jsonMode != JSONModeNone), so one source of truth governs both the claim and the wire — they cannot drift. New is unchanged and still reports both; NewCompat prepends JSONModeNone + no image claim, with caller options winning.

Two details are load-bearing and commented as such:

  • The rendered instruction always contains the literal word JSON — endpoints in this mode commonly reject a request whose messages never mention it.
  • The schema message is appended last rather than merged into an existing system prompt: it is an instruction, and the final message is the one models follow most closely.

Breaking

Callers using structured output through NewCompat must add WithJSONMode(...). No in-tree callers outside the package. The alternative — defaulting compat to json_schema — preserves both the false capability claim and the 400.

JSONModeObject guarantees only that the reply parses as JSON; Strict degrades from a rule to a request. Documented in docs/providers.md. The adapter deliberately does not validate-and-retry: how many attempts a malformed reply is worth is caller policy.

Tests

Wire-level via captureOpenAIRequest: object mode sends json_object and not json_schema; the trailing system message carries schema + description + the word JSON; no response_format without a schema in any mode; JSONModeNone fails before reaching the httptest server (asserts the server was never called, not merely that an error came back); capability table-test across all three modes plus caller-option precedence.

gofmt -l . empty · make vet · make lint (0 issues) · make build · make test · make test-race — all clean.

NewCompat sent json_schema unconditionally and Capabilities() hardcoded
true for both features, so a gateway publishing only json_object 400'd on
every structured call after passing the construction-time check.
@hung12ct
hung12ct merged commit 995116e into main Aug 15, 2026
2 checks passed
@hung12ct
hung12ct deleted the feat/openai-compat-json-mode branch August 15, 2026 16:04
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.

1 participant