diff --git a/docs/advanced/responses-api.mdx b/docs/advanced/responses-api.mdx index 63cd8fc2e..3d55aee69 100644 --- a/docs/advanced/responses-api.mdx +++ b/docs/advanced/responses-api.mdx @@ -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`. diff --git a/docs/advanced/responses-compatibility.mdx b/docs/advanced/responses-compatibility.mdx index 5a0db1eeb..11c9bf085 100644 --- a/docs/advanced/responses-compatibility.mdx +++ b/docs/advanced/responses-compatibility.mdx @@ -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 | @@ -49,6 +50,18 @@ function tool loops. They cannot safely execute OpenAI-hosted tools. rejects those fields instead of dropping them. +## 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 diff --git a/internal/core/responses.go b/internal/core/responses.go index 413b08741..b60fa8617 100644 --- a/internal/core/responses.go +++ b/internal/core/responses.go @@ -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 "", "" @@ -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" @@ -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. diff --git a/internal/core/responses_fidelity_test.go b/internal/core/responses_fidelity_test.go new file mode 100644 index 000000000..58bc13107 --- /dev/null +++ b/internal/core/responses_fidelity_test.go @@ -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) + } + }) + } +} diff --git a/internal/core/responses_json.go b/internal/core/responses_json.go index 321f93ab3..8328c47e9 100644 --- a/internal/core/responses_json.go +++ b/internal/core/responses_json.go @@ -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 @@ -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 @@ -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. diff --git a/internal/providers/anthropic/responses.go b/internal/providers/anthropic/responses.go index 9757214f4..2028c2339 100644 --- a/internal/providers/anthropic/responses.go +++ b/internal/providers/anthropic/responses.go @@ -99,7 +99,9 @@ func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (* return nil, err } - return convertAnthropicResponseToResponses(&anthropicResp, req.Model), nil + resp := convertAnthropicResponseToResponses(&anthropicResp, req.Model) + providers.ApplyResponsesRequestEcho(resp, req) + return resp, nil } // StreamResponses returns a raw response body for streaming Responses API (caller must close) @@ -122,7 +124,7 @@ func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesReque } // Return a reader that converts Anthropic SSE format to Responses API format - return newResponsesStreamConverter(stream, req.Model), nil + return newResponsesStreamConverter(stream, req.Model).withRequestEcho(req), nil } // responsesStreamConverter wraps an Anthropic stream and converts it to Responses API format @@ -146,6 +148,28 @@ type responsesStreamConverter struct { pendingErr error // upstream read error deferred until terminal events are drained usage anthropicUsage hasUsage bool + // requestEcho carries the request members OpenAI repeats on the response + // object; an Anthropic stream cannot carry them, so they are echoed onto + // every lifecycle event, matching the non-streamed answer. + requestEcho map[string]json.RawMessage +} + +// withRequestEcho records the request members to repeat on every lifecycle +// event of this stream. +func (sc *responsesStreamConverter) withRequestEcho(req *core.ResponsesRequest) *responsesStreamConverter { + sc.requestEcho = providers.ResponsesRequestEcho(req) + return sc +} + +// echoed adds the request members to one lifecycle response object, never +// overwriting a member the converter itself produced. +func (sc *responsesStreamConverter) echoed(response map[string]any) map[string]any { + for name, value := range sc.requestEcho { + if _, exists := response[name]; !exists { + response[name] = value + } + } + return response } func newResponsesStreamConverter(body io.ReadCloser, model string) *responsesStreamConverter { @@ -305,7 +329,7 @@ func (sc *responsesStreamConverter) appendTerminalEvents() { responseData["usage"] = anthropicResponsesUsagePayload(&sc.usage) } sc.buffer.AppendString(prefix) - sc.buffer.AppendString(sc.output.FinishResponse(eventName, responseData)) + sc.buffer.AppendString(sc.output.FinishResponse(eventName, sc.echoed(responseData))) } // startResponse opens the stream with response.created and @@ -315,14 +339,14 @@ func (sc *responsesStreamConverter) startResponse() string { return "" } sc.sentCreate = true - return sc.output.StartResponse(map[string]any{ + return sc.output.StartResponse(sc.echoed(map[string]any{ "id": sc.responseID, "object": "response", "status": "in_progress", "model": sc.model, "provider": "anthropic", "created_at": sc.createdAt, - }) + })) } // completePendingToolCalls emits the done events for tool calls the upstream diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go index 1dc716b54..c5f152faf 100644 --- a/internal/providers/responses_adapter.go +++ b/internal/providers/responses_adapter.go @@ -100,7 +100,10 @@ func validateResponsesRequestForChatTranslation(req *core.ResponsesRequest) erro if req.Prompt != nil { return unsupportedResponsesChatTranslationField("prompt") } - if strings.TrimSpace(req.Truncation) != "" { + // "disabled" is OpenAI's default and asks for nothing, so it is honored by + // doing nothing. Only "auto", which asks the provider to drop input that + // does not fit, cannot be translated. + if truncation := strings.TrimSpace(req.Truncation); truncation != "" && truncation != "disabled" { return unsupportedResponsesChatTranslationField("truncation") } if strings.TrimSpace(req.PromptCacheRetention) != "" { @@ -225,7 +228,7 @@ func unsupportedResponsesChatTranslationField(field string) error { return core.NewInvalidRequestError( fmt.Sprintf("responses field %q is only supported by native Responses providers; use an OpenAI-compatible provider or passthrough for this request", field), nil, - ) + ).WithParam(field) } func cloneStreamOptions(src *core.StreamOptions) *core.StreamOptions { @@ -378,7 +381,9 @@ func ResponsesViaChat(ctx context.Context, p ChatProvider, req *core.ResponsesRe return nil, core.NewNoChoicesProviderError(providerName) } - return ConvertChatResponseToResponses(chatResp), nil + resp := ConvertChatResponseToResponses(chatResp) + ApplyResponsesRequestEcho(resp, req) + return resp, nil } // StreamResponsesViaChat implements streaming Responses API by converting to/from Chat format. @@ -399,5 +404,5 @@ func StreamResponsesViaChat(ctx context.Context, p ChatProvider, req *core.Respo return nil, err } - return NewOpenAIResponsesStreamConverter(stream, req.Model, providerName), nil + return NewOpenAIResponsesStreamConverter(stream, req.Model, providerName).WithRequestEcho(req), nil } diff --git a/internal/providers/responses_converter.go b/internal/providers/responses_converter.go index e9fe90d95..af754d645 100644 --- a/internal/providers/responses_converter.go +++ b/internal/providers/responses_converter.go @@ -38,6 +38,10 @@ type OpenAIResponsesStreamConverter struct { sawFinish bool // upstream signalled completion (finish_reason or [DONE]) pendingErr error // upstream read error deferred until terminal events are drained cachedUsage json.RawMessage // Stores usage from final chunk for inclusion in response.completed + // requestEcho carries the request members OpenAI repeats on the response + // object. A chat stream cannot carry them, so the converter echoes them + // onto every lifecycle event. + requestEcho map[string]json.RawMessage } // NewOpenAIResponsesStreamConverter creates a new converter that transforms @@ -58,6 +62,25 @@ func NewOpenAIResponsesStreamConverter(reader io.ReadCloser, model, provider str } } +// WithRequestEcho makes the stream repeat the request members OpenAI echoes on +// the response object (instructions, metadata, tools, temperature, …), so a +// streamed answer and a non-streamed one carry the same fields. +func (sc *OpenAIResponsesStreamConverter) WithRequestEcho(req *core.ResponsesRequest) *OpenAIResponsesStreamConverter { + sc.requestEcho = ResponsesRequestEcho(req) + return sc +} + +// withRequestEcho adds the echoed request members to one lifecycle response +// object, never overwriting a member the converter itself produced. +func (sc *OpenAIResponsesStreamConverter) withRequestEcho(response map[string]any) map[string]any { + for name, value := range sc.requestEcho { + if _, exists := response[name]; !exists { + response[name] = value + } + } + return response +} + // openAIStreamChunk is the subset of an OpenAI chat.completion.chunk the // converter consumes. Typed decoding avoids a map[string]any per chunk. type openAIStreamChunk struct { @@ -468,7 +491,7 @@ func (sc *OpenAIResponsesStreamConverter) appendTerminalEvents() { responseData["usage"] = usage } } - sc.buffer.AppendString(sc.output.FinishResponse(eventName, responseData)) + sc.buffer.AppendString(sc.output.FinishResponse(eventName, sc.withRequestEcho(responseData))) } func (sc *OpenAIResponsesStreamConverter) appendFailedEvents(raw json.RawMessage) { @@ -510,7 +533,7 @@ func (sc *OpenAIResponsesStreamConverter) appendFailedEvents(raw json.RawMessage "message": upstream.Message, }, } - sc.buffer.AppendString(sc.output.FinishResponse("response.failed", responseData)) + sc.buffer.AppendString(sc.output.FinishResponse("response.failed", sc.withRequestEcho(responseData))) } // chatUsageToResponsesUsage renames a valid Chat Completions usage object into @@ -568,14 +591,14 @@ func (sc *OpenAIResponsesStreamConverter) Read(p []byte) (n int, err error) { // Open the stream with response.created and response.in_progress first if !sc.sentCreate { sc.sentCreate = true - sc.buffer.AppendString(sc.output.StartResponse(map[string]any{ + sc.buffer.AppendString(sc.output.StartResponse(sc.withRequestEcho(map[string]any{ "id": sc.responseID, "object": "response", "status": "in_progress", "model": sc.model, "provider": sc.provider, "created_at": sc.createdAt, - })) + }))) return sc.buffer.Read(p), nil } diff --git a/internal/providers/responses_echo.go b/internal/providers/responses_echo.go new file mode 100644 index 000000000..b050231e3 --- /dev/null +++ b/internal/providers/responses_echo.go @@ -0,0 +1,122 @@ +package providers + +import ( + "strings" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +// ResponsesRequestEcho returns the request members OpenAI repeats on the +// Response object it returns. Providers reached through chat translation have +// no Response object upstream, so the gateway echoes them itself and both the +// streamed and the non-streamed answer carry the same members. +// +// Only what the caller actually supplied is echoed: a default invented here +// (parallel_tool_calls, store, truncation…) would describe OpenAI's behaviour, +// not the translated provider's. Members the gateway does not model are +// carried on the request's ExtraFields and echoed through the allowlist below. +// responsesEchoExtraMembers are Response members OpenAI repeats that the +// gateway does not model on the request, so a caller-supplied value reaches +// the echo only through ExtraFields. The list is an allowlist on purpose: +// routing hints and transport members (provider, stream, …) also land in +// ExtraFields or on the struct, and OpenAI does not put them on the Response +// object. +var responsesEchoExtraMembers = []string{ + "background", + "frequency_penalty", + "max_tool_calls", + "presence_penalty", + "prompt_cache_key", +} + +func ResponsesRequestEcho(req *core.ResponsesRequest) map[string]json.RawMessage { + if req == nil { + return nil + } + + echo := make(map[string]json.RawMessage, 12) + add := func(name string, value any) { + raw, err := json.Marshal(value) + if err != nil { + return + } + echo[name] = raw + } + + if req.Instructions != "" { + add("instructions", req.Instructions) + } + if req.Metadata != nil { + add("metadata", req.Metadata) + } + if req.Tools != nil { + add("tools", req.Tools) + } + if req.ToolChoice != nil { + add("tool_choice", req.ToolChoice) + } + if req.ParallelToolCalls != nil { + add("parallel_tool_calls", req.ParallelToolCalls) + } + if req.Temperature != nil { + add("temperature", req.Temperature) + } + if req.TopP != nil { + add("top_p", req.TopP) + } + if req.MaxOutputTokens != nil { + add("max_output_tokens", req.MaxOutputTokens) + } + if req.Store != nil { + add("store", req.Store) + } + if req.Text != nil { + add("text", req.Text) + } + if req.Reasoning != nil { + add("reasoning", req.Reasoning) + } + // truncation is an enum, so the echo carries the canonical spelling the + // validator accepted rather than the caller's padding. + if truncation := strings.TrimSpace(req.Truncation); truncation != "" { + add("truncation", truncation) + } + if req.User != "" { + add("user", req.User) + } + if req.ServiceTier != "" { + add("service_tier", req.ServiceTier) + } + for _, name := range responsesEchoExtraMembers { + if raw := req.ExtraFields.Lookup(name); len(raw) > 0 { + echo[name] = core.CloneRawJSON(raw) + } + } + + if len(echo) == 0 { + return nil + } + return echo +} + +// ApplyResponsesRequestEcho adds the echoed request members to a response built +// by chat translation. Members the provider already produced win: the echo only +// fills what translation cannot carry. +func ApplyResponsesRequestEcho(resp *core.ResponsesResponse, req *core.ResponsesRequest) { + if resp == nil { + return + } + echo := ResponsesRequestEcho(req) + for name := range echo { + if len(resp.ExtraFields.Lookup(name)) > 0 { + delete(echo, name) + } + } + merged, err := core.MergeUnknownJSONFields(resp.ExtraFields, echo) + if err != nil { + return + } + resp.ExtraFields = merged +} diff --git a/internal/providers/responses_echo_test.go b/internal/providers/responses_echo_test.go new file mode 100644 index 000000000..3a5f9b420 --- /dev/null +++ b/internal/providers/responses_echo_test.go @@ -0,0 +1,217 @@ +package providers + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/goccy/go-json" + + "github.com/enterpilot/gomodel/internal/core" +) + +func echoRequest() *core.ResponsesRequest { + temperature := 0.25 + topP := 0.9 + maxOutputTokens := 32 + parallel := false + store := true + return &core.ResponsesRequest{ + // Members the gateway does not model reach the echo through ExtraFields. + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "frequency_penalty": json.RawMessage(`0.5`), + "presence_penalty": json.RawMessage(`0.25`), + "max_tool_calls": json.RawMessage(`4`), + "prompt_cache_key": json.RawMessage(`"key-1"`), + "background": json.RawMessage(`false`), + }), + Model: "claude-haiku-4-5", + Input: "hi", + Instructions: "be terse", + Metadata: map[string]string{"k": "v"}, + Tools: []map[string]any{{"type": "function", "name": "ping", "parameters": map[string]any{}}}, + ToolChoice: "auto", + ParallelToolCalls: ¶llel, + Temperature: &temperature, + TopP: &topP, + MaxOutputTokens: &maxOutputTokens, + Store: &store, + Text: map[string]any{"format": map[string]any{"type": "text"}}, + Reasoning: &core.Reasoning{Effort: "low"}, + Truncation: "disabled", + User: "user-1", + ServiceTier: "auto", + } +} + +// The echoed members are exactly the request fields OpenAI repeats on the +// Response object, and only the ones the caller supplied. +func TestResponsesRequestEcho(t *testing.T) { + tests := []struct { + name string + req *core.ResponsesRequest + want []string + }{ + {name: "nil request"}, + {name: "bare request", req: &core.ResponsesRequest{Model: "m", Input: "hi"}}, + { + name: "every echoable field", + req: echoRequest(), + want: []string{ + "instructions", "metadata", "tools", "tool_choice", "parallel_tool_calls", + "temperature", "top_p", "max_output_tokens", "store", "text", "reasoning", + "truncation", "user", "service_tier", "frequency_penalty", + "presence_penalty", "max_tool_calls", "prompt_cache_key", "background", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + echo := ResponsesRequestEcho(tt.req) + if len(echo) != len(tt.want) { + t.Fatalf("echo keys = %v, want %v", echo, tt.want) + } + for _, name := range tt.want { + if len(echo[name]) == 0 { + t.Fatalf("echo[%q] missing from %v", name, echo) + } + } + }) + } +} + +// truncation is an enum: the echo carries the canonical spelling the validator +// accepted, not the caller's padding, so a strict client still sees a legal value. +func TestResponsesRequestEchoCanonicalizesTruncation(t *testing.T) { + req := &core.ResponsesRequest{Model: "m", Input: "hi", Truncation: " disabled "} + + if got := string(ResponsesRequestEcho(req)["truncation"]); got != `"disabled"` { + t.Fatalf("truncation = %s, want \"disabled\"", got) + } +} + +// Members the gateway does not model are decoded onto ExtraFields, and the +// ones OpenAI repeats on the Response object are echoed from there. Routing +// hints and anything else the caller sent are not: OpenAI does not echo them. +func TestResponsesRequestEchoFromDecodedExtraFields(t *testing.T) { + body := `{"model":"anthropic/claude-haiku-4-5","input":"hi","stream":false,` + + `"frequency_penalty":0.5,"presence_penalty":0.25,"max_tool_calls":4,` + + `"prompt_cache_key":"key-1","background":false,"x_client_trace":"t-1"}` + var req core.ResponsesRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + echo := ResponsesRequestEcho(&req) + want := map[string]string{ + "frequency_penalty": "0.5", + "presence_penalty": "0.25", + "max_tool_calls": "4", + "prompt_cache_key": `"key-1"`, + "background": "false", + } + for name, value := range want { + if got := string(echo[name]); got != value { + t.Errorf("echo[%q] = %s, want %s", name, got, value) + } + } + for _, name := range []string{"x_client_trace", "stream", "model", "input"} { + if _, ok := echo[name]; ok { + t.Errorf("echo[%q] = %s, want it absent", name, echo[name]) + } + } +} + +// A member the provider itself returned must win over the echoed request value. +func TestApplyResponsesRequestEchoKeepsProviderMembers(t *testing.T) { + resp := &core.ResponsesResponse{ + ID: "resp_1", + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "instructions": json.RawMessage(`"from provider"`), + }), + } + + ApplyResponsesRequestEcho(resp, echoRequest()) + + if got := string(resp.ExtraFields.Lookup("instructions")); got != `"from provider"` { + t.Fatalf("instructions = %s, want the provider value", got) + } + if len(resp.ExtraFields.Lookup("metadata")) == 0 { + t.Fatal("metadata was not echoed alongside the provider member") + } +} + +// A chat-translated provider answers with the same echoed members whether the +// caller streamed the request or not. +func TestResponsesViaChatEchoMatchesStream(t *testing.T) { + req := echoRequest() + provider := &capturingChatProvider{ + chatResp: &core.ChatResponse{ + ID: "chatcmpl-1", + Model: req.Model, + Choices: []core.Choice{{Message: core.ResponseMessage{Role: "assistant", Content: "ok"}}}, + }, + streamData: "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + } + + resp, err := ResponsesViaChat(context.Background(), provider, req, "anthropic") + if err != nil { + t.Fatalf("ResponsesViaChat() error = %v", err) + } + encoded, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + var nonStream map[string]json.RawMessage + if err := json.Unmarshal(encoded, &nonStream); err != nil { + t.Fatalf("decode response: %v", err) + } + + stream, err := StreamResponsesViaChat(context.Background(), provider, req.WithStreaming(), "anthropic") + if err != nil { + t.Fatalf("StreamResponsesViaChat() error = %v", err) + } + defer func() { _ = stream.Close() }() + body, err := io.ReadAll(stream) + if err != nil && err != io.EOF { + t.Fatalf("read stream: %v", err) + } + streamed := lastLifecycleResponse(t, string(body)) + + for name := range ResponsesRequestEcho(req) { + if len(nonStream[name]) == 0 { + t.Fatalf("non-streaming response is missing %q: %s", name, encoded) + } + if string(nonStream[name]) != string(streamed[name]) { + t.Fatalf("%q = %s non-streaming, %s streaming", name, nonStream[name], streamed[name]) + } + } +} + +// lastLifecycleResponse returns the response object of the final +// response-carrying SSE event in an SSE body. +func lastLifecycleResponse(t *testing.T, body string) map[string]json.RawMessage { + t.Helper() + var last map[string]json.RawMessage + for line := range strings.SplitSeq(body, "\n") { + data, ok := strings.CutPrefix(strings.TrimSpace(line), "data: ") + if !ok || data == "[DONE]" { + continue + } + var event struct { + Response map[string]json.RawMessage `json:"response"` + } + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + if event.Response != nil { + last = event.Response + } + } + if last == nil { + t.Fatalf("no lifecycle event found in stream: %s", body) + } + return last +} diff --git a/internal/providers/responses_truncation_test.go b/internal/providers/responses_truncation_test.go new file mode 100644 index 000000000..ef6122e8f --- /dev/null +++ b/internal/providers/responses_truncation_test.go @@ -0,0 +1,50 @@ +package providers + +import ( + "errors" + "testing" + + "github.com/enterpilot/gomodel/internal/core" +) + +// "disabled" is OpenAI's documented default and asks for nothing, so a client +// that always sets truncation can still reach a chat-translated provider. Only +// "auto" asks for behaviour translation cannot provide. +func TestConvertResponsesRequestToChatTruncation(t *testing.T) { + tests := []struct { + name string + truncation string + wantErr bool + }{ + {name: "absent"}, + {name: "disabled", truncation: "disabled"}, + {name: "disabled with surrounding space", truncation: " disabled "}, + {name: "auto", truncation: "auto", wantErr: true}, + {name: "unknown value", truncation: "sometimes", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &core.ResponsesRequest{Model: "claude-haiku-4-5", Input: "hi", Truncation: tt.truncation} + + chatReq, err := ConvertResponsesRequestToChat(req) + if !tt.wantErr { + if err != nil { + t.Fatalf("ConvertResponsesRequestToChat() error = %v, want nil", err) + } + if chatReq == nil { + t.Fatal("ConvertResponsesRequestToChat() = nil") + } + return + } + + var gatewayErr *core.GatewayError + if !errors.As(err, &gatewayErr) { + t.Fatalf("ConvertResponsesRequestToChat() error = %v, want a gateway error", err) + } + if gatewayErr.Param == nil || *gatewayErr.Param != "truncation" { + t.Fatalf("param = %v, want truncation", gatewayErr.Param) + } + }) + } +} diff --git a/internal/server/responses_fidelity_test.go b/internal/server/responses_fidelity_test.go new file mode 100644 index 000000000..cfd371c29 --- /dev/null +++ b/internal/server/responses_fidelity_test.go @@ -0,0 +1,131 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enterpilot/gomodel/internal/core" +) + +func fidelityResponsesProvider() *mockProvider { + return &mockProvider{ + supportedModels: []string{"gpt-5-mini"}, + providerTypes: map[string]string{"gpt-5-mini": "mock"}, + responsesResponse: &core.ResponsesResponse{ + ID: "resp_fidelity", + Object: "response", + CreatedAt: 1000, + Model: "gpt-5-mini", + Status: "completed", + Output: []core.ResponsesOutputItem{}, + ExtraFields: core.UnknownJSONFieldsFromMap(map[string]json.RawMessage{ + "instructions": json.RawMessage(`"be terse"`), + "metadata": json.RawMessage(`{"k":"v"}`), + "tool_choice": json.RawMessage(`"auto"`), + "parallel_tool_calls": json.RawMessage(`false`), + "truncation": json.RawMessage(`"auto"`), + }), + }, + } +} + +// The non-streaming answer carries the Response members the provider returned, +// so metadata written on the request can be read back — from the create call +// and from the stored response. +func TestResponses_NonStreamingKeepsProviderResponseMembers(t *testing.T) { + srv := New(fidelityResponsesProvider(), nil) + + body := `{"model":"gpt-5-mini","input":"hi","metadata":{"k":"v"},"instructions":"be terse",` + + `"parallel_tool_calls":false,"truncation":"auto"}` + req := httptest.NewRequest(http.MethodPost, "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/v1/responses", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + srv.handler.drainSnapshotWrites() + + getRec := httptest.NewRecorder() + srv.ServeHTTP(getRec, httptest.NewRequest(http.MethodGet, "/v1/responses/resp_fidelity", nil)) + if getRec.Code != http.StatusOK { + t.Fatalf("get status = %d, want 200 (%s)", getRec.Code, getRec.Body.String()) + } + + want := map[string]string{ + "instructions": `"be terse"`, + "metadata": `{"k":"v"}`, + "tool_choice": `"auto"`, + "parallel_tool_calls": `false`, + "truncation": `"auto"`, + } + for name, payload := range map[string][]byte{"create": rec.Body.Bytes(), "retrieve": getRec.Body.Bytes()} { + var got map[string]json.RawMessage + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatalf("decode %s response: %v", name, err) + } + for member, value := range want { + if string(got[member]) != value { + t.Fatalf("%s response %q = %s, want %s (%s)", name, member, got[member], value, payload) + } + } + } +} + +// A request with no input is answered locally with OpenAI's +// missing-required-parameter error instead of being forwarded as {}. +func TestResponses_MissingInputRejectedLocally(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + }{ + {name: "missing input", body: `{"model":"gpt-5-mini"}`, wantStatus: http.StatusBadRequest}, + {name: "null input", body: `{"model":"gpt-5-mini","input":null}`, wantStatus: http.StatusBadRequest}, + {name: "streaming, missing input", body: `{"model":"gpt-5-mini","stream":true}`, wantStatus: http.StatusBadRequest}, + {name: "input present", body: `{"model":"gpt-5-mini","input":"hi"}`, wantStatus: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := New(fidelityResponsesProvider(), nil) + req := httptest.NewRequest(http.MethodPost, "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/v1/responses", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d (%s)", rec.Code, tt.wantStatus, rec.Body.String()) + } + if tt.wantStatus != http.StatusBadRequest { + return + } + var envelope struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Param *string `json:"param"` + Code *string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode error body: %v", err) + } + if envelope.Error.Message != "Missing required parameter: 'input'." { + t.Fatalf("message = %q", envelope.Error.Message) + } + if envelope.Error.Type != "invalid_request_error" { + t.Fatalf("type = %q, want invalid_request_error", envelope.Error.Type) + } + if envelope.Error.Param == nil || *envelope.Error.Param != "input" { + t.Fatalf("param = %v, want input", envelope.Error.Param) + } + if envelope.Error.Code == nil || *envelope.Error.Code != "missing_required_parameter" { + t.Fatalf("code = %v, want missing_required_parameter", envelope.Error.Code) + } + }) + } +} diff --git a/internal/server/translated_inference_service.go b/internal/server/translated_inference_service.go index c3008435a..c56365ccd 100644 --- a/internal/server/translated_inference_service.go +++ b/internal/server/translated_inference_service.go @@ -355,6 +355,12 @@ func (s *translatedInferenceService) dispatchResponses(c *echo.Context, req *cor ctx := c.Request().Context() requestID := requestIDFromContextOrHeader(c.Request()) + // Checked here, after history resolution has had its chance to supply the + // input, so a request with nothing to send never reaches a provider. + if err := req.ValidateInput(); err != nil { + return handleError(c, err) + } + adm, err := enforceAdmission(c, s.rateLimiter, s.budgetChecker, rateLimitRouteFromWorkflow(workflow).withFailovers(len(s.inference().FailoverSelectors(workflow)))) if err != nil { diff --git a/internal/streaming/assemble.go b/internal/streaming/assemble.go index d824f37fe..a371c6633 100644 --- a/internal/streaming/assemble.go +++ b/internal/streaming/assemble.go @@ -288,6 +288,9 @@ func AssembleResponsesResponse(events []Event) (*core.ResponsesResponse, error) resp := &core.ResponsesResponse{Object: "response", Status: "incomplete"} if base != nil { resp.ID, resp.Model, resp.Provider, resp.CreatedAt = base.ID, base.Model, base.Provider, base.CreatedAt + // The opening event carries the request-echo members (instructions, + // metadata, tools…); an interrupted stream keeps them too. + resp.ExtraFields = core.CloneUnknownJSONFields(base.ExtraFields) } sort.Ints(order) for _, index := range order { diff --git a/tests/contract/testdata/golden/openai/responses.golden.json b/tests/contract/testdata/golden/openai/responses.golden.json index 570420086..1045f273d 100644 --- a/tests/contract/testdata/golden/openai/responses.golden.json +++ b/tests/contract/testdata/golden/openai/responses.golden.json @@ -1,6 +1,17 @@ { + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1772295218, "created_at": 0, + "frequency_penalty": 0, "id": "resp_\u003cgenerated\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, "model": "gpt-4o-mini-2024-07-18", "object": "response", "output": [ @@ -18,8 +29,31 @@ "type": "message" } ], + "parallel_tool_calls": true, + "presence_penalty": 0, + "prompt_cache_key": null, + "prompt_cache_retention": null, "provider": "", + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1, + "truncation": "disabled", "usage": { "input_tokens": 17, "input_tokens_details": { @@ -36,5 +70,6 @@ "rejected_prediction_tokens": 0 }, "total_tokens": 22 - } + }, + "user": null } diff --git a/tests/contract/testdata/golden/xai/responses.golden.json b/tests/contract/testdata/golden/xai/responses.golden.json index a29ca7904..db1e77428 100644 --- a/tests/contract/testdata/golden/xai/responses.golden.json +++ b/tests/contract/testdata/golden/xai/responses.golden.json @@ -1,6 +1,14 @@ { + "background": false, + "completed_at": 1772295250, "created_at": 0, + "frequency_penalty": 0, "id": "afd66348-34f4-4057-3c98-abfc8078703c", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, "model": "grok-3-mini", "object": "response", "output": [ @@ -30,8 +38,29 @@ "type": "message" } ], + "parallel_tool_calls": true, + "presence_penalty": 0, + "prompt_cache_key": null, "provider": "", + "reasoning": { + "effort": "medium", + "summary": "detailed" + }, + "safety_identifier": null, + "service_tier": "default", "status": "completed", + "store": true, + "temperature": 0.7, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 0.95, + "truncation": "disabled", "usage": { "cost_in_usd_ticks": 1424250, "input_tokens": 17, @@ -51,5 +80,6 @@ "rejected_prediction_tokens": 0 }, "total_tokens": 293 - } + }, + "user": null }