feat: add OpenAI Responses API support (POST /v1/responses)
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

Add full Responses API endpoint alongside existing Chat Completions,
with identical logging/tracking/cost pipeline.

New:
- internal/models/responses.go — request/response/stream types + ToUsage() bridge
- internal/providers/openai_responses.go — OpenAI Responses/ResponsesStream

Modified:
- provider.go — Responses()+ResponsesStream() added to Provider interface
- helpers.go — BuildOpenAIResponsesBody, parsers, SSE stream reader
- circuit_breaker.go — CB wraps Responses, passthrough for stream
- server.go — POST /v1/responses route + handleResponses handler
- all non-OpenAI providers — stub methods with clear error messages

Logging: ResponsesUsage.ToUsage() bridges to models.Usage, feeding same
logRequest() -> DB insert -> dashboard WS -> client stats -> cost calc
pipeline. No schema or logger changes needed.
This commit is contained in:
2026-05-02 16:38:17 -04:00
parent eb67287b56
commit e5ef39f327
11 changed files with 507 additions and 4 deletions
+127
View File
@@ -133,6 +133,133 @@ func BuildOpenAIBody(request *models.UnifiedRequest, messagesJSON []interface{},
return body
}
// BuildOpenAIResponsesBody builds the request body for the Responses API endpoint.
func BuildOpenAIResponsesBody(req *models.ResponsesRequest, stream bool) map[string]interface{} {
body := map[string]interface{}{
"model": req.Model,
"stream": stream,
}
// The input field can be a string or a structured array.
// Try to preserve the original format.
if req.Input != nil {
// Try as string first
var inputStr string
if err := json.Unmarshal(req.Input, &inputStr); err == nil {
body["input"] = inputStr
} else {
// Try as array of messages
var inputArr []interface{}
if err := json.Unmarshal(req.Input, &inputArr); err == nil {
body["input"] = inputArr
}
}
}
if req.Instructions != "" {
body["instructions"] = req.Instructions
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
if req.MaxOutputTokens != nil {
body["max_output_tokens"] = *req.MaxOutputTokens
}
if req.TopP != nil {
body["top_p"] = *req.TopP
}
if req.Tools != nil {
var tools interface{}
if err := json.Unmarshal(req.Tools, &tools); err == nil {
body["tools"] = tools
}
}
if req.ToolChoice != nil {
var toolChoice interface{}
if err := json.Unmarshal(req.ToolChoice, &toolChoice); err == nil {
body["tool_choice"] = toolChoice
}
}
if req.Store != nil {
body["store"] = *req.Store
}
if stream {
body["stream_options"] = map[string]interface{}{
"include_usage": true,
}
}
return body
}
// ParseOpenAIResponsesResponse parses a raw JSON map into a ResponsesResponse.
func ParseOpenAIResponsesResponse(respJSON map[string]interface{}, model string) (*models.ResponsesResponse, error) {
data, err := json.Marshal(respJSON)
if err != nil {
return nil, err
}
var resp models.ResponsesResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
// Re-parse usage with the detailed tokens
if usageData, ok := respJSON["usage"]; ok {
var responsesUsage models.ResponsesUsage
usageBytes, _ := json.Marshal(usageData)
if err := json.Unmarshal(usageBytes, &responsesUsage); err == nil {
resp.Usage = &responsesUsage
}
}
return &resp, nil
}
// ParseOpenAIResponsesStreamChunk parses a single SSE line into a ResponsesStreamChunk.
// Returns the chunk, whether this is the [DONE] signal, and any error.
func ParseOpenAIResponsesStreamChunk(line string) (*models.ResponsesStreamChunk, bool, error) {
if line == "" {
return nil, false, nil
}
if !strings.HasPrefix(line, "data: ") {
return nil, false, nil
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return nil, true, nil
}
var chunk models.ResponsesStreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, false, fmt.Errorf("failed to unmarshal responses stream chunk: %w", err)
}
return &chunk, false, nil
}
// StreamOpenAIResponses reads SSE chunks from the body and sends them to the channel.
func StreamOpenAIResponses(ctx io.ReadCloser, ch chan<- *models.ResponsesStreamChunk) error {
defer ctx.Close()
scanner := bufio.NewScanner(ctx)
for scanner.Scan() {
line := scanner.Text()
chunk, done, err := ParseOpenAIResponsesStreamChunk(line)
if err != nil {
return err
}
if done {
break
}
if chunk != nil {
ch <- chunk
}
}
return scanner.Err()
}
type openAIUsage struct {
PromptTokens uint32 `json:"prompt_tokens"`
CompletionTokens uint32 `json:"completion_tokens"`