Skip to content

Commit 4dc1805

Browse files
authored
Merge pull request #1010 from dmedovich/issue-905-responses-api
Add structured output support to Responses API
2 parents 3b8b390 + 8ff5ef6 commit 4dc1805

8 files changed

Lines changed: 1130 additions & 38 deletions

File tree

e2e/inference_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,59 @@ 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{
124+
"type": "string",
125+
"enum": []string{"yes"},
126+
},
127+
},
128+
"required": []string{"answer"},
129+
"additionalProperties": false,
130+
},
131+
},
132+
},
133+
})
134+
if status != http.StatusOK {
135+
t.Fatalf("responses structured output: status=%d body=%s", status, body)
136+
}
137+
var resp struct {
138+
Status string `json:"status"`
139+
OutputText string `json:"output_text"`
140+
}
141+
if err := json.Unmarshal(body, &resp); err != nil {
142+
t.Fatalf("decode: %v (body=%s)", err, body)
143+
}
144+
if resp.Status != "completed" {
145+
t.Errorf("expected status=completed, got %q", resp.Status)
146+
}
147+
148+
var output struct {
149+
Answer string `json:"answer"`
150+
}
151+
if err := json.Unmarshal([]byte(resp.OutputText), &output); err != nil {
152+
t.Fatalf("output_text is not valid structured JSON: %v (output_text=%q)", err, resp.OutputText)
153+
}
154+
if output.Answer == "" {
155+
t.Fatalf("answer field is empty in output_text=%q", resp.OutputText)
156+
}
157+
if output.Answer != "yes" {
158+
t.Fatalf("answer = %q, want yes (output_text=%q)", output.Answer, resp.OutputText)
159+
}
160+
})
161+
109162
t.Run("AnthropicMessages", func(t *testing.T) {
110163
status, body := doJSON(t, http.MethodPost, serverURL+"/anthropic/v1/messages",
111164
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: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,25 @@ func NewHTTPHandler(log logging.Logger, schedulerHTTP http.Handler, allowedOrigi
3333
maxRequestBodyBytes: 10 * 1024 * 1024, // Default to 10MB
3434
}
3535

36-
// Register routes
37-
h.router.HandleFunc("POST "+APIPrefix, h.handleCreate)
38-
h.router.HandleFunc("GET "+APIPrefix+"/{id}", h.handleGet)
39-
h.router.HandleFunc("GET "+APIPrefix+"/{id}/input_items", h.handleListInputItems)
40-
h.router.HandleFunc("DELETE "+APIPrefix+"/{id}", h.handleDelete)
41-
// Also register /v1/responses routes
42-
h.router.HandleFunc("POST /v1"+APIPrefix, h.handleCreate)
43-
h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}", h.handleGet)
44-
h.router.HandleFunc("GET /v1"+APIPrefix+"/{id}/input_items", h.handleListInputItems)
45-
h.router.HandleFunc("DELETE /v1"+APIPrefix+"/{id}", h.handleDelete)
36+
h.registerRoutes(APIPrefix)
37+
h.registerRoutes("/v1" + APIPrefix)
38+
h.registerRoutes("/engines" + APIPrefix)
39+
h.registerRoutes("/engines/v1" + APIPrefix)
40+
h.registerRoutes("/engines/{backend}/v1" + APIPrefix)
4641

4742
// Apply CORS middleware
4843
h.httpHandler = middleware.CorsMiddleware(allowedOrigins, h.router)
4944

5045
return h
5146
}
5247

48+
func (h *HTTPHandler) registerRoutes(prefix string) {
49+
h.router.HandleFunc("POST "+prefix, h.handleCreate)
50+
h.router.HandleFunc("GET "+prefix+"/{id}", h.handleGet)
51+
h.router.HandleFunc("GET "+prefix+"/{id}/input_items", h.handleListInputItems)
52+
h.router.HandleFunc("DELETE "+prefix+"/{id}", h.handleDelete)
53+
}
54+
5355
// Close releases resources held by the handler, including the background
5456
// store cleanup goroutine. It should be called when the handler is shut down.
5557
func (h *HTTPHandler) Close() {
@@ -97,14 +99,21 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
9799
h.sendError(w, http.StatusBadRequest, "invalid_request", "model is required")
98100
return
99101
}
102+
if err := validateUnsupportedRequestFields(&req); err != nil {
103+
h.sendError(w, http.StatusBadRequest, "invalid_request", err.Error())
104+
return
105+
}
100106

101107
// Create a new response
102108
respID := GenerateResponseID()
103109
resp := NewResponse(respID, req.Model)
110+
store := shouldStore(&req)
104111
resp.Instructions = nilIfEmpty(req.Instructions)
105112
resp.Temperature = req.Temperature
106113
resp.TopP = req.TopP
107114
resp.MaxOutputTokens = req.MaxOutputTokens
115+
resp.Store = &store
116+
resp.Text = req.Text
108117
resp.Tools = req.Tools
109118
resp.ToolChoice = req.ToolChoice
110119
resp.ParallelToolCalls = req.ParallelToolCalls
@@ -134,7 +143,7 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
134143
}
135144

