Skip to content

Add structured output support for Ollama provider - #72

Open
gavmor wants to merge 6 commits into
jrswab:masterfrom
gavmor:structured-output
Open

Add structured output support for Ollama provider#72
gavmor wants to merge 6 commits into
jrswab:masterfrom
gavmor:structured-output

Conversation

@gavmor

@gavmor gavmor commented Apr 10, 2026

Copy link
Copy Markdown

Summary

Adds a format field to AgentConfig (TOML: format = "json" or a JSON Schema table), provider.Request, and ollamaRequest. When set, format is passed as-is to Ollama's format parameter, enabling both JSON mode and schema-constrained structured output.

Example

[format]
type = "object"
properties = { choice = { type = "integer" }, reason = { type = "string" } }
required = ["choice"]

Test plan

  • Set format = "json" in an agent TOML and verify Ollama returns valid JSON
  • Set format to a JSON Schema table and verify output is schema-constrained
  • Agents without format behave identically to before

🤖 Generated with Claude Code

Summary

This PR introduces provider-agnostic structured output support across the platform. It adds a format field to agent configuration that accepts either "json" for JSON mode or a JSON Schema table for schema-constrained output. A unified ResponseFormat type was implemented in the provider interface, with each provider implementing a SupportsFormat capability check. Format parameters are now validated at the CLI level, with early exit if the selected provider doesn't support the requested format. Provider-specific implementations handle format translation: OpenAI uses response_format with JSON object and JSON Schema modes; Anthropic uses prompt injection for JSON mode and forced tool calls for schemas; Gemini uses ResponseMimeType and ResponseSchema in generation config; Ollama passes the format field directly. Bedrock provider does not support structured output.

Changelog

Added

  • Format field to AgentConfig supporting "json" string or JSON Schema map[string]interface{}
  • FormatType enum (FormatNone, FormatJSON, FormatSchema) and ResponseFormat struct to provider interface
  • SupportsFormat(format *ResponseFormat) bool method to Provider interface
  • Format validation in CLI that aborts with exit code 2 if provider doesn't support requested format
  • Provider-specific format support implementations for OpenAI (response_format with json_object and json_schema modes), Anthropic (tool forcing for schemas, prompt injection for JSON), Gemini (response_mime_type and response_schema), and Ollama (direct format passthrough)
  • TestRun_ProviderConfigValidation consolidated test covering multiple configuration validation scenarios
  • TestScaffold_IncludesFormat and TestValidate_Format to verify format field handling in agent configuration
  • Format display in agents show command output

Changed

  • Request type now includes optional Format *ResponseFormat field
  • Ollama request payload struct now includes format field
  • OpenAI request payload now includes response_format field with wire-format structs for JSON and JSON Schema modes
  • Anthropic request handling refactored to support tool choice forcing for schema-constrained output
  • Gemini generation config extended with ResponseMimeType and ResponseSchema fields
  • Agent configuration scaffold template updated with commented format example
  • CLI validation refactored to use SupportsFormat method instead of hardcoded provider checks

Removed

  • TestRun_InvalidModelFormat standalone test (consolidated into TestRun_ProviderConfigValidation)
  • TestRun_UnsupportedProvider standalone test (consolidated into TestRun_ProviderConfigValidation)

Add a Format field to AgentConfig (TOML: format = "json" or a JSON
Schema table), provider.Request, and ollamaRequest. When set, Format
is passed as-is to Ollama's format parameter, enabling both JSON mode
and schema-constrained structured output.

Co-Authored-By: Regular <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added a Request-level structured output Format type and agent Format config, validated allowed shapes ("json" or JSON Schema), propagated formats into provider requests, added SupportsFormat support checks that abort run if provider doesn't support the requested format, and updated providers, tests, CLI output, and docs.

Changes

