Skip to content

Commit 3f137f6

Browse files
committed
Add structured output support to Responses API
1 parent 3b3cb7a commit 3f137f6

8 files changed

Lines changed: 1121 additions & 28 deletions

File tree

e2e/inference_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,53 @@ func TestE2E_Inference(t *testing.T) {
106106
t.Logf("responses API: %q", resp.OutputText)
107107
})
108108

109+
t.Run("ResponsesAPIStructuredOutput", func(t *testing.T) {
110+
status, body := doJSON(t, http.MethodPost, serverURL+"/responses",
111+
map[string]any{
112+
"model": bc.model,
113+
"input": "Return a JSON object with answer set to yes.",
114+
"max_output_tokens": 64,
115+
"text": map[string]any{
116+
"format": map[string]any{
117+
"type": "json_schema",
118+
"name": "answer_payload",
119+
"strict": true,
120+
"schema": map[string]any{
121+
"type": "object",
122+
"properties": map[string]any{
123+
"answer": map[string]any{"type": "string"},
124+
},
125+
"required": []string{"answer"},
126+
"additionalProperties": false,
127+
},
128+
},
129+
},
130+
})
131+
if status != http.StatusOK {
132+
t.Fatalf("responses structured output: status=%d body=%s", status, body)
133+
}
134+
var resp struct {
135+
Status string `json:"status"`
136+
OutputText string `json:"output_text"`
137+
}
138+
if err := json.Unmarshal(body, &resp); err != nil {
139+
t.Fatalf("decode: %v (body=%s)", err, body)
140+
}
141+
if resp.Status != "completed" {
142+
t.Errorf("expected status=completed, got %q", resp.Status)
143+
}
144+
145+
var output struct {
146+
Answer string `json:"answer"`
147+
}
148+
if err := json.Unmarshal([]byte(resp.OutputText), &output); err != nil {
149+
t.Fatalf("output_text is not valid structured JSON: %v (output_text=%q)", err, resp.OutputText)
150+
}
151+
if output.Answer == "" {
152+
t.Fatalf("answer field is empty in output_text=%q", resp.OutputText)
153+
}
154+
})
155+
109156
t.Run("AnthropicMessages", func(t *testing.T) {
110157
status, body := doJSON(t, http.MethodPost, serverURL+"/anthropic/v1/messages",
111158
map[string]any{

pkg/responses/api.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,42 @@ type CreateRequest struct {
9191
// MaxOutputTokens limits the response length.
9292
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
9393

94+
// Store controls whether the response is stored for later retrieval.
95+
Store *bool `json:"store,omitempty"`
96+
97+
// Text configures text output formatting.
98+
Text *ResponseTextConfig `json:"text,omitempty"`
99+
100+
// Include requests additional hosted-tool output fields. Unsupported locally.
101+
Include []string `json:"include,omitempty"`
102+
94103
// Stream enables streaming responses.
95104
Stream bool `json:"stream,omitempty"`
96105

106+
// StreamOptions configures response streaming. Unsupported locally.
107+
StreamOptions json.RawMessage `json:"stream_options,omitempty"`
108+
109+
// TopLogprobs requests token log probabilities. Unsupported locally.
110+
TopLogprobs *int `json:"top_logprobs,omitempty"`
111+
112+
// Truncation configures OpenAI context truncation behavior.
113+
Truncation string `json:"truncation,omitempty"`
114+
115+
// Background requests asynchronous background response execution.
116+
Background *bool `json:"background,omitempty"`
117+
118+
// Conversation requests OpenAI hosted conversation state. Unsupported locally.
119+
Conversation json.RawMessage `json:"conversation,omitempty"`
120+
121+
// Prompt requests an OpenAI hosted prompt template. Unsupported locally.
122+
Prompt json.RawMessage `json:"prompt,omitempty"`
123+
124+
// ServiceTier selects OpenAI service tier. Unsupported locally.
125+
ServiceTier string `json:"service_tier,omitempty"`
126+
127+
// SafetyIdentifier is an OpenAI safety identifier. Unsupported locally.
128+
SafetyIdentifier string `json:"safety_identifier,omitempty"`
129+
97130
// Metadata is user-defined metadata for the response.
98131
Metadata map[string]string `json:"metadata,omitempty"`
99132

@@ -104,6 +137,21 @@ type CreateRequest struct {
104137
User string `json:"user,omitempty"`
105138
}
106139

140+
// ResponseTextConfig configures text output for a Responses API request.
141+
type ResponseTextConfig struct {
142+
Format *ResponseTextFormat `json:"format,omitempty"`
143+
Verbosity string `json:"verbosity,omitempty"`
144+
}
145+
146+
// ResponseTextFormat configures the output text format.
147+
type ResponseTextFormat struct {
148+
Type string `json:"type"`
149+
Name string `json:"name,omitempty"`
150+
Description string `json:"description,omitempty"`
151+
Schema json.RawMessage `json:"schema,omitempty"`
152+
Strict *bool `json:"strict,omitempty"`
153+
}
154+
107155
// Response represents a complete response from the API.
108156
type Response struct {
109157
// ID is the unique identifier for this response.
@@ -160,6 +208,12 @@ type Response struct {
160208
// MaxOutputTokens limit used.
161209
MaxOutputTokens *int `json:"max_output_tokens"`
162210

211+
// Store indicates whether the response was stored.
212+
Store *bool `json:"store,omitempty"`
213+
214+
// Text config used for the response.
215+
Text *ResponseTextConfig `json:"text,omitempty"`
216+
163217
// PreviousResponseID is the ID of the previous response in the chain.
164218
PreviousResponseID *string `json:"previous_response_id"`
165219

pkg/responses/handler.go

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ func NewHTTPHandler(log logging.Logger, schedulerHTTP http.Handler, allowedOrigi
4343
h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}", h.handleGet)
4444
h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}/input_items", h.handleListInputItems)
4545
h.router.HandleFunc("DELETE /v1"+APIPrefix+"/{id}", h.handleDelete)
46+
// Also register /engines/responses and /engines/v1/responses routes.
47+
h.router.HandleFunc("POST /engines"+APIPrefix, h.handleCreate)
48+
h.router.HandleFunc("GET /engines"+APIPrefix+"/{id}", h.handleGet)
49+
h.router.HandleFunc("GET /engines"+APIPrefix+"/{id}/input_items", h.handleListInputItems)
50+
h.router.HandleFunc("DELETE /engines"+APIPrefix+"/{id}", h.handleDelete)
51+
h.router.HandleFunc("POST /engines/v1"+APIPrefix, h.handleCreate)
52+
h.router.HandleFunc("GET /engines/v1"+APIPrefix+"/{id}", h.handleGet)
53+
h.router.HandleFunc("GET /engines/v1"+APIPrefix+"/{id}/input_items", h.handleListInputItems)
54+
h.router.HandleFunc("DELETE /engines/v1"+APIPrefix+"/{id}", h.handleDelete)
55+
h.router.HandleFunc("POST /engines/{backend}/v1"+APIPrefix, h.handleCreate)
56+
h.router.HandleFunc("GET /engines/{backend}/v1"+APIPrefix+"/{id}", h.handleGet)
57+
h.router.HandleFunc("GET /engines/{backend}/v1"+APIPrefix+"/{id}/input_items", h.handleListInputItems)
58+
h.router.HandleFunc("DELETE /engines/{backend}/v1"+APIPrefix+"/{id}", h.handleDelete)
4659

4760
// Apply CORS middleware
4861
h.httpHandler = middleware.CorsMiddleware(allowedOrigins, h.router)
@@ -97,14 +110,21 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
97110
h.sendError(w, http.StatusBadRequest, "invalid_request", "model is required")
98111
return
99112
}
113+
if err := validateUnsupportedRequestFields(&req); err != nil {
114+
h.sendError(w, http.StatusBadRequest, "invalid_request", err.Error())
115+
return
116+
}
100117

101118
// Create a new response
102119
respID := GenerateResponseID()
103120
resp := NewResponse(respID, req.Model)
121+
store := shouldStore(&req)
104122
resp.Instructions = nilIfEmpty(req.Instructions)
105123
resp.Temperature = req.Temperature
106124
resp.TopP = req.TopP
107125
resp.MaxOutputTokens = req.MaxOutputTokens
126+
resp.Store = &store
127+
resp.Text = req.Text
108128
resp.Tools = req.Tools
109129
resp.ToolChoice = req.ToolChoice
110130
resp.ParallelToolCalls = req.ParallelToolCalls
@@ -134,7 +154,7 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
134154
}
135155

136156
// Create upstream request
137-
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "/engines/v1/chat/completions", bytes.NewReader(chatBody))
157+
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, chatCompletionPathForRequest(r), bytes.NewReader(chatBody))
138158
if err != nil {
139159
h.sendError(w, http.StatusInternalServerError, "internal_error", "Failed to create request")
140160
return
@@ -147,24 +167,29 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
147167

148168
if req.Stream {
149169
// Handle streaming response
150-
h.handleStreaming(w, upstreamReq, resp)
170+
h.handleStreaming(w, upstreamReq, resp, store)
151171
} else {
152172
// Handle non-streaming response
153-
h.handleNonStreaming(w, upstreamReq, resp)
173+
h.handleNonStreaming(w, upstreamReq, resp, store)
154174
}
155175
}
156176

157177
// handleStreaming handles streaming responses.
158-
func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) {
178+
func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) {
179+
responseStore := h.store
180+
if !store {
181+
responseStore = nil
182+
}
183+
159184
// Create streaming writer
160-
streamWriter := NewStreamingResponseWriter(w, resp, h.store)
185+
streamWriter := NewStreamingResponseWriter(w, resp, responseStore)
161186

162187
// Forward to scheduler
163188
h.schedulerHTTP.ServeHTTP(streamWriter, upstreamReq)
164189
}
165190

166191
// handleNonStreaming handles non-streaming responses.
167-
func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) {
192+
func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) {
168193
// Capture upstream response
169194
capture := NewNonStreamingResponseCapture()
170195

@@ -187,7 +212,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
187212
Code: errResp.Error.Code,
188213
Message: errResp.Error.Message,
189214
}
190-
h.store.Save(resp)
215+
if store {
216+
h.store.Save(resp)
217+
}
191218
h.sendJSON(w, capture.StatusCode, resp)
192219
return
193220
}
@@ -197,7 +224,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
197224
Code: "upstream_error",
198225
Message: capture.Body.String(),
199226
}
200-
h.store.Save(resp)
227+
if store {
228+
h.store.Save(resp)
229+
}
201230
h.sendJSON(w, capture.StatusCode, resp)
202231
return
203232
}
@@ -210,7 +239,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
210239
Code: "parse_error",
211240
Message: "Failed to parse upstream response",
212241
}
213-
h.store.Save(resp)
242+
if store {
243+
h.store.Save(resp)
244+
}
214245
h.sendJSON(w, http.StatusInternalServerError, resp)
215246
return
216247
}
@@ -222,6 +253,8 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
222253
finalResp.Temperature = resp.Temperature
223254
finalResp.TopP = resp.TopP
224255
finalResp.MaxOutputTokens = resp.MaxOutputTokens
256+
finalResp.Store = resp.Store
257+
finalResp.Text = resp.Text
225258
finalResp.Tools = resp.Tools
226259
finalResp.ToolChoice = resp.ToolChoice
227260
finalResp.ParallelToolCalls = resp.ParallelToolCalls
@@ -232,7 +265,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
232265
finalResp.CreatedAt = resp.CreatedAt
233266