136145
// Create upstream request
137-
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, "/engines/v1/chat/completions", bytes.NewReader(chatBody))
146+
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, chatCompletionPathForRequest(r), bytes.NewReader(chatBody))
138147
if err != nil {
139148
h.sendError(w, http.StatusInternalServerError, "internal_error", "Failed to create request")
140149
return
@@ -147,24 +156,29 @@ func (h *HTTPHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
147156

148157
if req.Stream {
149158
// Handle streaming response
150-
h.handleStreaming(w, upstreamReq, resp)
159+
h.handleStreaming(w, upstreamReq, resp, store)
151160
} else {
152161
// Handle non-streaming response
153-
h.handleNonStreaming(w, upstreamReq, resp)
162+
h.handleNonStreaming(w, upstreamReq, resp, store)
154163
}
155164
}
156165

157166
// handleStreaming handles streaming responses.
158-
func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) {
167+
func (h *HTTPHandler) handleStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) {
168+
responseStore := h.store
169+
if !store {
170+
responseStore = nil
171+
}
172+
159173
// Create streaming writer
160-
streamWriter := NewStreamingResponseWriter(w, resp, h.store)
174+
streamWriter := NewStreamingResponseWriter(w, resp, responseStore)
161175

162176
// Forward to scheduler
163177
h.schedulerHTTP.ServeHTTP(streamWriter, upstreamReq)
164178
}
165179

166180
// handleNonStreaming handles non-streaming responses.
167-
func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response) {
181+
func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *http.Request, resp *Response, store bool) {
168182
// Capture upstream response
169183
capture := NewNonStreamingResponseCapture()
170184

@@ -187,7 +201,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
187201
Code: errResp.Error.Code,
188202
Message: errResp.Error.Message,
189203
}
190-
h.store.Save(resp)
204+
if store {
205+
h.store.Save(resp)
206+
}
191207
h.sendJSON(w, capture.StatusCode, resp)
192208
return
193209
}
@@ -197,7 +213,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
197213
Code: "upstream_error",
198214
Message: capture.Body.String(),
199215
}
200-
h.store.Save(resp)
216+
if store {
217+
h.store.Save(resp)
218+
}
201219
h.sendJSON(w, capture.StatusCode, resp)
202220
return
203221
}
@@ -210,7 +228,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
210228
Code: "parse_error",
211229
Message: "Failed to parse upstream response",
212230
}
213-
h.store.Save(resp)
231+
if store {
232+
h.store.Save(resp)
233+
}
214234
h.sendJSON(w, http.StatusInternalServerError, resp)
215235
return
216236
}
@@ -222,6 +242,8 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
222242
finalResp.Temperature = resp.Temperature
223243
finalResp.TopP = resp.TopP
224244
finalResp.MaxOutputTokens = resp.MaxOutputTokens
245+
finalResp.Store = resp.Store
246+
finalResp.Text = resp.Text
225247
finalResp.Tools = resp.Tools
226248
finalResp.ToolChoice = resp.ToolChoice
227249
finalResp.ParallelToolCalls = resp.ParallelToolCalls
@@ -232,7 +254,9 @@ func (h *HTTPHandler) handleNonStreaming(w http.ResponseWriter, upstreamReq *htt
232254
finalResp.CreatedAt = resp.CreatedAt
233255

234256
// Store the response
235-
h.store.Save(finalResp)
257+
if store {
258+
h.store.Save(finalResp)
259+
}
236260

237261
// Send response
238262
h.sendJSON(w, http.StatusOK, finalResp)
@@ -332,6 +356,58 @@ func nilIfEmpty(s string) *string {
332356
return &s
333357
}
334358

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

0 commit comments

Comments
 (0)