Cohort / File(s) Summary
Core types & config
internal/provider/provider.go, internal/agent/agent.go
Introduce FormatType/ResponseFormat and add Format *ResponseFormat to Request; add Format interface{} to AgentConfig with validation (only "json" or a JSON Schema map) and scaffold comment.
CLI run & display
cmd/run.go, cmd/agents.go, cmd/run_test.go, cmd/agents_test.go
Constructs provider.ResponseFormat from cfg.Format, aborts run with ExitError if selected provider doesn't SupportsFormat the requested format; agents show prints Format: when present; tests consolidated/updated for provider-config validation and display.
Provider interface & retry
internal/provider/retry.go, internal/provider/provider_test.go, internal/provider/retry_test.go
Add SupportsFormat(format *ResponseFormat) bool to Provider and implement delegation in RetryProvider; update test mocks to implement the method.
Ollama provider
internal/provider/ollama.go, internal/provider/ollama_test.go
Add format field to Ollama wire model; Send/SendStream map Request.Format into outgoing body.Format; test asserts outgoing format == "json". SupportsFormat returns true.
OpenAI & OpenCode (Zen route)
internal/provider/openai.go, internal/provider/opencode.go
Add wire structs for OpenAI JSON/JSON-Schema modes, populate response_format in requests for JSON/Schema modes, wire Schema into payloads, and add SupportsFormat (OpenAI/OpenCode return true). Many renames for Zen route request types and added response-format handling for Claude/Anthropic/GPT paths.
Anthropic & Claude handling
internal/provider/anthropic.go, internal/provider/opencode.go
Add tool-based schema enforcement: append forced print_output tool for FormatSchema, set ToolChoice, add JSON-only instruction for FormatJSON; parse tool blocks to extract marshaled tool Input when appropriate. SupportsFormat added (returns true).
Gemini provider
internal/provider/gemini.go
Add ResponseMimeType/ResponseSchema to generation config, populate them for JSON/Schema formats, ensure GenerationConfig included, and add SupportsFormat (returns true).
Bedrock provider
internal/provider/bedrock.go
Add SupportsFormat method that always returns false (explicitly not supporting structured formats).
OpenAI streaming & parsing
internal/provider/openai.go, internal/provider/opencode.go
Populate streaming and non-stream request formats for JSON/object and JSON Schema modes; add wire-format types for schema definition and JSON-object mode.
Large refactor & plumbing
internal/provider/* (many files)
Broad propagation of ResponseFormat handling across providers and stream/non-stream paths; added/updated marshal/unmarshal behavior and request structs to carry format information.
Docs
README.md
Agent example updated to include format = "json" in TOML example.
Tests
internal/agent/agent_test.go, internal/provider/provider_test.go, internal/provider/retry_test.go, internal/provider/ollama_test.go, cmd/run_test.go, cmd/agents_test.go
Added and updated tests for Scaffold/Validate of Format, provider SupportsFormat behavior, request-format propagation to providers, and consolidated run validation tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A little format flag danced into the frame,
JSON or Schema — we gave it a name.
Providers learned to nod or to balk,
Tests stood ready, the CLI took stock.
Ship the change — may responses stay tame 🛳️✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the PR—adding structured output support specifically for the Ollama provider—which aligns with the primary change across the implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/run.go (1)

395-402: ⚠️ Potential issue | 🟠 Major

Fail fast if format is set on a provider that doesn’t support it.

Line 401 forwards cfg.Format unconditionally. For non-Ollama providers this currently becomes a silent no-op, which is a sneaky UX footgun. Add an explicit guard and return a config error (exit code 2) with a fix message.

💡 Suggested fix
diff --git a/cmd/run.go b/cmd/run.go
@@
 	// Step 4-5: Parse model and validate provider
 	provName, modelName, err := parseModel(cfg.Model)
 	if err != nil {
 		return &ExitError{Code: 1, Err: err}
 	}
+	if cfg.Format != nil && provName != "ollama" {
+		return &ExitError{
+			Code: 2,
+			Err:  fmt.Errorf("format is only supported with provider %q; remove format or switch model provider", "ollama"),
+		}
+	}

As per coding guidelines: cmd/**/*.go: “Config validation errors must exit with code 2”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/run.go` around lines 395 - 402, When building the provider.Request you
currently forward cfg.Format unconditionally; instead, detect when cfg.Format is
set but the chosen provider implementation does not support formatting (i.e.,
not Ollama) and fail fast: check the active provider type/name (the code that
selects the provider) and if cfg.Format != "" and provider does not support
format, print a clear config error and exit with code 2. Update the validation
before creating provider.Request so the guard uses cfg.Format and the provider
identity/capability and returns a config error (exit code 2) with a suggested
fix message rather than silently ignoring the field.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/agent/agent.go`:
- Around line 94-97: The Format field is currently interface{} and isn't
validated early; update the agent config validation (the Validate method for the
agent config / AgentConfig.Validate) to check the Format value at load time:
allow either the string "json" or a JSON Schema table (e.g.,
map[string]interface{}), and return a clear validation error otherwise (message:
`format must be "json" or a JSON Schema table`). Implement a type switch on the
Format field (case string: verify == "json"; case map[string]interface{}:
accept; default: return error) so invalid types/typos fail fast with an
actionable error that callers (cmd/*.go) can exit with code 2. Ensure the error
originates from AgentConfig.Validate and includes that exact message.

In `@internal/provider/ollama.go`:
- Around line 236-238: The SendStream implementation is missing propagation of
the output format, causing structured output to be lost in streaming mode;
update the SendStream signature to accept the same format parameter (or access
req.Format) and set body.Format in SendStream the same way Send does (i.e., if
req.Format != nil { body.Format = req.Format }), then ensure the request uses
that body so streaming responses honor the format; locate SendStream and Send in
the ollama.go file and mirror how Send assigns body.Format to apply the fix.

---

Outside diff comments:
In `@cmd/run.go`:
- Around line 395-402: When building the provider.Request you currently forward
cfg.Format unconditionally; instead, detect when cfg.Format is set but the
chosen provider implementation does not support formatting (i.e., not Ollama)
and fail fast: check the active provider type/name (the code that selects the
provider) and if cfg.Format != "" and provider does not support format, print a
clear config error and exit with code 2. Update the validation before creating
provider.Request so the guard uses cfg.Format and the provider
identity/capability and returns a config error (exit code 2) with a suggested
fix message rather than silently ignoring the field.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7f67bd98-47d3-4004-9294-c76448556f2e

📥 Commits

Reviewing files that changed from the base of the PR and between 72df532 and c6edd76.

📒 Files selected for processing (4)
  • cmd/run.go
  • internal/agent/agent.go
  • internal/provider/ollama.go
  • internal/provider/provider.go

Comment thread internal/agent/agent.go
Comment thread internal/provider/ollama.go
gavmor added 3 commits April 10, 2026 16:50
This commit addresses the actionable comments from the recent PR review:

- Fail fast with exit code 2 if format is specified for a provider other than Ollama.

- Validate Format in AgentConfig to ensure it is either "json" or a valid JSON Schema table.

- Fix structured output support in Ollama.SendStream by propagating the Format field to the request body.

- Add comprehensive test coverage for all new validation rules and streaming behavior.
This commit applies code style refactorings based on recently accepted PRs:

- Refactored provider and format validation tests into a single table-driven TestRun_ProviderConfigValidation.

- Added conditional display of Format to 'agents show' command, properly omitting it when not set.

- Added Format to the agent TOML scaffold template and verified its presence via TestScaffold_IncludesFormat.
This commit updates the README.md, docs/src/configuration/agent-config.md, and docs/design/agent-config-schema.md to show how to configure the structured output format.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
cmd/agents_test.go (1)

171-171: Consider one table-driven case for the JSON Schema branch too.

You now cover format = "json" nicely, but the non-string display path (Format: JSON Schema) in cmd/agents.go is still untested. A tiny table-driven matrix (nil, "json", schema table) would close that loop.

As per coding guidelines: **/*_test.go: "Use table-driven tests with []struct{name, input, want} pattern".

Also applies to: 206-207

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/agents_test.go` at line 171, Add a table-driven test in
cmd/agents_test.go that covers the three display-format branches (nil/default,
"json" string, and a non-string JSON Schema table) so the `Format: JSON Schema`
path in cmd/agents.go is exercised; update the existing test cases to use the
[]struct{name, input, want} pattern and add a case where the format value is a
schema object (not a string) to verify the non-string rendering branch, along
with the existing `"json"` case and a nil/default case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@cmd/agents_test.go`:
- Line 171: Add a table-driven test in cmd/agents_test.go that covers the three
display-format branches (nil/default, "json" string, and a non-string JSON
Schema table) so the `Format: JSON Schema` path in cmd/agents.go is exercised;
update the existing test cases to use the []struct{name, input, want} pattern
and add a case where the format value is a schema object (not a string) to
verify the non-string rendering branch, along with the existing `"json"` case
and a nil/default case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fe44536d-eda5-4371-939f-665c98bc49f4

📥 Commits

Reviewing files that changed from the base of the PR and between 8c74c71 and 44d4596.

📒 Files selected for processing (5)
  • cmd/agents.go
  • cmd/agents_test.go
  • cmd/run_test.go
  • internal/agent/agent.go
  • internal/agent/agent_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/agent/agent_test.go
  • cmd/run_test.go

This commit refactors the structured output implementation to use an adapter model, enabling JSON mode and JSON Schema support across Ollama, OpenAI, Anthropic, and Gemini:

- Defined a unified ResponseFormat internal representation and added capability checking to the Provider interface.

- Implemented OpenAI adapter supporting JSON mode and Structured Outputs (json_schema).

- Implemented Anthropic adapter supporting JSON mode via prompt injection and JSON Schema via forced tool calls.

- Implemented Gemini adapter supporting JSON mode and response_schema.

- Refactored CLI validation to be provider-agnostic, using the new SupportsFormat method.

- Updated documentation and scaffolds to reflect multi-provider support.

- Added and updated tests to verify behavior across different provider implementations.
@gavmor
gavmor force-pushed the structured-output branch from 9b6a80e to 1dcd386 Compare April 11, 2026 00:21

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/provider/anthropic.go`:
- Around line 247-263: The synthetic anthropic tool "print_output" (added via
anthropicToolDef with Name "print_output" and forced via body.ToolChoice)
prevents real tool invocation and causes streamed events to be emitted as
StreamEventTool* rather than JSON text, breaking consumers expecting Send;
update the handler to either reject requests that combine
req.Format.Type==FormatSchema with any real tool usage or special-case
"print_output" in the streaming/response path: if using the synthetic
print_output, translate StreamEventTool* events back into the expected JSON/text
stream (or emit a StreamEventText) and ensure body.ToolChoice is not forced when
real tools are present, or else validate and return an error early. Reference:
req.Format, anthropicToolDef{Name:"print_output"}, body.ToolChoice,
StreamEventTool*, and the stream parser/Send path.

In `@internal/provider/gemini.go`:
- Around line 105-109: Rename the geminiGenerationConfig struct field
ResponseSchema to ResponseJSONSchema and change its JSON tag from
"response_schema,omitempty" to "response_json_schema,omitempty"; then update all
places that construct/assign this field (the two assignment sites that set the
schema on geminiGenerationConfig) to use ResponseJSONSchema so the Gemini
structured-output API receives the correct "response_json_schema" field; also
update any references/serialization that rely on the old name (e.g., any use in
SupportsFormat logic or marshaling) to the new ResponseJSONSchema identifier.

In `@internal/provider/opencode.go`:
- Around line 313-333: The current code appends a synthetic ocAnthropicToolDef
named "print_output" and then forces selection by setting body.ToolChoice =
&ocAnthropicToolChoice{Type: "tool", Name: "print_output"}, which prevents real
tools from being chosen and causes structured outputs to surface as tool-stream
events; instead stop forcing the tool choice: keep adding the ocAnthropicToolDef
("print_output") and add a clear system/instruction hint to prefer using that
tool for structured output but remove or avoid setting body.ToolChoice to
"print_output" so real tools remain selectable; make the same change in the
other similar block (the later block around the 461-481 region) so both schema
and JSON structured-output flows don't suppress real tools.
- Around line 230-265: The current helpers convertToOCAnthropicJSONSchemaProps
and convertToOCAnthropicRequiredProps strip almost all JSON Schema constraints;
instead preserve and forward the original property schemas and required list.
Modify ocJSONSchemaProp (or replace it) to hold the raw property representation
(e.g., map[string]interface{} or json.RawMessage) so
convertToOCAnthropicJSONSchemaProps returns the original property objects
unchanged (not just "type" and "description"); ensure
convertToOCAnthropicRequiredProps builds a []string containing only valid string
entries from schema["required"] (no nils) and return nil only when there is no
required field. Update any callers that expect the old lightweight
ocJSONSchemaProp shape to consume the preserved/raw schema representation.
- Around line 606-614: The request structs (e.g., ocGPTStreamRequest and
ocGPTRequest) are setting a top-level response_format field which the
/v1/responses endpoint ignores; change the payload to use the text.format
mechanism instead of ResponseFormat/response_format: remove or stop populating
the response_format field and instead embed the structured output spec under the
model input using the text.format envelope (follow OpenAI Responses API
text.format schema) so JSON mode and schema enforcement are honored; update any
place that marshals ResponseFormat into requests to use the text.format payload
format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e1bf79c0-ae01-46d7-9b60-06295073a7a6

📥 Commits

Reviewing files that changed from the base of the PR and between 20ac0c5 and 1dcd386.

⛔ Files ignored due to path filters (2)
  • docs/design/agent-config-schema.md is excluded by !docs/**
  • docs/src/configuration/agent-config.md is excluded by !docs/**
📒 Files selected for processing (15)
  • README.md
  • cmd/run.go
  • cmd/run_test.go
  • internal/agent/agent.go
  • internal/provider/anthropic.go
  • internal/provider/bedrock.go
  • internal/provider/gemini.go
  • internal/provider/ollama.go
  • internal/provider/ollama_test.go
  • internal/provider/openai.go
  • internal/provider/opencode.go
  • internal/provider/provider.go
  • internal/provider/provider_test.go
  • internal/provider/retry.go
  • internal/provider/retry_test.go
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/provider/ollama_test.go
  • internal/provider/ollama.go
  • internal/agent/agent.go
  • internal/provider/provider.go
  • cmd/run.go
  • cmd/run_test.go

Comment thread internal/provider/anthropic.go
Comment on lines 105 to +109
type geminiGenerationConfig struct {
Temperature *float64 `json:"temperature,omitempty"`
MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
ResponseMimeType string `json:"response_mime_type,omitempty"`
ResponseSchema map[string]interface{} `json:"response_schema,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the gemini.go file
find . -name "gemini.go" -type f | head -5

Repository: jrswab/axe

Length of output: 83


🏁 Script executed:

# Get file structure and check the mentioned lines
wc -l internal/provider/gemini.go

Repository: jrswab/axe

Length of output: 85


🏁 Script executed:

# Read the struct definition around lines 105-109
sed -n '100,115p' internal/provider/gemini.go

Repository: jrswab/axe

Length of output: 708


🏁 Script executed:

# Check the Send method around lines 285-293
sed -n '280,300p' internal/provider/gemini.go

Repository: jrswab/axe

Length of output: 516


🏁 Script executed:

# Check the SendStream method around lines 429-437
sed -n '425,445p' internal/provider/gemini.go

Repository: jrswab/axe

Length of output: 511


🌐 Web query:

Gemini API structured output response_json_schema field documentation

💡 Result:

The response_json_schema field in the Gemini API is used for structured outputs to constrain model responses to a specific JSON schema, ensuring predictable, type-safe JSON output. It is specified in the generationConfig (or config in SDKs) alongside "response_mime_type": "application/json". Key details: - Location: Within generationConfig object of the generateContent request. - Purpose: Defines a valid JSON Schema (full support added in Gemini 2.5+ models) that the model must adhere to. Supports libraries like Pydantic (Python) and Zod (JS). - Usage example (REST/Python): { "contents": [...], "generationConfig": { "responseMimeType": "application/json", "responseJsonSchema": { "type": "object", "properties": { "recipe_name": {"type": "string"}, "ingredients": {"type": "array", "items": {"type": "object"}} }, "required": ["recipe_name", "ingredients"] } } } - Supported types: string, number, integer, boolean, object, array, null (as union). - Additional features: Implicit property ordering (matches schema key order), keywords like anyOf, $ref, min/max constraints. - Model support: Gemini 2.5+ and 3 series (e.g., gemini-2.5-flash, gemini-3-flash-preview). Preview for some 3.x models. - Differences: Newer than response_schema (OpenAPI subset); response_json_schema enables full JSON Schema. Vertex AI has both fields. - Limitations: Subset of JSON Schema; complex schemas may error. Note: Older docs reference response_schema (OpenAPI-based). Use response_json_schema for latest full JSON Schema support in Gemini API.

Citations:


Rename ResponseSchema field to ResponseJSONSchema and use the correct Gemini API field name.

Gemini's structured-output API uses response_json_schema (not response_schema) for JSON Schema mode. The current code advertises schema support via SupportsFormat but serializes under the wrong field, so requests won't work as intended. Update the struct field, JSON tag, and both assignment locations to use the correct field name. (ai.google.dev)

🛠️ Minimal fix
 type geminiGenerationConfig struct {
	Temperature      *float64               `json:"temperature,omitempty"`
	MaxOutputTokens  *int                   `json:"maxOutputTokens,omitempty"`
	ResponseMimeType string                 `json:"response_mime_type,omitempty"`
-	ResponseSchema   map[string]interface{} `json:"response_schema,omitempty"`
+	ResponseJSONSchema map[string]interface{} `json:"response_json_schema,omitempty"`
 }
@@
-			genCfg.ResponseSchema = req.Format.Schema
+			genCfg.ResponseJSONSchema = req.Format.Schema
@@
-			genCfg.ResponseSchema = req.Format.Schema
+			genCfg.ResponseJSONSchema = req.Format.Schema

Also applies to: 285-293, 429-437

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/provider/gemini.go` around lines 105 - 109, Rename the
geminiGenerationConfig struct field ResponseSchema to ResponseJSONSchema and
change its JSON tag from "response_schema,omitempty" to
"response_json_schema,omitempty"; then update all places that construct/assign
this field (the two assignment sites that set the schema on
geminiGenerationConfig) to use ResponseJSONSchema so the Gemini
structured-output API receives the correct "response_json_schema" field; also
update any references/serialization that rely on the old name (e.g., any use in
SupportsFormat logic or marshaling) to the new ResponseJSONSchema identifier.

Comment on lines +230 to +265
func convertToOCAnthropicJSONSchemaProps(schema map[string]interface{}) map[string]ocJSONSchemaProp {
propsRaw, ok := schema["properties"].(map[string]interface{})
if !ok {
return nil
}
props := make(map[string]ocJSONSchemaProp, len(propsRaw))
for name, val := range propsRaw {
valMap, ok := val.(map[string]interface{})
if !ok {
continue
}
p := ocJSONSchemaProp{}
if t, ok := valMap["type"].(string); ok {
p.Type = t
}
if d, ok := valMap["description"].(string); ok {
p.Description = d
}
props[name] = p
}
return props
}

func convertToOCAnthropicRequiredProps(schema map[string]interface{}) []string {
reqRaw, ok := schema["required"].([]interface{})
if !ok {
return nil
}
req := make([]string, len(reqRaw))
for i, v := range reqRaw {
if s, ok := v.(string); ok {
req[i] = s
}
}
return req
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This helper strips most of the user’s schema.

Only top-level type, description, and a best-effort required list survive this conversion. Nested objects, enum, items, additionalProperties, numeric/string constraints, and other JSON Schema rules all disappear before the schema reaches Claude, so the output is much less constrained than the config asked for. Passing the schema through unchanged would be a lot less “telephone game.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/provider/opencode.go` around lines 230 - 265, The current helpers
convertToOCAnthropicJSONSchemaProps and convertToOCAnthropicRequiredProps strip
almost all JSON Schema constraints; instead preserve and forward the original
property schemas and required list. Modify ocJSONSchemaProp (or replace it) to
hold the raw property representation (e.g., map[string]interface{} or
json.RawMessage) so convertToOCAnthropicJSONSchemaProps returns the original
property objects unchanged (not just "type" and "description"); ensure
convertToOCAnthropicRequiredProps builds a []string containing only valid string
entries from schema["required"] (no nils) and return nil only when there is no
required field. Update any callers that expect the old lightweight
ocJSONSchemaProp shape to consume the preserved/raw schema representation.

Comment on lines +313 to +333
if req.Format != nil {
if req.Format.Type == FormatSchema {
// Add forced tool call for schema enforcement
body.Tools = append(body.Tools, ocAnthropicToolDef{
Name: "print_output",
Description: "Outputs the response in the requested structured format.",
InputSchema: ocAnthropicInputSchema{
Type: "object",
Properties: convertToOCAnthropicJSONSchemaProps(req.Format.Schema),
Required: convertToOCAnthropicRequiredProps(req.Format.Schema),
},
})
body.ToolChoice = &ocAnthropicToolChoice{Type: "tool", Name: "print_output"}
} else if req.Format.Type == FormatJSON {
// Prompt injection for JSON mode
if body.System != "" {
body.System += "\n\n"
}
body.System += "You must output your response in valid JSON. Do not include any markdown formatting, preamble, or conversational text. Output only the JSON."
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The Claude structured-output shim also suppresses real tools.

This has the same trap as the Anthropic provider: tool_choice = print_output prevents any real tool from being selected, and on the streaming path that synthetic tool will surface as StreamEventTool* instead of JSON text. Right now “structured output” comes with a surprise side quest.

Also applies to: 461-481

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/provider/opencode.go` around lines 313 - 333, The current code
appends a synthetic ocAnthropicToolDef named "print_output" and then forces
selection by setting body.ToolChoice = &ocAnthropicToolChoice{Type: "tool",
Name: "print_output"}, which prevents real tools from being chosen and causes
structured outputs to surface as tool-stream events; instead stop forcing the
tool choice: keep adding the ocAnthropicToolDef ("print_output") and add a clear
system/instruction hint to prefer using that tool for structured output but
remove or avoid setting body.ToolChoice to "print_output" so real tools remain
selectable; make the same change in the other similar block (the later block
around the 461-481 region) so both schema and JSON structured-output flows don't
suppress real tools.

Comment on lines +606 to 614
type ocGPTStreamRequest struct {
Model string `json:"model"`
Input []interface{} `json:"input"`
Temperature *float64 `json:"temperature,omitempty"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Tools []ocOpenAIToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
ResponseFormat *ocResponseFormat `json:"response_format,omitempty"`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and the specific lines mentioned
cd internal/provider && head -n 1 opencode.go && wc -l opencode.go

Repository: jrswab/axe

Length of output: 87


🏁 Script executed:

# Examine the code at the specified line ranges
sed -n '606,614p' internal/provider/opencode.go

Repository: jrswab/axe

Length of output: 507


🏁 Script executed:

# Check the other affected locations
echo "=== Lines 658-671 ===" && sed -n '658,671p' internal/provider/opencode.go
echo "=== Lines 987-994 ===" && sed -n '987,994p' internal/provider/opencode.go
echo "=== Lines 1128-1141 ===" && sed -n '1128,1141p' internal/provider/opencode.go

Repository: jrswab/axe

Length of output: 1274


🏁 Script executed:

# Search for all uses of ResponseFormat in the file to understand the full scope
rg 'ResponseFormat|response_format' internal/provider/opencode.go -n

Repository: jrswab/axe

Length of output: 919


🏁 Script executed:

# Let's look for where these structs are being used and understand the endpoint context
rg '/v1/responses' internal/provider/opencode.go -n -B 2 -A 2

Repository: jrswab/axe

Length of output: 609


🏁 Script executed:

# Get context on the ocResponseFormat struct definition
sed -n '118,130p' internal/provider/opencode.go

Repository: jrswab/axe

Length of output: 444


🌐 Web query:

OpenAI Responses API text.format structured outputs

💡 Result:

The OpenAI Responses API supports structured outputs using the text.format parameter to ensure model responses adhere to a specified JSON schema or produce valid JSON. ## Key Features - Structured Outputs: Guarantees adherence to a JSON schema. Enable with text: { format: { type: "json_schema", "strict": true, "schema": {...} } }. Supported on models like gpt-4o-2024-08-06, gpt-4o-mini, and later. - JSON Mode: Ensures valid JSON output (no schema adherence). Enable with text: { format: { type: "json_object" } }. Available on older models like gpt-3.5-turbo, gpt-4-*. ## Usage Structured Outputs via text.format is ideal for formatting the model's direct response to users (vs. function calling for tools). Example (Python SDK): response = client.responses.parse( model="gpt-4o-2024-08-06", input=[{"role": "user", "content": "Extract event info."}], text_format=YourSchema # Pydantic/Zod schema ) Direct API: text: { format: { type: "json_schema", strict: true, schema: { ... } } } Supports streaming, refusals (via refusal field), and a subset of JSON Schema. First request with a schema incurs extra latency for processing. Official docs: https://platform.openai.com/docs/guides/structured-outputs (Responses API mode).

Citations:


🌐 Web query:

OpenAI Responses API request body schema json

💡 Result:

The OpenAI Responses API uses the POST /v1/responses endpoint. The request body is a JSON object with the following key parameters (all optional unless noted, based on official API reference; exact schema is complex with nested unions for inputs/tools): - model: string (e.g., "gpt-4o", "o3") - input: string | array (text prompt or list of input messages/objects like {content, role, type}) - instructions: string | array (system/developer instructions) - text: object {format: {type: "text" | "json_schema" | "json_object", ...}, verbosity?: ...} for output config, including Structured Outputs with schema - tools: array of tool objects (functions, web_search, file_search, etc., with parameters JSON schema) - reasoning: object {effort: "low" | "medium" | "high"} - conversation: string | {id: string} - background: boolean - stream: boolean - temperature: number (0-2) - include: array of strings (e.g., "web_search_call.action.sources") - context_management: array of {type: "compaction", compact_threshold: number} - metadata: object (key-value pairs) Full detailed schema and types are documented at https://platform.openai.com/docs/api-reference/responses/create (select JSON view for complete structure). Examples: Simple curl: curl https://api.openai.com/v1/responses -H "Authorization: Bearer $OPENAI_API_KEY" -d '{"model": "gpt-4o", "input": "Hello"}' With structured output: "text": {"format": {"type": "json_schema", "name": "response", "schema": {...}}}

Citations:


Use text.format for the Responses API—response_format won't work here.

The code sends requests to /v1/responses, where OpenAI's API expects structured outputs via text.format, not a top-level response_format field. As written, the API will silently ignore all structured output configuration, breaking JSON mode and schema enforcement.

🛠️ Minimal fix sketch
+type ocTextConfig struct {
+	Format *ocResponseFormat `json:"format,omitempty"`
+}
+
 type ocGPTStreamRequest struct {
 	Model           string            `json:"model"`
 	Input           []interface{}     `json:"input"`
 	Temperature     *float64          `json:"temperature,omitempty"`
 	MaxOutputTokens *int              `json:"max_output_tokens,omitempty"`
 	Tools           []ocOpenAIToolDef `json:"tools,omitempty"`
 	Stream          bool              `json:"stream"`
-	ResponseFormat  *ocResponseFormat `json:"response_format,omitempty"`
+	Text            *ocTextConfig     `json:"text,omitempty"`
 }
@@
-			body.ResponseFormat = &ocResponseFormat{Type: "json_object"}
+			body.Text = &ocTextConfig{Format: &ocResponseFormat{Type: "json_object"}}
@@
-			body.ResponseFormat = &ocResponseFormat{
-				Type: "json_schema",
-				JSONSchema: &ocJSONSchemaDef{
-					Name:   "structured_output",
-					Strict: true,
-					Schema: req.Format.Schema,
-				},
-			}
+			body.Text = &ocTextConfig{
+				Format: &ocResponseFormat{
+					Type: "json_schema",
+					JSONSchema: &ocJSONSchemaDef{
+						Name:   "structured_output",
+						Strict: true,
+						Schema: req.Format.Schema,
+					},
+				},
+			}

Apply the same change to all affected request structs: ocGPTStreamRequest, ocGPTRequest, and any others using ResponseFormat with the /v1/responses endpoint.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type ocGPTStreamRequest struct {
Model string `json:"model"`
Input []interface{} `json:"input"`
Temperature *float64 `json:"temperature,omitempty"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Tools []ocOpenAIToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
ResponseFormat *ocResponseFormat `json:"response_format,omitempty"`
}
type ocTextConfig struct {
Format *ocResponseFormat `json:"format,omitempty"`
}
type ocGPTStreamRequest struct {
Model string `json:"model"`
Input []interface{} `json:"input"`
Temperature *float64 `json:"temperature,omitempty"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Tools []ocOpenAIToolDef `json:"tools,omitempty"`
Stream bool `json:"stream"`
Text *ocTextConfig `json:"text,omitempty"`
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/provider/opencode.go` around lines 606 - 614, The request structs
(e.g., ocGPTStreamRequest and ocGPTRequest) are setting a top-level
response_format field which the /v1/responses endpoint ignores; change the
payload to use the text.format mechanism instead of
ResponseFormat/response_format: remove or stop populating the response_format
field and instead embed the structured output spec under the model input using
the text.format envelope (follow OpenAI Responses API text.format schema) so
JSON mode and schema enforcement are honored; update any place that marshals
ResponseFormat into requests to use the text.format payload format.

@jrswab

jrswab commented Apr 13, 2026

Copy link
Copy Markdown
Owner

Hey @gavmor, thanks for this. Structured output from the LLM is a useful feature and I can see how it strengthens the pipeline use case.

The scope of this PR is wider than I'd like to merge as-is.

Would you be open to splitting this into a PR that adds only the format field to agent config and passes it through in a provider-agnostic way? The goal would be keeping the implementation simple enough that each provider doesn't need its own adapter logic for this.

Let me know if you'd like a hand. Thanks again for contributing to Axe! 🙏

@gavmor

gavmor commented Apr 13, 2026

Copy link
Copy Markdown
Author

Great idea, happy to pare it down.

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.

2 participants