Make gateway Open Responses compliant
This commit is contained in:
@@ -1,81 +1,307 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ResponseRequest models the Open Responses create request payload.
|
||||
// ============================================================
|
||||
// Request Types (CreateResponseBody)
|
||||
// ============================================================
|
||||
|
||||
// ResponseRequest models the OpenResponses CreateResponseBody.
|
||||
type ResponseRequest struct {
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Input InputUnion `json:"input"`
|
||||
Instructions *string `json:"instructions,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Input []Message `json:"input"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||
PreviousResponseID *string `json:"previous_response_id,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||
TopLogprobs *int `json:"top_logprobs,omitempty"`
|
||||
Truncation *string `json:"truncation,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Tools json.RawMessage `json:"tools,omitempty"`
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Store *bool `json:"store,omitempty"`
|
||||
Text json.RawMessage `json:"text,omitempty"`
|
||||
Reasoning json.RawMessage `json:"reasoning,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
ServiceTier *string `json:"service_tier,omitempty"`
|
||||
Background *bool `json:"background,omitempty"`
|
||||
StreamOptions json.RawMessage `json:"stream_options,omitempty"`
|
||||
MaxToolCalls *int `json:"max_tool_calls,omitempty"`
|
||||
|
||||
// Non-spec extension: allows client to select a specific provider.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
// Message captures user, assistant, or system roles.
|
||||
// InputUnion handles the polymorphic "input" field: string or []InputItem.
|
||||
type InputUnion struct {
|
||||
String *string
|
||||
Items []InputItem
|
||||
}
|
||||
|
||||
func (u *InputUnion) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
u.String = &s
|
||||
return nil
|
||||
}
|
||||
var items []InputItem
|
||||
if err := json.Unmarshal(data, &items); err == nil {
|
||||
u.Items = items
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("input must be a string or array of items")
|
||||
}
|
||||
|
||||
func (u InputUnion) MarshalJSON() ([]byte, error) {
|
||||
if u.String != nil {
|
||||
return json.Marshal(*u.String)
|
||||
}
|
||||
if u.Items != nil {
|
||||
return json.Marshal(u.Items)
|
||||
}
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
// InputItem is a discriminated union on "type".
|
||||
// Valid types: message, item_reference, function_call, function_call_output, reasoning.
|
||||
type InputItem struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Internal Types (providers + conversation store)
|
||||
// ============================================================
|
||||
|
||||
// Message is the normalized internal message representation.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
}
|
||||
|
||||
// ContentBlock represents a typed content element (text, data, tool call, etc.).
|
||||
// ContentBlock is a typed content element.
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// Response is a simplified Open Responses response payload.
|
||||
// NormalizeInput converts the request Input into messages for providers.
|
||||
// Does NOT include instructions (the server prepends those separately).
|
||||
func (r *ResponseRequest) NormalizeInput() []Message {
|
||||
if r.Input.String != nil {
|
||||
return []Message{{
|
||||
Role: "user",
|
||||
Content: []ContentBlock{{Type: "input_text", Text: *r.Input.String}},
|
||||
}}
|
||||
}
|
||||
|
||||
var msgs []Message
|
||||
for _, item := range r.Input.Items {
|
||||
switch item.Type {
|
||||
case "message", "":
|
||||
msg := Message{Role: item.Role}
|
||||
if item.Content != nil {
|
||||
var s string
|
||||
if err := json.Unmarshal(item.Content, &s); err == nil {
|
||||
contentType := "input_text"
|
||||
if item.Role == "assistant" {
|
||||
contentType = "output_text"
|
||||
}
|
||||
msg.Content = []ContentBlock{{Type: contentType, Text: s}}
|
||||
} else {
|
||||
var blocks []ContentBlock
|
||||
_ = json.Unmarshal(item.Content, &blocks)
|
||||
msg.Content = blocks
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, msg)
|
||||
case "function_call_output":
|
||||
msgs = append(msgs, Message{
|
||||
Role: "tool",
|
||||
Content: []ContentBlock{{Type: "input_text", Text: item.Output}},
|
||||
})
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Response Types (ResponseResource)
|
||||
// ============================================================
|
||||
|
||||
// Response is the spec-compliant ResponseResource.
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
Output []Message `json:"output"`
|
||||
Usage Usage `json:"usage"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CompletedAt *int64 `json:"completed_at"`
|
||||
Status string `json:"status"`
|
||||
IncompleteDetails *IncompleteDetails `json:"incomplete_details"`
|
||||
Model string `json:"model"`
|
||||
PreviousResponseID *string `json:"previous_response_id"`
|
||||
Instructions *string `json:"instructions"`
|
||||
Output []OutputItem `json:"output"`
|
||||
Error *ResponseError `json:"error"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice"`
|
||||
Truncation string `json:"truncation"`
|
||||
ParallelToolCalls bool `json:"parallel_tool_calls"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
TopP float64 `json:"top_p"`
|
||||
PresencePenalty float64 `json:"presence_penalty"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty"`
|
||||
TopLogprobs int `json:"top_logprobs"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Reasoning json.RawMessage `json:"reasoning"`
|
||||
Usage *Usage `json:"usage"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens"`
|
||||
MaxToolCalls *int `json:"max_tool_calls"`
|
||||
Store bool `json:"store"`
|
||||
Background bool `json:"background"`
|
||||
ServiceTier string `json:"service_tier"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
SafetyIdentifier *string `json:"safety_identifier"`
|
||||
PromptCacheKey *string `json:"prompt_cache_key"`
|
||||
|
||||
// Non-spec extension
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
// Usage captures token accounting.
|
||||
// OutputItem represents a typed item in the response output.
|
||||
type OutputItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []ContentPart `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// ContentPart is a content block within an output item.
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Annotations []Annotation `json:"annotations"`
|
||||
}
|
||||
|
||||
// Annotation on output text content.
|
||||
type Annotation struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// IncompleteDetails explains why a response is incomplete.
|
||||
type IncompleteDetails struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ResponseError describes an error in the response.
|
||||
type ResponseError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code *string `json:"code"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Usage Types
|
||||
// ============================================================
|
||||
|
||||
// Usage captures token accounting with sub-details.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
InputTokensDetails InputTokensDetails `json:"input_tokens_details"`
|
||||
OutputTokensDetails OutputTokensDetails `json:"output_tokens_details"`
|
||||
}
|
||||
|
||||
// StreamChunk represents a single Server-Sent Event in a streaming response.
|
||||
type StreamChunk struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Delta *StreamDelta `json:"delta,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
// InputTokensDetails breaks down input token usage.
|
||||
type InputTokensDetails struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
}
|
||||
|
||||
// StreamDelta represents incremental content in a stream chunk.
|
||||
type StreamDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content []ContentBlock `json:"content,omitempty"`
|
||||
// OutputTokensDetails breaks down output token usage.
|
||||
type OutputTokensDetails struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Streaming Types
|
||||
// ============================================================
|
||||
|
||||
// StreamEvent represents a single SSE event in the streaming response.
|
||||
// Fields are selectively populated based on the event Type.
|
||||
type StreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
SequenceNumber int `json:"sequence_number"`
|
||||
Response *Response `json:"response,omitempty"`
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
Item *OutputItem `json:"item,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"`
|
||||
ContentIndex *int `json:"content_index,omitempty"`
|
||||
Part *ContentPart `json:"part,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Result Types (internal, not exposed via HTTP)
|
||||
// ============================================================
|
||||
|
||||
// ProviderResult is returned by Provider.Generate.
|
||||
type ProviderResult struct {
|
||||
ID string
|
||||
Model string
|
||||
Text string
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
// ProviderStreamDelta is sent through the stream channel.
|
||||
type ProviderStreamDelta struct {
|
||||
ID string
|
||||
Model string
|
||||
Text string
|
||||
Done bool
|
||||
Usage *Usage
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Models Endpoint Types
|
||||
// ============================================================
|
||||
|
||||
// ModelInfo describes a single model available through the gateway.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
// ModelsResponse is returned by the GET /v1/models endpoint.
|
||||
// ModelsResponse is returned by GET /v1/models.
|
||||
type ModelsResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelInfo `json:"data"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validation
|
||||
// ============================================================
|
||||
|
||||
// Validate performs basic structural validation.
|
||||
func (r *ResponseRequest) Validate() error {
|
||||
if r == nil {
|
||||
@@ -84,16 +310,8 @@ func (r *ResponseRequest) Validate() error {
|
||||
if r.Model == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
if len(r.Input) == 0 {
|
||||
return errors.New("input messages are required")
|
||||
}
|
||||
for i, msg := range r.Input {
|
||||
if msg.Role == "" {
|
||||
return fmt.Errorf("input[%d] role is required", i)
|
||||
}
|
||||
if len(msg.Content) == 0 {
|
||||
return fmt.Errorf("input[%d] content is required", i)
|
||||
}
|
||||
if r.Input.String == nil && len(r.Input.Items) == 0 {
|
||||
return errors.New("input is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user