diff --git a/e2e/inference_test.go b/e2e/inference_test.go index b7b74134d..7652ffb23 100644 --- a/e2e/inference_test.go +++ b/e2e/inference_test.go @@ -106,6 +106,59 @@ func TestE2E_Inference(t *testing.T) { t.Logf("responses API: %q", resp.OutputText) }) + t.Run("ResponsesAPIStructuredOutput", func(t *testing.T) { + status, body := doJSON(t, http.MethodPost, serverURL+"/responses", + map[string]any{ + "model": bc.model, + "input": "Return a JSON object with answer set to yes.", + "max_output_tokens": 64, + "text": map[string]any{ + "format": map[string]any{ + "type": "json_schema", + "name": "answer_payload", + "strict": true, + "schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "answer": map[string]any{ + "type": "string", + "enum": []string{"yes"}, + }, + }, + "required": []string{"answer"}, + "additionalProperties": false, + }, + }, + }, + }) + if status != http.StatusOK { + t.Fatalf("responses structured output: status=%d body=%s", status, body) + } + var resp struct { + Status string `json:"status"` + OutputText string `json:"output_text"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode: %v (body=%s)", err, body) + } + if resp.Status != "completed" { + t.Errorf("expected status=completed, got %q", resp.Status) + } + + var output struct { + Answer string `json:"answer"` + } + if err := json.Unmarshal([]byte(resp.OutputText), &output); err != nil { + t.Fatalf("output_text is not valid structured JSON: %v (output_text=%q)", err, resp.OutputText) + } + if output.Answer == "" { + t.Fatalf("answer field is empty in output_text=%q", resp.OutputText) + } + if output.Answer != "yes" { + t.Fatalf("answer = %q, want yes (output_text=%q)", output.Answer, resp.OutputText) + } + }) + t.Run("AnthropicMessages", func(t *testing.T) { status, body := doJSON(t, http.MethodPost, serverURL+"/anthropic/v1/messages", map[string]any{ diff --git a/pkg/responses/api.go b/pkg/responses/api.go index 214401f55..9bfc6dc69 100644 --- a/pkg/responses/api.go +++ b/pkg/responses/api.go @@ -91,9 +91,42 @@ type CreateRequest struct { // MaxOutputTokens limits the response length. MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + // Store controls whether the response is stored for later retrieval. + Store *bool `json:"store,omitempty"` + + // Text configures text output formatting. + Text *ResponseTextConfig `json:"text,omitempty"` + + // Include requests additional hosted-tool output fields. Unsupported locally. + Include []string `json:"include,omitempty"` + // Stream enables streaming responses. Stream bool `json:"stream,omitempty"` + // StreamOptions configures response streaming. Unsupported locally. + StreamOptions json.RawMessage `json:"stream_options,omitempty"` + + // TopLogprobs requests token log probabilities. Unsupported locally. + TopLogprobs *int `json:"top_logprobs,omitempty"` + + // Truncation configures OpenAI context truncation behavior. + Truncation string `json:"truncation,omitempty"` + + // Background requests asynchronous background response execution. + Background *bool `json:"background,omitempty"` + + // Conversation requests OpenAI hosted conversation state. Unsupported locally. + Conversation json.RawMessage `json:"conversation,omitempty"` + + // Prompt requests an OpenAI hosted prompt template. Unsupported locally. + Prompt json.RawMessage `json:"prompt,omitempty"` + + // ServiceTier selects OpenAI service tier. Unsupported locally. + ServiceTier string `json:"service_tier,omitempty"` + + // SafetyIdentifier is an OpenAI safety identifier. Unsupported locally. + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Metadata is user-defined metadata for the response. Metadata map[string]string `json:"metadata,omitempty"` @@ -104,6 +137,21 @@ type CreateRequest struct { User string `json:"user,omitempty"` } +// ResponseTextConfig configures text output for a Responses API request. +type ResponseTextConfig struct { + Format *ResponseTextFormat `json:"format,omitempty"` + Verbosity string `json:"verbosity,omitempty"` +} + +// ResponseTextFormat configures the output text format. +type ResponseTextFormat struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Schema json.RawMessage `json:"schema,omitempty"` + Strict *bool `json:"strict,omitempty"` +} + // Response represents a complete response from the API. type Response struct { // ID is the unique identifier for this response. @@ -160,6 +208,12 @@ type Response struct { // MaxOutputTokens limit used. MaxOutputTokens *int `json:"max_output_tokens"` + // Store indicates whether the response was stored. + Store *bool `json:"store,omitempty"` + + // Text config used for the response. + Text *ResponseTextConfig `json:"text,omitempty"` + // PreviousResponseID is the ID of the previous response in the chain. PreviousResponseID *string `json:"previous_response_id"` diff --git a/pkg/responses/handler.go b/pkg/responses/handler.go index eba10c7c8..c4aecc2ea 100644 --- a/pkg/responses/handler.go +++ b/pkg/responses/handler.go @@ -33,16 +33,11 @@ func NewHTTPHandler(log logging.Logger, schedulerHTTP http.Handler, allowedOrigi maxRequestBodyBytes: 10 * 1024 * 1024, // Default to 10MB } - // Register routes - h.router.HandleFunc("POST "+APIPrefix, h.handleCreate) - h.router.HandleFunc("GET "+APIPrefix+"/{id}", h.handleGet) - h.router.HandleFunc("GET "+APIPrefix+"/{id}/input_items", h.handleListInputItems) - h.router.HandleFunc("DELETE "+APIPrefix+"/{id}", h.handleDelete) - // Also register /v1/responses routes - h.router.HandleFunc("POST /v1"+APIPrefix, h.handleCreate) - h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}", h.handleGet) - h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}/input_items", h.handleListInputItems) - h.router.HandleFunc("DELETE /v1"+APIPrefix+"/{id}", h.handleDelete) + h.registerRoutes(APIPrefix) + h.registerRoutes("/v1" + APIPrefix) + h.registerRoutes("/engines" + APIPrefix) + h.registerRoutes("/engines/v1" + APIPrefix) + h.registerRoutes("/engines/{backend}/v1" + APIPrefix) // Apply CORS middleware h.httpHandler = middleware.CorsMiddleware(allowedOrigins, h.router) @@ -50,6 +45,13 @@ func NewHTTPHandler(log logging.Logger, schedulerHTTP http.Handler, allowedOrigi return h } +func (h *HTTPHandler) registerRoutes(prefix string) { + h.router.HandleFunc("POST "+prefix, h.handleCreate) + h.router.HandleFunc("GET "+prefix+"/{id}", h.handleGet) + h.router.HandleFunc("GET "+prefix+"/{id}/input_items", h.handleListInputItems) + h.router.HandleFunc("DELETE "+prefix+"/{id}", h.handleDelete) +} + // Close releases resources held by the handler, including the background // store cleanup goroutine. It should be called when the handler is shut down. func (h *HTTPHandler) Close() { @@ -97,14 +99,21 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) { h.sendError(w, http.StatusBadRequest, "invalid_request", "model is required") return } + if err := validateUnsupportedRequestFields(&req); err != nil { + h.sendError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } // Create a new response respID := GenerateResponseID() resp := NewResponse(respID, req.Model) + store := shouldStore(&req) resp.Instructions = nilIfEmpty(req.Instructions) resp.Temperature = req.Temperature resp.TopP = req.TopP resp.MaxOutputTokens = req.MaxOutputTokens + resp.Store = &store + resp.Text = req.Text resp.Tools = req.Tools resp.ToolChoice = req.ToolChoice resp.ParallelToolCalls = req.ParallelToolCalls @@ -134,7 +143,7 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } // Create upstream request - upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "/engines/v1/chat/completions", bytes.NewReader(chatBody)) + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, chatCompletionPathForRequest(r), bytes.NewReader(chatBody)) if err != nil { h.sendError(w, http.StatusInternalServerError, "internal_error", "Failed to create request") return @@ -147,24 +156,29 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) { if req.Stream { // Handle streaming response - h.handleStreaming(w, upstreamReq, resp) + h.handleStreaming(w, upstreamReq, resp, store) } else { // Handle non-streaming response - h.handleNonStreaming(w, upstreamReq, resp) + h.handleNonStreaming(w, upstreamReq, resp, store) } } // handleStreaming handles streaming responses. -func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) { +func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) { + responseStore := h.store + if !store { + responseStore = nil + } + // Create streaming writer - streamWriter := NewStreamingResponseWriter(w, resp, h.store) + streamWriter := NewStreamingResponseWriter(w, resp, responseStore) // Forward to scheduler h.schedulerHTTP.ServeHTTP(streamWriter, upstreamReq) } // handleNonStreaming handles non-streaming responses. -func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) { +func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) { // Capture upstream response capture := NewNonStreamingResponseCapture() @@ -187,7 +201,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt Code: errResp.Error.Code, Message: errResp.Error.Message, } - h.store.Save(resp) + if store { + h.store.Save(resp) + } h.sendJSON(w, capture.StatusCode, resp) return } @@ -197,7 +213,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt Code: "upstream_error", Message: capture.Body.String(), } - h.store.Save(resp) + if store { + h.store.Save(resp) + } h.sendJSON(w, capture.StatusCode, resp) return } @@ -210,7 +228,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt Code: "parse_error", Message: "Failed to parse upstream response", } - h.store.Save(resp) + if store { + h.store.Save(resp) + } h.sendJSON(w, http.StatusInternalServerError, resp) return } @@ -222,6 +242,8 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt finalResp.Temperature = resp.Temperature finalResp.TopP = resp.TopP finalResp.MaxOutputTokens = resp.MaxOutputTokens + finalResp.Store = resp.Store + finalResp.Text = resp.Text finalResp.Tools = resp.Tools finalResp.ToolChoice = resp.ToolChoice finalResp.ParallelToolCalls = resp.ParallelToolCalls @@ -232,7 +254,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt finalResp.CreatedAt = resp.CreatedAt // Store the response - h.store.Save(finalResp) + if store { + h.store.Save(finalResp) + } // Send response h.sendJSON(w, http.StatusOK, finalResp) @@ -332,6 +356,58 @@ func nilIfEmpty(s string) *string { return &s } +func shouldStore(req *CreateRequest) bool { + return req.Store == nil || *req.Store +} + +func chatCompletionPathForRequest(r *http.Request) string { + if backend := r.PathValue("backend"); backend != "" { + return "/engines/" + backend + "/v1/chat/completions" + } + return "/engines/v1/chat/completions" +} + +func validateUnsupportedRequestFields(req *CreateRequest) error { + if len(req.Include) > 0 { + return unsupportedFieldError("include") + } + if req.StreamOptions != nil && !isNullJSON(req.StreamOptions) { + return unsupportedFieldError("stream_options") + } + if req.TopLogprobs != nil { + return unsupportedFieldError("top_logprobs") + } + switch req.Truncation { + case "", "disabled": + default: + return fmt.Errorf("truncation value %q is not supported by Docker Model Runner Responses compatibility layer", req.Truncation) + } + if req.Background != nil && *req.Background { + return fmt.Errorf("background responses are not supported by Docker Model Runner Responses compatibility layer") + } + if req.Conversation != nil && !isNullJSON(req.Conversation) { + return fmt.Errorf("conversation is not supported by Docker Model Runner Responses compatibility layer; use previous_response_id instead") + } + if req.Prompt != nil && !isNullJSON(req.Prompt) { + return unsupportedFieldError("prompt") + } + if req.ServiceTier != "" { + return unsupportedFieldError("service_tier") + } + if req.SafetyIdentifier != "" { + return unsupportedFieldError("safety_identifier") + } + return nil +} + +func unsupportedFieldError(field string) error { + return fmt.Errorf("%s is not supported by Docker Model Runner Responses compatibility layer", field) +} + +func isNullJSON(raw json.RawMessage) bool { + return strings.TrimSpace(string(raw)) == "null" +} + // GetStore returns the response store (for testing). func (h *HTTPHandler) GetStore() *Store { return h.store diff --git a/pkg/responses/handler_test.go b/pkg/responses/handler_test.go index 33092fabc..255320ee4 100644 --- a/pkg/responses/handler_test.go +++ b/pkg/responses/handler_test.go @@ -17,9 +17,18 @@ type mockSchedulerHTTP struct { statusCode int streaming bool streamChunks []string + calls int + lastPath string + lastBody []byte } func (m *mockSchedulerHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.calls++ + m.lastPath = r.URL.Path + if r.Body != nil { + m.lastBody, _ = io.ReadAll(r.Body) + } + if m.streaming { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) @@ -106,11 +115,458 @@ func TestHandler_CreateResponse_NonStreaming(t *testing.T) { if result.OutputText != "Hello! How can I help you?" { t.Errorf("output_text = %s, want Hello! How can I help you?", result.OutputText) } + if result.Store == nil || !*result.Store { + t.Fatalf("store = %v, want true", result.Store) + } if !strings.HasPrefix(result.ID, "resp_") { t.Errorf("id should start with resp_, got %s", result.ID) } } +func TestHandler_CreateResponse_TextFormatJSONSchema(t *testing.T) { + mock := &mockSchedulerHTTP{ + statusCode: http.StatusOK, + response: `{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"answer\":\"yes\"}" + }, + "finish_reason": "stop" + } + ] + }`, + } + + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Answer yes as JSON", + "text": { + "format": { + "type": "json_schema", + "name": "answer_prompt", + "description": "Answer payload", + "strict": true, + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"], + "additionalProperties": false + } + } + } + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + + if mock.lastPath != "/engines/v1/chat/completions" { + t.Fatalf("upstream path = %s, want /engines/v1/chat/completions", mock.lastPath) + } + + var chatReq ChatCompletionRequest + if err := json.Unmarshal(mock.lastBody, &chatReq); err != nil { + t.Fatalf("failed to decode upstream request: %v", err) + } + if chatReq.ResponseFormat == nil { + t.Fatal("upstream response_format is nil") + } + if chatReq.ResponseFormat.Type != "json_schema" { + t.Errorf("upstream response_format.type = %s, want json_schema", chatReq.ResponseFormat.Type) + } + if chatReq.ResponseFormat.JSONSchema == nil { + t.Fatal("upstream response_format.json_schema is nil") + } + if chatReq.ResponseFormat.JSONSchema.Name != "answer_prompt" { + t.Errorf("json_schema.name = %s, want answer_prompt", chatReq.ResponseFormat.JSONSchema.Name) + } + if chatReq.ResponseFormat.JSONSchema.Strict == nil || !*chatReq.ResponseFormat.JSONSchema.Strict { + t.Errorf("json_schema.strict = %v, want true", chatReq.ResponseFormat.JSONSchema.Strict) + } + if !strings.Contains(string(chatReq.ResponseFormat.JSONSchema.Schema), `"additionalProperties":false`) { + t.Errorf("json_schema.schema = %s, want original schema", chatReq.ResponseFormat.JSONSchema.Schema) + } + + var result Response + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Text == nil || result.Text.Format == nil { + t.Fatal("response text.format is nil") + } + if result.Text.Format.Type != "json_schema" { + t.Errorf("response text.format.type = %s, want json_schema", result.Text.Format.Type) + } + if result.Text.Format.Name != "answer_prompt" { + t.Errorf("response text.format.name = %s, want answer_prompt", result.Text.Format.Name) + } +} + +func TestHandler_CreateResponse_TextFormatJSONObject(t *testing.T) { + mock := &mockSchedulerHTTP{ + statusCode: http.StatusOK, + response: `{ + "id": "chatcmpl-123", + "choices": [ + { + "message": { + "role": "assistant", + "content": "{\"answer\":\"yes\"}" + } + } + ] + }`, + } + + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Answer with JSON", + "text": { + "format": { + "type": "json_object" + } + } + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + + var chatReq ChatCompletionRequest + if err := json.Unmarshal(mock.lastBody, &chatReq); err != nil { + t.Fatalf("failed to decode upstream request: %v", err) + } + if chatReq.ResponseFormat == nil { + t.Fatal("upstream response_format is nil") + } + if chatReq.ResponseFormat.Type != "json_object" { + t.Errorf("upstream response_format.type = %s, want json_object", chatReq.ResponseFormat.Type) + } + if chatReq.ResponseFormat.JSONSchema != nil { + t.Errorf("upstream response_format.json_schema = %v, want nil", chatReq.ResponseFormat.JSONSchema) + } +} + +func TestHandler_CreateResponse_StoreFalseNotPersisted(t *testing.T) { + mock := &mockSchedulerHTTP{ + statusCode: http.StatusOK, + response: `{ + "id": "chatcmpl-123", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello!" + } + } + ] + }`, + } + + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Hi", + "store": false + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + + var result Response + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Store == nil || *result.Store { + t.Fatalf("store = %v, want false", result.Store) + } + if handler.store.Count() != 0 { + t.Fatalf("store count = %d, want 0", handler.store.Count()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/v1/responses/"+result.ID, http.NoBody) + getReq.SetPathValue("id", result.ID) + getW := httptest.NewRecorder() + handler.handleGet(getW, getReq) + if getW.Result().StatusCode != http.StatusNotFound { + t.Fatalf("GET status = %d, want %d", getW.Result().StatusCode, http.StatusNotFound) + } +} + +func TestHandler_CreateResponse_StreamingStoreFalseNotPersisted(t *testing.T) { + mock := &mockSchedulerHTTP{ + streaming: true, + streamChunks: []string{ + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + }, + } + + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Hello", + "stream": true, + "store": false + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + if handler.store.Count() != 0 { + t.Fatalf("store count = %d, want 0", handler.store.Count()) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + if !strings.Contains(string(body), `"store":false`) { + t.Fatalf("stream events did not preserve store=false: %s", body) + } +} + +func TestHandler_CreateResponse_BackendQualifiedRoute(t *testing.T) { + mock := &mockSchedulerHTTP{ + statusCode: http.StatusOK, + response: `{ + "id": "chatcmpl-123", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello!" + } + } + ] + }`, + } + + handler := newTestHandler(t, mock) + + req := httptest.NewRequest(http.MethodPost, "/engines/llama.cpp/v1/responses", strings.NewReader(`{ + "model": "gpt-4", + "input": "Hi" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + if mock.lastPath != "/engines/llama.cpp/v1/chat/completions" { + t.Fatalf("upstream path = %s, want /engines/llama.cpp/v1/chat/completions", mock.lastPath) + } +} + +func TestHandler_CreateResponse_PreviousResponseNotFound(t *testing.T) { + mock := &mockSchedulerHTTP{} + handler := newTestHandler(t, mock) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model": "gpt-4", + "input": "Hi", + "previous_response_id": "resp_missing" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusBadRequest, body) + } + if mock.calls != 0 { + t.Fatalf("scheduler calls = %d, want 0", mock.calls) + } +} + +func TestHandler_CreateResponse_UnsupportedResponseFields(t *testing.T) { + tests := []struct { + name string + field string + wantErr string + }{ + { + name: "include", + field: `"include": ["file_search_call.results"],`, + wantErr: "include is not supported", + }, + { + name: "stream options", + field: `"stream_options": {"include_obfuscation": true},`, + wantErr: "stream_options is not supported", + }, + { + name: "top logprobs", + field: `"top_logprobs": 5,`, + wantErr: "top_logprobs is not supported", + }, + { + name: "truncation auto", + field: `"truncation": "auto",`, + wantErr: "truncation value", + }, + { + name: "background", + field: `"background": true,`, + wantErr: "background responses are not supported", + }, + { + name: "conversation", + field: `"conversation": {"id": "conv_123"},`, + wantErr: "conversation is not supported", + }, + { + name: "prompt", + field: `"prompt": {"id": "pmpt_123"},`, + wantErr: "prompt is not supported", + }, + { + name: "service tier", + field: `"service_tier": "flex",`, + wantErr: "service_tier is not supported", + }, + { + name: "safety identifier", + field: `"safety_identifier": "user-123",`, + wantErr: "safety_identifier is not supported", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockSchedulerHTTP{} + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + ` + tt.field + ` + "input": "Hello" + }` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusBadRequest, body) + } + if mock.calls != 0 { + t.Fatalf("scheduler calls = %d, want 0", mock.calls) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + if !strings.Contains(string(body), tt.wantErr) { + t.Fatalf("body = %s, want to contain %q", body, tt.wantErr) + } + }) + } +} + +func TestHandler_CreateResponse_SupportedNoopResponseFields(t *testing.T) { + mock := &mockSchedulerHTTP{ + statusCode: http.StatusOK, + response: `{ + "id": "chatcmpl-123", + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello!" + } + } + ] + }`, + } + + handler := newTestHandler(t, mock) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model": "gpt-4", + "input": "Hi", + "background": false, + "truncation": "disabled", + "stream_options": null, + "conversation": null, + "prompt": null + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + if mock.calls != 1 { + t.Fatalf("scheduler calls = %d, want 1", mock.calls) + } +} + func TestHandler_CreateResponse_MissingModel(t *testing.T) { mock := &mockSchedulerHTTP{} handler := newTestHandler(t, mock) @@ -456,6 +912,132 @@ func TestHandler_CreateResponse_Streaming(t *testing.T) { } } +func TestHandler_CreateResponse_Streaming_TextFormatJSONSchema(t *testing.T) { + mock := &mockSchedulerHTTP{ + streaming: true, + streamChunks: []string{ + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"{\\\"answer\\\":\\\"yes\\\"}\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + }, + } + + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Answer yes as JSON", + "stream": true, + "text": { + "format": { + "type": "json_schema", + "name": "answer_prompt", + "strict": true, + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"], + "additionalProperties": false + } + } + } + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusOK, body) + } + + var chatReq ChatCompletionRequest + if err := json.Unmarshal(mock.lastBody, &chatReq); err != nil { + t.Fatalf("failed to decode upstream request: %v", err) + } + if chatReq.ResponseFormat == nil || chatReq.ResponseFormat.JSONSchema == nil { + t.Fatalf("upstream response_format = %#v, want json_schema", chatReq.ResponseFormat) + } + if chatReq.ResponseFormat.JSONSchema.Name != "answer_prompt" { + t.Errorf("json_schema.name = %s, want answer_prompt", chatReq.ResponseFormat.JSONSchema.Name) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + bodyStr := string(body) + if !strings.Contains(bodyStr, `"text":{"format":{"type":"json_schema"`) { + t.Errorf("stream response events did not preserve text config: %s", bodyStr) + } +} + +func TestHandler_CreateResponse_InvalidTextFormat(t *testing.T) { + mock := &mockSchedulerHTTP{} + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Hello", + "text": { + "format": { + "type": "json_schema", + "schema": {"type": "object"} + } + } + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusBadRequest, body) + } + if mock.calls != 0 { + t.Fatalf("scheduler calls = %d, want 0", mock.calls) + } +} + +func TestHandler_CreateResponse_UnsupportedHostedTool(t *testing.T) { + mock := &mockSchedulerHTTP{} + handler := newTestHandler(t, mock) + + reqBody := `{ + "model": "gpt-4", + "input": "Search the web", + "tools": [ + {"type": "web_search"} + ] + }` + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler.handleCreate(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusBadRequest, body) + } + if mock.calls != 0 { + t.Fatalf("scheduler calls = %d, want 0", mock.calls) + } +} + func TestHandler_CreateResponse_WithTools(t *testing.T) { // Mock response with tool call mock := &mockSchedulerHTTP{ diff --git a/pkg/responses/transform.go b/pkg/responses/transform.go index e1a8ce899..cae624ee0 100644 --- a/pkg/responses/transform.go +++ b/pkg/responses/transform.go @@ -3,21 +3,39 @@ package responses import ( "encoding/json" "fmt" + "regexp" "strings" ) +var responseTextFormatNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + // ChatCompletionRequest represents an OpenAI chat completion request. type ChatCompletionRequest struct { - Model string `json:"model"` - Messages []ChatMessage `json:"messages"` - Tools []ChatTool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` - User string `json:"user,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Tools []ChatTool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + User string `json:"user,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ResponseFormat *ChatResponseFormat `json:"response_format,omitempty"` +} + +// ChatResponseFormat configures output formatting in chat completions. +type ChatResponseFormat struct { + Type string `json:"type"` + JSONSchema *ChatJSONSchema `json:"json_schema,omitempty"` +} + +// ChatJSONSchema is the chat completions JSON schema response_format payload. +type ChatJSONSchema struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schema json.RawMessage `json:"schema"` + Strict *bool `json:"strict,omitempty"` } // ChatMessage represents a message in the chat completion format. @@ -130,6 +148,12 @@ func TransformRequestToChatCompletion(req *CreateRequest, store *Store) (*ChatCo ToolChoice: req.ToolChoice, } + responseFormat, err := responseFormatFromText(req.Text) + if err != nil { + return nil, err + } + chatReq.ResponseFormat = responseFormat + // Convert tools if len(req.Tools) > 0 { chatReq.Tools = make([]ChatTool, 0, len(req.Tools)) @@ -152,7 +176,10 @@ func TransformRequestToChatCompletion(req *CreateRequest, store *Store) (*ChatCo } } chatReq.Tools = append(chatReq.Tools, chatTool) + continue } + + return nil, fmt.Errorf("tool type %q is not supported by Docker Model Runner Responses compatibility layer", tool.Type) } } @@ -168,13 +195,18 @@ func TransformRequestToChatCompletion(req *CreateRequest, store *Store) (*ChatCo } // If there's a previous response, include its conversation - if req.PreviousResponseID != "" && store != nil { + if req.PreviousResponseID != "" { + if store == nil { + return nil, fmt.Errorf("previous_response_id %q cannot be used because response storage is unavailable", req.PreviousResponseID) + } prevResp, ok := store.Get(req.PreviousResponseID) - if ok { - // Recursively get the conversation history - prevMessages := getConversationHistory(prevResp, store) - messages = append(messages, prevMessages...) + if !ok { + return nil, fmt.Errorf("previous_response_id %q was not found", req.PreviousResponseID) } + + // Recursively get the conversation history + prevMessages := getConversationHistory(prevResp, store) + messages = append(messages, prevMessages...) } // Parse and convert input @@ -188,6 +220,50 @@ func TransformRequestToChatCompletion(req *CreateRequest, store *Store) (*ChatCo return chatReq, nil } +func responseFormatFromText(text *ResponseTextConfig) (*ChatResponseFormat, error) { + if text == nil || text.Format == nil { + return nil, nil + } + + format := text.Format + switch format.Type { + case "", "text": + return nil, nil + case "json_object": + return &ChatResponseFormat{Type: "json_object"}, nil + case "json_schema": + if format.Name == "" { + return nil, fmt.Errorf("text.format.name is required when text.format.type is json_schema") + } + if !responseTextFormatNamePattern.MatchString(format.Name) { + return nil, fmt.Errorf("text.format.name must contain only letters, numbers, underscores, and dashes, with a maximum length of 64") + } + if strings.TrimSpace(string(format.Schema)) == "" { + return nil, fmt.Errorf("text.format.schema is required when text.format.type is json_schema") + } + + var schemaValue any + if err := json.Unmarshal(format.Schema, &schemaValue); err != nil { + return nil, fmt.Errorf("text.format.schema must be a valid JSON object: %w", err) + } + if _, ok := schemaValue.(map[string]any); !ok { + return nil, fmt.Errorf("text.format.schema must be a JSON object") + } + + return &ChatResponseFormat{ + Type: "json_schema", + JSONSchema: &ChatJSONSchema{ + Name: format.Name, + Description: format.Description, + Schema: format.Schema, + Strict: format.Strict, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported text.format.type %q", format.Type) + } +} + // parseInput parses the input field which can be a string or array of items. func parseInput(input json.RawMessage) ([]ChatMessage, error) { if len(input) == 0 { diff --git a/pkg/responses/transform_test.go b/pkg/responses/transform_test.go index a7a22552e..08fdc8b30 100644 --- a/pkg/responses/transform_test.go +++ b/pkg/responses/transform_test.go @@ -2,6 +2,7 @@ package responses import ( "encoding/json" + "strings" "testing" ) @@ -223,6 +224,41 @@ func TestTransformRequestToChatCompletion_FunctionCallOutput(t *testing.T) { } } +func TestTransformRequestToChatCompletion_PreviousResponseNotFound(t *testing.T) { + store := NewStore(DefaultTTL) + t.Cleanup(store.Close) + + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Hello"`), + PreviousResponseID: "resp_missing", + } + + _, err := TransformRequestToChatCompletion(req, store) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), `previous_response_id "resp_missing" was not found`) { + t.Fatalf("error = %q, want previous_response_id not found", err.Error()) + } +} + +func TestTransformRequestToChatCompletion_PreviousResponseWithoutStore(t *testing.T) { + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Hello"`), + PreviousResponseID: "resp_missing", + } + + _, err := TransformRequestToChatCompletion(req, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "response storage is unavailable") { + t.Fatalf("error = %q, want storage unavailable", err.Error()) + } +} + func TestTransformRequestToChatCompletion_WithParameters(t *testing.T) { temp := 0.7 topP := 0.9 @@ -256,6 +292,156 @@ func TestTransformRequestToChatCompletion_WithParameters(t *testing.T) { } } +func TestTransformRequestToChatCompletion_TextFormatJSONSchema(t *testing.T) { + strict := true + schema := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"],"additionalProperties":false}`) + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Answer in JSON"`), + Text: &ResponseTextConfig{ + Format: &ResponseTextFormat{ + Type: "json_schema", + Name: "answer_prompt", + Description: "Answer payload", + Schema: schema, + Strict: &strict, + }, + }, + } + + chatReq, err := TransformRequestToChatCompletion(req, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if chatReq.ResponseFormat == nil { + t.Fatal("response_format is nil") + } + if chatReq.ResponseFormat.Type != "json_schema" { + t.Errorf("response_format.type = %s, want json_schema", chatReq.ResponseFormat.Type) + } + if chatReq.ResponseFormat.JSONSchema == nil { + t.Fatal("response_format.json_schema is nil") + } + if chatReq.ResponseFormat.JSONSchema.Name != "answer_prompt" { + t.Errorf("json_schema.name = %s, want answer_prompt", chatReq.ResponseFormat.JSONSchema.Name) + } + if chatReq.ResponseFormat.JSONSchema.Description != "Answer payload" { + t.Errorf("json_schema.description = %s, want Answer payload", chatReq.ResponseFormat.JSONSchema.Description) + } + if string(chatReq.ResponseFormat.JSONSchema.Schema) != string(schema) { + t.Errorf("json_schema.schema = %s, want %s", chatReq.ResponseFormat.JSONSchema.Schema, schema) + } + if chatReq.ResponseFormat.JSONSchema.Strict == nil || !*chatReq.ResponseFormat.JSONSchema.Strict { + t.Errorf("json_schema.strict = %v, want true", chatReq.ResponseFormat.JSONSchema.Strict) + } +} + +func TestTransformRequestToChatCompletion_TextFormatJSONObject(t *testing.T) { + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Answer with a JSON object"`), + Text: &ResponseTextConfig{ + Format: &ResponseTextFormat{ + Type: "json_object", + }, + }, + } + + chatReq, err := TransformRequestToChatCompletion(req, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if chatReq.ResponseFormat == nil { + t.Fatal("response_format is nil") + } + if chatReq.ResponseFormat.Type != "json_object" { + t.Errorf("response_format.type = %s, want json_object", chatReq.ResponseFormat.Type) + } + if chatReq.ResponseFormat.JSONSchema != nil { + t.Errorf("response_format.json_schema = %v, want nil", chatReq.ResponseFormat.JSONSchema) + } +} + +func TestTransformRequestToChatCompletion_TextFormatTextOmitted(t *testing.T) { + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Hello"`), + Text: &ResponseTextConfig{ + Format: &ResponseTextFormat{ + Type: "text", + }, + }, + } + + chatReq, err := TransformRequestToChatCompletion(req, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if chatReq.ResponseFormat != nil { + t.Errorf("response_format = %v, want nil", chatReq.ResponseFormat) + } +} + +func TestTransformRequestToChatCompletion_TextFormatValidation(t *testing.T) { + tests := []struct { + name string + format ResponseTextFormat + wantErr string + }{ + { + name: "invalid type", + format: ResponseTextFormat{Type: "xml"}, + wantErr: "unsupported text.format.type", + }, + { + name: "json schema requires name", + format: ResponseTextFormat{Type: "json_schema", Schema: json.RawMessage(`{"type":"object"}`)}, + wantErr: "text.format.name is required", + }, + { + name: "json schema validates name", + format: ResponseTextFormat{Type: "json_schema", Name: "not valid", Schema: json.RawMessage(`{"type":"object"}`)}, + wantErr: "text.format.name must contain", + }, + { + name: "json schema requires schema", + format: ResponseTextFormat{Type: "json_schema", Name: "answer"}, + wantErr: "text.format.schema is required", + }, + { + name: "json schema must be valid JSON", + format: ResponseTextFormat{Type: "json_schema", Name: "answer", Schema: json.RawMessage(`{invalid`)}, + wantErr: "text.format.schema must be a valid JSON object", + }, + { + name: "json schema must be object", + format: ResponseTextFormat{Type: "json_schema", Name: "answer", Schema: json.RawMessage(`[]`)}, + wantErr: "text.format.schema must be a JSON object", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &CreateRequest{ + Model: "gpt-4", + Input: json.RawMessage(`"Hello"`), + Text: &ResponseTextConfig{ + Format: &tt.format, + }, + } + + _, err := TransformRequestToChatCompletion(req, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestTransformChatCompletionToResponse_TextContent(t *testing.T) { chatResp := &ChatCompletionResponse{ ID: "chatcmpl-123", diff --git a/pkg/routing/router.go b/pkg/routing/router.go index 39863d86d..bd060e0cd 100644 --- a/pkg/routing/router.go +++ b/pkg/routing/router.go @@ -51,8 +51,9 @@ type RouterConfig struct { ModelHandlerMiddleware func(http.Handler) http.Handler // IncludeResponsesAPI enables the OpenAI Responses API compatibility - // layer, registering it under /responses, /v1/responses, and - // /engines/responses prefixes. Requires SchedulerHTTP to be set. + // layer, registering it under /responses, /v1/responses, + // /engines/responses, /engines/v1/responses, and backend-qualified + // /engines/{backend}/v1/responses prefixes. Requires SchedulerHTTP to be set. IncludeResponsesAPI bool } @@ -99,8 +100,15 @@ func NewRouter(cfg RouterConfig) *RouterResult { router.Handle(responses.APIPrefix, responsesHandler) router.Handle("/v1"+responses.APIPrefix+"/", responsesHandler) router.Handle("/v1"+responses.APIPrefix, responsesHandler) - router.Handle(inference.InferencePrefix+responses.APIPrefix+"/", responsesHandler) router.Handle(inference.InferencePrefix+responses.APIPrefix, responsesHandler) + router.Handle(inference.InferencePrefix+responses.APIPrefix+"/{id}", responsesHandler) + router.Handle(inference.InferencePrefix+responses.APIPrefix+"/{id}/input_items", responsesHandler) + router.Handle(inference.InferencePrefix+"/v1"+responses.APIPrefix, responsesHandler) + router.Handle(inference.InferencePrefix+"/v1"+responses.APIPrefix+"/{id}", responsesHandler) + router.Handle(inference.InferencePrefix+"/v1"+responses.APIPrefix+"/{id}/input_items", responsesHandler) + router.Handle(inference.InferencePrefix+"/{backend}/v1"+responses.APIPrefix, responsesHandler) + router.Handle(inference.InferencePrefix+"/{backend}/v1"+responses.APIPrefix+"/{id}", responsesHandler) + router.Handle(inference.InferencePrefix+"/{backend}/v1"+responses.APIPrefix+"/{id}/input_items", responsesHandler) result.closers = append(result.closers, responsesHandler.Close) } diff --git a/pkg/routing/router_test.go b/pkg/routing/router_test.go index c3d8ceb2e..416d7e6c8 100644 --- a/pkg/routing/router_test.go +++ b/pkg/routing/router_test.go @@ -1,7 +1,12 @@ package routing import ( + "encoding/json" + "io" "log/slog" + "net/http" + "net/http/httptest" + "strings" "testing" ) @@ -45,3 +50,55 @@ func TestNewRouter_WithoutResponsesAPI_Close(t *testing.T) { // Should be a no-op, must not panic. result.Close() } + +func TestNewRouter_ResponsesAPIRoutes(t *testing.T) { + tests := []string{ + "/responses", + "/v1/responses", + "/engines/responses", + "/engines/v1/responses", + "/engines/llama.cpp/v1/responses", + } + + for _, path := range tests { + t.Run(path, func(t *testing.T) { + log := slog.New(slog.DiscardHandler) + + result := NewRouter(RouterConfig{ + Log: log, + IncludeResponsesAPI: true, + }) + t.Cleanup(result.Close) + + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{ + "input": "Hello" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + result.Mux.ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want %d, body: %s", resp.StatusCode, http.StatusBadRequest, body) + } + + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { + t.Fatalf("failed to decode error response: %v", err) + } + if errResp.Error.Code != "invalid_request" { + t.Errorf("error.code = %s, want invalid_request", errResp.Error.Code) + } + if errResp.Error.Message != "model is required" { + t.Errorf("error.message = %s, want model is required", errResp.Error.Message) + } + }) + } +}