234267
// Store the response
235-
h.store.Save(finalResp)
268+
if store {
269+
h.store.Save(finalResp)
270+
}
236271

237272
// Send response
238273
h.sendJSON(w, http.StatusOK, finalResp)
@@ -332,6 +367,54 @@ func nilIfEmpty(s string) *string {
332367
return &s
333368
}
334369

370+
func shouldStore(req *CreateRequest) bool {
371+
return req.Store == nil || *req.Store
372+
}
373+
374+
func chatCompletionPathForRequest(r *http.Request) string {
375+
if backend := r.PathValue("backend"); backend != "" {
376+
return "/engines/" + backend + "/v1/chat/completions"
377+
}
378+
return "/engines/v1/chat/completions"
379+
}
380+
381+
func validateUnsupportedRequestFields(req *CreateRequest) error {
382+
if len(req.Include) > 0 {
383+
return fmt.Errorf("include is not supported by Docker Model Runner Responses compatibility layer")
384+
}
385+
if req.StreamOptions != nil && !isNullJSON(req.StreamOptions) {
386+
return fmt.Errorf("stream_options is not supported by Docker Model Runner Responses compatibility layer")
387+
}
388+
if req.TopLogprobs != nil {
389+
return fmt.Errorf("top_logprobs is not supported by Docker Model Runner Responses compatibility layer")
390+
}
391+
switch req.Truncation {
392+
case "", "disabled":
393+
default:
394+
return fmt.Errorf("truncation value %q is not supported by Docker Model Runner Responses compatibility layer", req.Truncation)
395+
}
396+
if req.Background != nil && *req.Background {
397+
return fmt.Errorf("background responses are not supported by Docker Model Runner Responses compatibility layer")
398+
}
399+
if req.Conversation != nil && !isNullJSON(req.Conversation) {
400+
return fmt.Errorf("conversation is not supported by Docker Model Runner Responses compatibility layer; use previous_response_id instead")
401+
}
402+
if req.Prompt != nil && !isNullJSON(req.Prompt) {
403+
return fmt.Errorf("prompt is not supported by Docker Model Runner Responses compatibility layer")
404+
}
405+
if req.ServiceTier != "" {
406+
return fmt.Errorf("service_tier is not supported by Docker Model Runner Responses compatibility layer")
407+
}
408+
if req.SafetyIdentifier != "" {
409+
return fmt.Errorf("safety_identifier is not supported by Docker Model Runner Responses compatibility layer")
410+
}
411+
return nil
412+
}
413+
414+
func isNullJSON(raw json.RawMessage) bool {
415+
return strings.TrimSpace(string(raw)) == "null"
416+
}
417+
335418
// GetStore returns the response store (for testing).
336419
func (h *HTTPHandler) GetStore() *Store {
337420
return h.store

0 commit comments

Comments
 (0)