Skip to content

fix(openai): always emit required fields on Responses API input items#115

Merged
clintecker merged 2 commits intomainfrom
fix/openrouter-responses-api-114
Apr 20, 2026
Merged

fix(openai): always emit required fields on Responses API input items#115
clintecker merged 2 commits intomainfrom
fix/openrouter-responses-api-114

Conversation

@clintecker
Copy link
Copy Markdown
Collaborator

@clintecker clintecker commented Apr 17, 2026

Closes #114.

Diagnosis

The OpenAI Responses API input array is a discriminated union keyed by each item's type field:

  • function_call_output requires output: string | array
  • function_call requires name: string and arguments: string
  • role-based messages require role and content

Tracker modeled this as a single Go struct with omitempty on every field. The serializer dropped required fields whenever the value happened to be an empty string — e.g. a tool that returned `""` produced:

```json
{"type": "function_call_output", "call_id": "call_xyz"}
```

OpenAI's endpoint accepted the loose shape, so the bug was latent. OpenRouter validates with a strict Zod schema matching the published spec and rejected the requests with `invalid_prompt` / `invalid_union` errors. Symptoms matched the report: random-looking failures only on OpenRouter-proxied models (GLM, Qwen, Kimi), deterministic per-model on resume (the bad item replays from conversation history), different failure location when the model changes.

Fix

Replace `omitempty`-on-every-field with a `MarshalJSON` method that emits only the fields valid per discriminator, with required fields always present (empty strings kept, undefined never produced). Keeps the single builder struct so translation code is unchanged; retains unmarshal tags for round-trip test reads.

Test plan

  • Added `TestTranslateRequest_EmptyToolResult_KeepsOutputField` — fails on main, passes after fix
  • Added `TestTranslateRequest_EmptyArguments_KeepsArgumentsField` — fails on main, passes after fix
  • `go test ./...` (all 17 packages) — pass
  • `go test -race ./llm/openai` — pass
  • `go vet ./...` clean
  • `dippin doctor` on three core pipelines → A / 100
  • Existing `TestTranslateRequestMultiTurnToolCall` still asserts the "function_call items use call_id, not id" contract — still passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Consistently include required fields for tool call and result items (even when empty), avoiding external validation errors.
    • Marshaling now fails for unrecognized item types instead of producing partial/ambiguous payloads.
  • Tests

    • Added tests verifying required fields are always present and unknown types produce an error.

Closes #114.

The OpenAI Responses API input array is a discriminated union keyed by
the item's `type` field. Tracker modeled it as a single struct with
`omitempty` on every field, which stripped required fields whenever the
value happened to be an empty string:

  - function_call_output with an empty tool result dropped `output`
    (required: string | array)
  - function_call with empty arguments dropped `arguments` (required:
    string) and `name` (required: string)

OpenAI's own endpoint tolerated these, so the bug lay dormant. OpenRouter
validates with a strict Zod schema matching the documented spec and
rejected the requests with `invalid_prompt` / `invalid_union` errors.
Symptoms: random-looking failures on GLM, Qwen, Kimi (models OpenRouter
routes through its /v1/responses proxy), consistent per-model on resume
because the bad item is replayed from conversation history.

The fix replaces omitempty-on-everything with a MarshalJSON method that
emits only the fields valid for each discriminator, with required fields
always present. Unmarshal tags retained for round-trip test reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8273168e-7aef-4d5b-9920-7ab3a130f54c

📥 Commits

Reviewing files that changed from the base of the PR and between 40f13d4 and 35d35e9.

📒 Files selected for processing (2)
  • llm/openai/translate.go
  • llm/openai/translate_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • llm/openai/translate_test.go

Walkthrough

Replaces omitempty-based JSON behavior for OpenAI Responses API messages with a custom MarshalJSON for openaiInput, emitting type-specific required fields (function_call, function_call_output, or role message) and returning an error for unknown types. Adds changelog entry and tests validating serialization.

Changes

Cohort / File(s) Summary
CHANGELOG entry
CHANGELOG.md
Adds [Unreleased] Fixed note describing consistent serialization for Responses API function_call / function_call_output items and references issue #114.
Custom JSON marshaler
llm/openai/translate.go
Removed omitempty from openaiInput fields and added func (i openaiInput) MarshalJSON() ([]byte, error) implementing discriminated-union output: emits {role,content} for role messages, {type,call_id,name,arguments} for function_call, {type,call_id,output} for function_call_output, and errors on unknown type.
Serialization tests
llm/openai/translate_test.go
Adds tests: TestTranslateRequest_EmptyToolResult_KeepsOutputField, TestTranslateRequest_EmptyArguments_KeepsArgumentsField, and TestOpenaiInput_MarshalJSON_UnknownTypeIsError to assert required fields are present and unknown types produce an error.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I hopped through fields both shy and sly,
Replaced the holes where values hide,
Now every call and output shows,
No missing strings where validators go,
I nibble bugs and bound outside.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(openai): always emit required fields on Responses API input items' directly describes the main change—a bug fix ensuring required fields are consistently serialized in OpenAI Responses API inputs.
Linked Issues check ✅ Passed The PR addresses core coding objectives from #114: implements input validation/normalization by replacing omitempty with custom MarshalJSON to ensure required fields are always present, adds unit tests validating schema correctness, and fixes serialization mismatches in discriminated union items.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the serialization bug: CHANGELOG entry documents the fix, translate.go implements custom MarshalJSON logic, and translate_test.go adds focused tests verifying required field presence—no unrelated refactoring or scope creep.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/openrouter-responses-api-114

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
llm/openai/translate.go (1)

