feat: add OpenAI Responses API support (POST /v1/responses)
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:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user