Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/advanced/responses-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,21 @@ OpenAI-compatible error with:
}
```

A create request that carries no `input` (missing or `null`, and without a
`prompt` template to supply one) is answered by the gateway, with OpenAI's own
error, before any provider is called:

```json
{
"error": {
"type": "invalid_request_error",
"message": "Missing required parameter: 'input'.",
"param": "input",
"code": "missing_required_parameter"
}
}
```

GoModel uses OpenAI's `invalid_request_error` type for unsupported operations
so the public error type set stays closed. The `unsupported_response_operation`
code identifies the unsupported operation, returned with HTTP `501 Not Implemented`.
Expand Down
13 changes: 13 additions & 0 deletions docs/advanced/responses-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function tool loops. They cannot safely execute OpenAI-hosted tools.
| `previous_response_id` | Forwarded | Resolved by GoModel: the stored previous response's input items and output are prepended to the input. The previous response must be stored (`store` left on), or the request returns 404 (see [Stored responses](/advanced/responses-api#stored-responses)) |
| `conversation` | Resolved by GoModel from the gateway-managed conversation | Resolved by GoModel from the gateway-managed conversation |
| `include` annotations | Forwarded | Accepted and ignored, except `message.output_text.logprobs` |
| `truncation` | Forwarded | `"disabled"` (OpenAI's default) accepted as the no-op it is; `"auto"` rejected |
| Unknown Responses input item types | Preserved | Rejected |
| Responses websocket transport | Not implemented by GoModel | Not implemented by GoModel |

Expand All @@ -49,6 +50,18 @@ function tool loops. They cannot safely execute OpenAI-hosted tools.
rejects those fields instead of dropping them.
</Note>

## The response object

The response object carries the same members whether or not the request
streamed. A native Responses provider's object is relayed with every member it
returned, including ones GoModel does not model itself, so `metadata`,
`instructions`, `tools`, `tool_choice`, `temperature`, `text`, `reasoning` and
`truncation` can be read back from the create call and from
`GET /v1/responses/{id}`. A chat-translated provider has no response object
upstream, so GoModel echoes the members the caller supplied — and only those: a
default invented by the gateway would describe OpenAI's behavior rather than the
provider's.

## The `include` field

`include` asks for extra annotations on response items, such as
Expand Down
21 changes: 20 additions & 1 deletion internal/core/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ func (r *ResponsesRequest) CompactRequest() *ResponseCompactRequest {
return &compact
}

// ValidateInput rejects a Responses request that carries no input, with the
// error OpenAI returns for it. A missing or null input would otherwise reach
// the provider as an empty object and come back as a confusing upstream error,
// billed or not. A prompt template supplies its own input, so it is exempt.
func (r *ResponsesRequest) ValidateInput() error {
if r == nil || r.Input != nil || r.Prompt != nil {
return nil
}
return NewInvalidRequestError("Missing required parameter: 'input'.", nil).
WithParam("input").
WithCode("missing_required_parameter")
}

func (r *ResponsesRequest) semanticSelector() (string, string) {
if r == nil {
return "", ""
Expand Down Expand Up @@ -183,6 +196,11 @@ type ResponsesInputElement struct {
}

// ResponsesResponse represents the response from the Responses API.
// Unknown JSON members encountered during unmarshaling are preserved in
// ExtraFields (UnknownJSONFields) and marshaled back out unchanged, so the
// request-echo members OpenAI returns (instructions, metadata, tools,
// tool_choice, temperature, text, reasoning, …) survive the gateway instead of
// being stripped. Swagger ignores ExtraFields; typed fields take precedence.
type ResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"` // "response"
Expand All @@ -195,7 +213,8 @@ type ResponsesResponse struct {
Error *ResponsesError `json:"error,omitempty"`
// PreviousResponseID names the response this one was chained from, as
// OpenAI echoes it; stored snapshots follow it to rebuild the history.
PreviousResponseID string `json:"previous_response_id,omitempty"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
ExtraFields UnknownJSONFields `json:"-" swaggerignore:"true"`
}

// ResponsesOutputItem represents an item in the output array.
Expand Down
179 changes: 179 additions & 0 deletions internal/core/responses_fidelity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package core

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"testing"
)

// Every Response member the gateway does not model itself must survive a
// decode/encode round trip: OpenAI echoes the whole request back on the
// Response object and clients read those members back.
func TestResponsesResponseRoundTripsUnknownMembers(t *testing.T) {
tests := []struct {
name string
body string
want []string
}{
{
name: "request echo members",
body: `{"id":"resp_1","object":"response","status":"completed","model":"gpt-4o-mini",` +
`"output":[],"instructions":"be terse","metadata":{"k":"v"},"tool_choice":"auto",` +
`"parallel_tool_calls":false,"temperature":1,"top_p":1,"store":true,` +
`"text":{"format":{"type":"text"}},"reasoning":{"effort":null},"truncation":"disabled",` +
`"tools":[],"max_output_tokens":32}`,
want: []string{
`"instructions":"be terse"`, `"metadata":{"k":"v"}`, `"tool_choice":"auto"`,
`"parallel_tool_calls":false`, `"temperature":1`, `"top_p":1`, `"store":true`,
`"text":{"format":{"type":"text"}}`, `"reasoning":{"effort":null}`,
`"truncation":"disabled"`, `"tools":[]`, `"max_output_tokens":32`,
},
},
{
name: "members added after this release",
body: `{"id":"resp_2","object":"response","status":"completed","output":[],` +
`"billing":{"payer":"developer"},"x_big":9007199254740993}`,
want: []string{`"billing":{"payer":"developer"}`, `"x_big":9007199254740993`},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp ResponsesResponse
if err := json.Unmarshal([]byte(tt.body), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
encoded, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
for _, want := range tt.want {
if !bytes.Contains(encoded, []byte(want)) {
t.Fatalf("response = %s, want %s", encoded, want)
}
}
})
}
}

// incomplete_details is sent on every OpenAI Response object, as an explicit
// null on a completed one. It must survive the round trip exactly once,
// whether or not the struct types it.
func TestResponsesResponseKeepsIncompleteDetails(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "explicit null incomplete_details survives",
body: `{"id":"resp_1","object":"response","status":"completed","model":"gpt-4o-mini",` +
`"output":[],"incomplete_details":null}`,
want: `"incomplete_details":null`,
},
{
name: "populated incomplete_details is emitted once",
body: `{"id":"resp_2","object":"response","status":"incomplete","model":"gpt-4o-mini",` +
`"output":[],"incomplete_details":{"reason":"max_output_tokens"}}`,
want: `"incomplete_details":{"reason":"max_output_tokens"}`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp ResponsesResponse
if err := json.Unmarshal([]byte(tt.body), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
encoded, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !bytes.Contains(encoded, []byte(tt.want)) {
t.Fatalf("response = %s, want %s", encoded, tt.want)
}
if got := bytes.Count(encoded, []byte(`"incomplete_details"`)); got != 1 {
t.Fatalf("response = %s, want a single incomplete_details member, got %d", encoded, got)
}
})
}
}

// Typed members stay authoritative: a value set on the struct is emitted once,
// from the field, not from the passthrough object.
func TestResponsesResponseTypedMembersWinOverPassthrough(t *testing.T) {
var resp ResponsesResponse
body := `{"id":"resp_1","object":"response","status":"completed","model":"gpt-4o-mini","output":[],` +
`"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
resp.Status = "incomplete"

encoded, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if bytes.Count(encoded, []byte(`"status"`)) != 1 {
t.Fatalf("response = %s, want a single status member", encoded)
}
if !bytes.Contains(encoded, []byte(`"status":"incomplete"`)) {
t.Fatalf("response = %s, want the typed status", encoded)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 3 {
t.Fatalf("usage = %+v, want the typed usage", resp.Usage)
}
}

// A request with nothing to send is answered locally, in OpenAI's shape,
// instead of reaching a provider as an empty object.
func TestResponsesRequestValidateInput(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{name: "missing input", body: `{"model":"openai/gpt-4o-mini"}`, wantErr: true},
{name: "null input", body: `{"model":"openai/gpt-4o-mini","input":null}`, wantErr: true},
{name: "string input", body: `{"model":"openai/gpt-4o-mini","input":"hi"}`},
{name: "empty string input", body: `{"model":"openai/gpt-4o-mini","input":""}`},
{name: "array input", body: `{"model":"openai/gpt-4o-mini","input":[]}`},
{name: "prompt template supplies the input", body: `{"model":"openai/gpt-4o-mini","prompt":{"id":"pmpt_1"}}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req ResponsesRequest
if err := json.Unmarshal([]byte(tt.body), &req); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}

err := req.ValidateInput()
if !tt.wantErr {
if err != nil {
t.Fatalf("ValidateInput() = %v, want nil", err)
}
return
}

var gatewayErr *GatewayError
if !errors.As(err, &gatewayErr) {
t.Fatalf("ValidateInput() = %v, want a gateway error", err)
}
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", gatewayErr.HTTPStatusCode())
}
if gatewayErr.Message != "Missing required parameter: 'input'." {
t.Fatalf("message = %q", gatewayErr.Message)
}
if gatewayErr.Param == nil || *gatewayErr.Param != "input" {
t.Fatalf("param = %v, want input", gatewayErr.Param)
}
if gatewayErr.Code == nil || *gatewayErr.Code != "missing_required_parameter" {
t.Fatalf("code = %v, want missing_required_parameter", gatewayErr.Code)
}
})
}
}
51 changes: 51 additions & 0 deletions internal/core/responses_json.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/goccy/go-json"
"github.com/tidwall/gjson"
)

// Known-field lists are derived from the struct definitions (json tags) at
Expand All @@ -15,6 +16,7 @@ var (
responsesRequestFields = jsonFieldSetOf(ResponsesRequest{})
responsesUtilityRequestFields = jsonFieldSetOf(ResponseInputTokensRequest{})
responsesOutputItemFields = jsonFieldSetOf(ResponsesOutputItem{})
responsesResponseFields = jsonFieldSetOf(ResponsesResponse{})
)

// responsesExtrasAndInput finishes a responses-shaped decode: it captures
Expand Down Expand Up @@ -319,6 +321,55 @@ func (e ResponsesInputElement) MarshalJSON() ([]byte, error) {
}
}

// UnmarshalJSON preserves every Response member the gateway does not model
// itself. OpenAI echoes the whole request back on the Response object
// (instructions, metadata, tools, tool_choice, temperature, text, reasoning,
// truncation, …) and clients read those members back, so they must survive a
// decode/encode round trip through the gateway.
func (r *ResponsesResponse) UnmarshalJSON(data []byte) error {
type alias ResponsesResponse
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
keepIncompleteDetails := hasExplicitNullMember(data, responsesIncompleteDetailsMember)
extraFields, err := extractUnknownJSONFieldsWith(data, func(key string) bool {
if keepIncompleteDetails && key == responsesIncompleteDetailsMember {
return false
}
_, known := responsesResponseFields[key]
return known
})
if err != nil {
return err
}
*r = ResponsesResponse(decoded)
r.ExtraFields = extraFields
return nil
}

// responsesIncompleteDetailsMember is the one Response member OpenAI always
// sends and always sets to null on a completed response. Once the struct types
// it, an `omitempty` field decodes that null to a zero value and then drops it
// on the way out, so the null has to be retained as an unknown extra to survive
// the round trip. A populated value is emitted by the typed member itself and
// must not be duplicated here.
const responsesIncompleteDetailsMember = "incomplete_details"

// hasExplicitNullMember reports whether the object carries member set to an
// explicit JSON null, as opposed to omitting it.
func hasExplicitNullMember(data []byte, member string) bool {
value := gjson.GetBytes(data, member)
return value.Exists() && value.Type == gjson.Null
}

// MarshalJSON emits the typed Response members together with every unknown
// member retained during decoding or echoed from the request.
func (r ResponsesResponse) MarshalJSON() ([]byte, error) {
type alias ResponsesResponse
return marshalWithUnknownJSONFields(alias(r), r.ExtraFields)
}

// UnmarshalJSON preserves variant-specific Responses output item fields. This
// is required for lossless Responses passthrough and for replaying reasoning
// and hosted-tool items from a gateway-managed conversation.
Expand Down
Loading