89-95: Fail fast on unsupported openaiInput.Type values

The default branch currently treats any unknown non-empty Type as a role-message item, which can silently produce malformed union payloads. Return an explicit error when Type is non-empty but not recognized.

Proposed patch
 import (
 	"encoding/json"
+	"fmt"
 	"strings"
@@
 	default:
-		// Role-based message (user / assistant / system / developer).
+		// Role-based message (user / assistant / system / developer).
+		if i.Type != "" {
+			return nil, fmt.Errorf("openai: unsupported input type %q", i.Type)
+		}
 		return json.Marshal(struct {
 			Role    string `json:"role"`
 			Content string `json:"content"`
 		}{i.Role, i.Content})
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@llm/openai/translate.go` around lines 89 - 95, The default branch in the
switch that marshals openai input items currently assumes unknown types are
role-based messages and silently produces bad payloads; update that default case
to fail fast: if i.Type is non-empty return an explicit error (e.g.
fmt.Errorf("unsupported openai input type: %q", i.Type)) instead of
json.Marshal, ensuring the enclosing function (the marshal/convert function in
llm/openai/translate.go that handles i.Type, Role, Content) returns an error up
the call stack; keep the existing behavior only for empty i.Type (preserve the
role-based json.Marshal for the empty-type case).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@llm/openai/translate_test.go`:
- Around line 319-321: The test currently only checks for presence of
"arguments" in the fc map but must assert it's a string (empty string is
allowed) not e.g. null; replace the presence check on fc["arguments"] with a
type assertion like v, ok := fc["arguments"].(string) and call t.Error (or
t.Fatalf) if !ok, optionally keeping an additional assertion for expected empty
string value when required; update the assertion around the fc variable in
translate_test.go to ensure the "arguments" value is a string.

---

Nitpick comments:
In `@llm/openai/translate.go`:
- Around line 89-95: The default branch in the switch that marshals openai input
items currently assumes unknown types are role-based messages and silently
produces bad payloads; update that default case to fail fast: if i.Type is
non-empty return an explicit error (e.g. fmt.Errorf("unsupported openai input
type: %q", i.Type)) instead of json.Marshal, ensuring the enclosing function
(the marshal/convert function in llm/openai/translate.go that handles i.Type,
Role, Content) returns an error up the call stack; keep the existing behavior
only for empty i.Type (preserve the role-based json.Marshal for the empty-type
case).
🪄 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: 7c92a6ac-9966-44c3-81fc-d3c0a7510f20

📥 Commits

Reviewing files that changed from the base of the PR and between 9e5f1b7 and 40f13d4.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • llm/openai/translate.go
  • llm/openai/translate_test.go

Comment thread llm/openai/translate_test.go Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes strict OpenAI Responses API request validation failures (notably via OpenRouter) by ensuring discriminated-union input items always serialize the required fields even when values are empty strings, preventing invalid_prompt / invalid_union errors when replaying conversation history.

Changes:

  • Implemented openaiInput.MarshalJSON to emit only fields valid for each type discriminator and to always include required fields (no omitempty stripping).
  • Added regression tests covering empty tool outputs (output: "") and empty tool-call arguments (arguments: "").
  • Documented the fix and its impact in CHANGELOG.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
llm/openai/translate.go Adds custom JSON marshaling for openaiInput to ensure required discriminator fields are always emitted.
llm/openai/translate_test.go Adds regression tests ensuring empty-string required fields are preserved in serialized request JSON.
CHANGELOG.md Records the bug, symptoms (OpenRouter strict validation), and the serialization fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread llm/openai/translate.go
Comment on lines +75 to +95
switch i.Type {
case "function_call":
return json.Marshal(struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}{i.Type, i.CallID, i.Name, i.Arguments})
case "function_call_output":
return json.Marshal(struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Output string `json:"output"`
}{i.Type, i.CallID, i.Output})
default:
// Role-based message (user / assistant / system / developer).
return json.Marshal(struct {
Role string `json:"role"`
Content string `json:"content"`
}{i.Role, i.Content})
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

MarshalJSON treats any unknown Type the same as a role-based message (it falls into default and drops the type field). Given the comment that an empty Type means role-message, it would be safer to handle case "" for role-messages and return an error for any other unexpected discriminator to avoid silently emitting an invalid item shape if Type is ever set incorrectly.

Copilot uses AI. Check for mistakes.
Comment thread llm/openai/translate.go Outdated
Comment on lines +82 to +94
}{i.Type, i.CallID, i.Name, i.Arguments})
case "function_call_output":
return json.Marshal(struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Output string `json:"output"`
}{i.Type, i.CallID, i.Output})
default:
// Role-based message (user / assistant / system / developer).
return json.Marshal(struct {
Role string `json:"role"`
Content string `json:"content"`
}{i.Role, i.Content})
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

In MarshalJSON, the anonymous struct literals are initialized using positional fields (e.g., }{i.Type, i.CallID, ...}). This is brittle if the struct definition changes and makes reviews harder; prefer keyed fields (Type: i.Type, etc.) to prevent accidental field-order bugs.

Suggested change
}{i.Type, i.CallID, i.Name, i.Arguments})
case "function_call_output":
return json.Marshal(struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Output string `json:"output"`
}{i.Type, i.CallID, i.Output})
default:
// Role-based message (user / assistant / system / developer).
return json.Marshal(struct {
Role string `json:"role"`
Content string `json:"content"`
}{i.Role, i.Content})
}{
Type: i.Type,
CallID: i.CallID,
Name: i.Name,
Arguments: i.Arguments,
})
case "function_call_output":
return json.Marshal(struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Output string `json:"output"`
}{
Type: i.Type,
CallID: i.CallID,
Output: i.Output,
})
default:
// Role-based message (user / assistant / system / developer).
return json.Marshal(struct {
Role string `json:"role"`
Content string `json:"content"`
}{
Role: i.Role,
Content: i.Content,
})

Copilot uses AI. Check for mistakes.
Comment thread llm/openai/translate_test.go Outdated
Comment on lines +266 to +268
input := raw["input"].([]any)
fco := input[1].(map[string]any)
if fco["type"] != "function_call_output" {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

This test uses unchecked type assertions (raw["input"].([]any), input[1].(map[string]any)) which will panic on failure and make the test output less actionable. Prefer asserting the type with an ok check (and also validating len(input) before indexing) so failures report via t.Fatalf instead of a panic.

Copilot uses AI. Check for mistakes.
Comment thread llm/openai/translate_test.go Outdated
Comment on lines +311 to +312
input := raw["input"].([]any)
fc := input[0].(map[string]any)
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

Same as above: this test uses unchecked type assertions (raw["input"].([]any), input[0].(map[string]any)) which can panic. Add ok checks and a length assertion so test failures are reported cleanly.

Suggested change
input := raw["input"].([]any)
fc := input[0].(map[string]any)
input, ok := raw["input"].([]any)
if !ok {
t.Fatalf("expected input to be []any, got %T", raw["input"])
}
if len(input) == 0 {
t.Fatal("expected input to contain at least one item")
}
fc, ok := input[0].(map[string]any)
if !ok {
t.Fatalf("expected first input item to be map[string]any, got %T", input[0])
}

Copilot uses AI. Check for mistakes.
Comment on lines +249 to +251
req := &llm.Request{
Model: "openai/gpt-4.1",
Messages: []llm.Message{
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

The new tests set req.Model to "openai/gpt-4.1", while other tests in this file use bare model IDs (e.g. "gpt-4.1", "gpt-5.4"). Consider keeping the model string consistent within these unit tests unless the prefix is required for this specific code path, to avoid confusion about what translateRequest expects.

Copilot uses AI. Check for mistakes.
Comment on lines +286 to +288
req := &llm.Request{
Model: "openai/gpt-4.1",
Messages: []llm.Message{
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

Same consistency note here: Model: "openai/gpt-4.1" differs from the bare model IDs used in the other translate_test.go cases. Aligning the model string format across tests will make intent clearer.

Copilot uses AI. Check for mistakes.
- MarshalJSON: fail fast on unknown Type rather than silent role-message
  fallback. Adding a new item shape should be a test failure, not a
  wire-format bug. (Copilot + CodeRabbit)
- MarshalJSON: use keyed anonymous-struct field literals. (Copilot)
- Tests: type-check map assertions with ok + length checks so failures
  report via t.Fatalf instead of panicking. (Copilot)
- Tests: assert arguments is a string (empty "" OK, null/undefined not).
  (CodeRabbit)
- Tests: use bare model IDs ("gpt-4.1") consistent with sibling tests,
  not the OpenRouter-prefixed form. (Copilot)
- Tests: add regression for unknown-Type error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@clintecker clintecker merged commit ea2eeb9 into main Apr 20, 2026
2 checks passed
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.

Tracker randomly fails on OpenRouter calls

2 participants