Make gateway Open Responses compliant

This commit is contained in:
2026-03-02 04:21:29 +00:00
parent 47d517c913
commit 3e645a3525
6 changed files with 858 additions and 453 deletions

View File

@@ -3,7 +3,6 @@ package anthropic
import (
"context"
"fmt"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
@@ -60,8 +59,8 @@ func NewAzure(azureCfg config.AzureAnthropicConfig) *Provider {
func (p *Provider) Name() string { return Name }
// Generate routes the Open Responses request to Anthropic's API.
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
// Generate routes the request to Anthropic's API.
func (p *Provider) Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
if p.cfg.APIKey == "" {
return nil, fmt.Errorf("anthropic api key missing")
}
@@ -69,37 +68,40 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
return nil, fmt.Errorf("anthropic client not initialized")
}
model := chooseModel(req.Model, p.cfg.Model)
// Convert Open Responses messages to Anthropic format
messages := make([]anthropic.MessageParam, 0, len(req.Input))
// Convert messages to Anthropic format
anthropicMsgs := make([]anthropic.MessageParam, 0, len(messages))
var system string
for _, msg := range req.Input {
for _, msg := range messages {
var content string
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
content += block.Text
}
}
switch msg.Role {
case "user":
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
case "assistant":
messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
case "system":
anthropicMsgs = append(anthropicMsgs, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
case "system", "developer":
system = content
}
}
// Build request params
params := anthropic.MessageNewParams{
Model: anthropic.Model(model),
Messages: messages,
MaxTokens: int64(4096),
maxTokens := int64(4096)
if req.MaxOutputTokens != nil {
maxTokens = int64(*req.MaxOutputTokens)
}
params := anthropic.MessageNewParams{
Model: anthropic.Model(req.Model),
Messages: anthropicMsgs,
MaxTokens: maxTokens,
}
if system != "" {
systemBlocks := []anthropic.TextBlockParam{
{Text: system, Type: "text"},
@@ -107,36 +109,31 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
params.System = systemBlocks
}
if req.Temperature != nil {
params.Temperature = anthropic.Float(*req.Temperature)
}
if req.TopP != nil {
params.TopP = anthropic.Float(*req.TopP)
}
// Call Anthropic API
resp, err := p.client.Messages.New(ctx, params)
if err != nil {
return nil, fmt.Errorf("anthropic api error: %w", err)
}
// Convert Anthropic response to Open Responses format
output := make([]api.Message, 0, 1)
// Extract text from response
var text string
for _, block := range resp.Content {
if block.Type == "text" {
text += block.Text
}
}
output = append(output, api.Message{
Role: "assistant",
Content: []api.ContentBlock{
{Type: "output_text", Text: text},
},
})
return &api.Response{
ID: resp.ID,
Object: "response",
Created: time.Now().Unix(),
Model: string(resp.Model),
Provider: Name,
Output: output,
return &api.ProviderResult{
ID: resp.ID,
Model: string(resp.Model),
Text: text,
Usage: api.Usage{
InputTokens: int(resp.Usage.InputTokens),
OutputTokens: int(resp.Usage.OutputTokens),
@@ -146,12 +143,12 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
}
// GenerateStream handles streaming requests to Anthropic.
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
chunkChan := make(chan *api.StreamChunk)
func (p *Provider) GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
deltaChan := make(chan *api.ProviderStreamDelta)
errChan := make(chan error, 1)
go func() {
defer close(chunkChan)
defer close(deltaChan)
defer close(errChan)
if p.cfg.APIKey == "" {
@@ -163,37 +160,40 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
return
}
model := chooseModel(req.Model, p.cfg.Model)
// Convert messages
messages := make([]anthropic.MessageParam, 0, len(req.Input))
// Convert messages to Anthropic format
anthropicMsgs := make([]anthropic.MessageParam, 0, len(messages))
var system string
for _, msg := range req.Input {
for _, msg := range messages {
var content string
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
content += block.Text
}
}
switch msg.Role {
case "user":
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
case "assistant":
messages = append(messages, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
case "system":
system = content
}
}
}
// Build params
params := anthropic.MessageNewParams{
Model: anthropic.Model(model),
Messages: messages,
MaxTokens: int64(4096),
switch msg.Role {
case "user":
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
case "assistant":
anthropicMsgs = append(anthropicMsgs, anthropic.NewAssistantMessage(anthropic.NewTextBlock(content)))
case "system", "developer":
system = content
}
}
// Build params
maxTokens := int64(4096)
if req.MaxOutputTokens != nil {
maxTokens = int64(*req.MaxOutputTokens)
}
params := anthropic.MessageNewParams{
Model: anthropic.Model(req.Model),
Messages: anthropicMsgs,
MaxTokens: maxTokens,
}
if system != "" {
systemBlocks := []anthropic.TextBlockParam{
{Text: system, Type: "text"},
@@ -201,42 +201,28 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
params.System = systemBlocks
}
if req.Temperature != nil {
params.Temperature = anthropic.Float(*req.Temperature)
}
if req.TopP != nil {
params.TopP = anthropic.Float(*req.TopP)
}
// Create stream
stream := p.client.Messages.NewStreaming(ctx, params)
// Process stream
for stream.Next() {
event := stream.Current()
delta := &api.StreamDelta{}
var text string
// Handle different event types
if event.Type == "content_block_delta" && event.Delta.Type == "text_delta" {
text = event.Delta.Text
delta.Content = []api.ContentBlock{
{Type: "output_text", Text: text},
select {
case deltaChan <- &api.ProviderStreamDelta{Text: event.Delta.Text}:
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
if event.Type == "message_start" {
delta.Role = "assistant"
}
streamChunk := &api.StreamChunk{
Object: "response.chunk",
Created: time.Now().Unix(),
Model: string(model),
Provider: Name,
Delta: delta,
}
select {
case chunkChan <- streamChunk:
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
if err := stream.Err(); err != nil {
@@ -244,15 +230,15 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
return
}
// Send final chunk
// Send final delta
select {
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
case deltaChan <- &api.ProviderStreamDelta{Done: true}:
case <-ctx.Done():
errChan <- ctx.Err()
}
}()
return chunkChan, errChan
return deltaChan, errChan
}
func chooseModel(requested, defaultModel string) string {

View File

@@ -3,7 +3,6 @@ package google
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"google.golang.org/genai"
@@ -41,8 +40,8 @@ func New(cfg config.ProviderConfig) *Provider {
func (p *Provider) Name() string { return Name }
// Generate routes the Open Responses request to Gemini.
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
// Generate routes the request to Gemini and returns a ProviderResult.
func (p *Provider) Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
if p.cfg.APIKey == "" {
return nil, fmt.Errorf("google api key missing")
}
@@ -50,60 +49,18 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
return nil, fmt.Errorf("google client not initialized")
}
model := chooseModel(req.Model, p.cfg.Model)
model := req.Model
// Convert Open Responses messages to Gemini format
var contents []*genai.Content
var systemText string
for _, msg := range req.Input {
if msg.Role == "system" {
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
systemText += block.Text
}
}
continue
}
contents, systemText := convertMessages(messages)
var parts []*genai.Part
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
parts = append(parts, genai.NewPartFromText(block.Text))
}
}
role := "user"
if msg.Role == "assistant" || msg.Role == "model" {
role = "model"
}
contents = append(contents, &genai.Content{
Role: role,
Parts: parts,
})
}
config := buildConfig(systemText, req)
// Build config with system instruction if present
var config *genai.GenerateContentConfig
if systemText != "" {
config = &genai.GenerateContentConfig{
SystemInstruction: &genai.Content{
Parts: []*genai.Part{genai.NewPartFromText(systemText)},
},
}
}
// Generate content
resp, err := p.client.Models.GenerateContent(ctx, model, contents, config)
if err != nil {
return nil, fmt.Errorf("google api error: %w", err)
}
// Convert Gemini response to Open Responses format
output := make([]api.Message, 0, 1)
var text string
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
if part != nil {
@@ -111,28 +68,17 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
}
}
}
output = append(output, api.Message{
Role: "assistant",
Content: []api.ContentBlock{
{Type: "output_text", Text: text},
},
})
// Extract usage info if available
var inputTokens, outputTokens int
if resp.UsageMetadata != nil {
inputTokens = int(resp.UsageMetadata.PromptTokenCount)
outputTokens = int(resp.UsageMetadata.CandidatesTokenCount)
}
return &api.Response{
ID: uuid.NewString(),
Object: "response",
Created: time.Now().Unix(),
Model: model,
Provider: Name,
Output: output,
return &api.ProviderResult{
ID: uuid.NewString(),
Model: model,
Text: text,
Usage: api.Usage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
@@ -142,12 +88,12 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
}
// GenerateStream handles streaming requests to Google.
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
chunkChan := make(chan *api.StreamChunk)
func (p *Provider) GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
deltaChan := make(chan *api.ProviderStreamDelta)
errChan := make(chan error, 1)
go func() {
defer close(chunkChan)
defer close(deltaChan)
defer close(errChan)
if p.cfg.APIKey == "" {
@@ -159,54 +105,14 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
return
}
model := chooseModel(req.Model, p.cfg.Model)
model := req.Model
// Convert messages
var contents []*genai.Content
var systemText string
for _, msg := range req.Input {
if msg.Role == "system" {
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
systemText += block.Text
}
}
continue
}
contents, systemText := convertMessages(messages)
var parts []*genai.Part
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
parts = append(parts, genai.NewPartFromText(block.Text))
}
}
role := "user"
if msg.Role == "assistant" || msg.Role == "model" {
role = "model"
}
contents = append(contents, &genai.Content{
Role: role,
Parts: parts,
})
}
config := buildConfig(systemText, req)
// Build config with system instruction if present
var config *genai.GenerateContentConfig
if systemText != "" {
config = &genai.GenerateContentConfig{
SystemInstruction: &genai.Content{
Parts: []*genai.Part{genai.NewPartFromText(systemText)},
},
}
}
// Create stream
stream := p.client.Models.GenerateContentStream(ctx, model, contents, config)
// Process stream
for resp, err := range stream {
if err != nil {
errChan <- fmt.Errorf("google stream error: %w", err)
@@ -222,38 +128,94 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
}
}
delta := &api.StreamDelta{}
if text != "" {
delta.Content = []api.ContentBlock{
{Type: "output_text", Text: text},
select {
case deltaChan <- &api.ProviderStreamDelta{Text: text}:
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
streamChunk := &api.StreamChunk{
Object: "response.chunk",
Created: time.Now().Unix(),
Model: model,
Provider: Name,
Delta: delta,
}
select {
case chunkChan <- streamChunk:
case <-ctx.Done():
errChan <- ctx.Err()
return
}
}
// Send final chunk
select {
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
case deltaChan <- &api.ProviderStreamDelta{Done: true}:
case <-ctx.Done():
errChan <- ctx.Err()
}
}()
return chunkChan, errChan
return deltaChan, errChan
}
// convertMessages splits messages into Gemini contents and system text.
func convertMessages(messages []api.Message) ([]*genai.Content, string) {
var contents []*genai.Content
var systemText string
for _, msg := range messages {
if msg.Role == "system" || msg.Role == "developer" {
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
systemText += block.Text
}
}
continue
}
var parts []*genai.Part
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
parts = append(parts, genai.NewPartFromText(block.Text))
}
}
role := "user"
if msg.Role == "assistant" || msg.Role == "model" {
role = "model"
}
contents = append(contents, &genai.Content{
Role: role,
Parts: parts,
})
}
return contents, systemText
}
// buildConfig constructs a GenerateContentConfig from system text and request params.
func buildConfig(systemText string, req *api.ResponseRequest) *genai.GenerateContentConfig {
var cfg *genai.GenerateContentConfig
needsCfg := systemText != "" || req.MaxOutputTokens != nil || req.Temperature != nil || req.TopP != nil
if !needsCfg {
return nil
}
cfg = &genai.GenerateContentConfig{}
if systemText != "" {
cfg.SystemInstruction = &genai.Content{
Parts: []*genai.Part{genai.NewPartFromText(systemText)},
}
}
if req.MaxOutputTokens != nil {
cfg.MaxOutputTokens = int32(*req.MaxOutputTokens)
}
if req.Temperature != nil {
t := float32(*req.Temperature)
cfg.Temperature = &t
}
if req.TopP != nil {
tp := float32(*req.TopP)
cfg.TopP = &tp
}
return cfg
}
func chooseModel(requested, defaultModel string) string {

View File

@@ -3,7 +3,6 @@ package openai
import (
"context"
"fmt"
"time"
"github.com/openai/openai-go"
"github.com/openai/openai-go/azure"
@@ -64,8 +63,8 @@ func NewAzure(azureCfg config.AzureOpenAIConfig) *Provider {
// Name returns the provider identifier.
func (p *Provider) Name() string { return Name }
// Generate routes the Open Responses request to OpenAI.
func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error) {
// Generate routes the request to OpenAI and returns a ProviderResult.
func (p *Provider) Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
if p.cfg.APIKey == "" {
return nil, fmt.Errorf("openai api key missing")
}
@@ -73,55 +72,57 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
return nil, fmt.Errorf("openai client not initialized")
}
model := chooseModel(req.Model, p.cfg.Model)
// Convert Open Responses messages to OpenAI format
messages := make([]openai.ChatCompletionMessageParamUnion, 0, len(req.Input))
for _, msg := range req.Input {
// Convert messages to OpenAI format
oaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages))
for _, msg := range messages {
var content string
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
content += block.Text
}
}
switch msg.Role {
case "user":
messages = append(messages, openai.UserMessage(content))
oaiMessages = append(oaiMessages, openai.UserMessage(content))
case "assistant":
messages = append(messages, openai.AssistantMessage(content))
oaiMessages = append(oaiMessages, openai.AssistantMessage(content))
case "system":
messages = append(messages, openai.SystemMessage(content))
oaiMessages = append(oaiMessages, openai.SystemMessage(content))
case "developer":
oaiMessages = append(oaiMessages, openai.SystemMessage(content))
}
}
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(req.Model),
Messages: oaiMessages,
}
if req.MaxOutputTokens != nil {
params.MaxTokens = openai.Int(int64(*req.MaxOutputTokens))
}
if req.Temperature != nil {
params.Temperature = openai.Float(*req.Temperature)
}
if req.TopP != nil {
params.TopP = openai.Float(*req.TopP)
}
// Call OpenAI API
resp, err := p.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModel(model),
Messages: messages,
})
resp, err := p.client.Chat.Completions.New(ctx, params)
if err != nil {
return nil, fmt.Errorf("openai api error: %w", err)
}
// Convert OpenAI response to Open Responses format
output := make([]api.Message, 0, len(resp.Choices))
var combinedText string
for _, choice := range resp.Choices {
output = append(output, api.Message{
Role: "assistant",
Content: []api.ContentBlock{
{Type: "output_text", Text: choice.Message.Content},
},
})
combinedText += choice.Message.Content
}
return &api.Response{
ID: resp.ID,
Object: "response",
Created: time.Now().Unix(),
Model: resp.Model,
Provider: Name,
Output: output,
return &api.ProviderResult{
ID: resp.ID,
Model: resp.Model,
Text: combinedText,
Usage: api.Usage{
InputTokens: int(resp.Usage.PromptTokens),
OutputTokens: int(resp.Usage.CompletionTokens),
@@ -131,12 +132,12 @@ func (p *Provider) Generate(ctx context.Context, req *api.ResponseRequest) (*api
}
// GenerateStream handles streaming requests to OpenAI.
func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error) {
chunkChan := make(chan *api.StreamChunk)
func (p *Provider) GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
deltaChan := make(chan *api.ProviderStreamDelta)
errChan := make(chan error, 1)
go func() {
defer close(chunkChan)
defer close(deltaChan)
defer close(errChan)
if p.cfg.APIKey == "" {
@@ -148,62 +149,60 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
return
}
model := chooseModel(req.Model, p.cfg.Model)
// Convert messages
messages := make([]openai.ChatCompletionMessageParamUnion, 0, len(req.Input))
for _, msg := range req.Input {
// Convert messages to OpenAI format
oaiMessages := make([]openai.ChatCompletionMessageParamUnion, 0, len(messages))
for _, msg := range messages {
var content string
for _, block := range msg.Content {
if block.Type == "input_text" || block.Type == "output_text" {
content += block.Text
}
}
switch msg.Role {
case "user":
messages = append(messages, openai.UserMessage(content))
case "assistant":
messages = append(messages, openai.AssistantMessage(content))
case "system":
messages = append(messages, openai.SystemMessage(content))
}
}
}
// Create streaming request
stream := p.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Model: openai.ChatModel(model),
Messages: messages,
})
switch msg.Role {
case "user":
oaiMessages = append(oaiMessages, openai.UserMessage(content))
case "assistant":
oaiMessages = append(oaiMessages, openai.AssistantMessage(content))
case "system":
oaiMessages = append(oaiMessages, openai.SystemMessage(content))
case "developer":
oaiMessages = append(oaiMessages, openai.SystemMessage(content))
}
}
params := openai.ChatCompletionNewParams{
Model: openai.ChatModel(req.Model),
Messages: oaiMessages,
}
if req.MaxOutputTokens != nil {
params.MaxTokens = openai.Int(int64(*req.MaxOutputTokens))
}
if req.Temperature != nil {
params.Temperature = openai.Float(*req.Temperature)
}
if req.TopP != nil {
params.TopP = openai.Float(*req.TopP)
}
// Create streaming request
stream := p.client.Chat.Completions.NewStreaming(ctx, params)
// Process stream
for stream.Next() {
chunk := stream.Current()
for _, choice := range chunk.Choices {
delta := &api.StreamDelta{}
if choice.Delta.Role != "" {
delta.Role = string(choice.Delta.Role)
}
if choice.Delta.Content != "" {
delta.Content = []api.ContentBlock{
{Type: "output_text", Text: choice.Delta.Content},
}
}
streamChunk := &api.StreamChunk{
ID: chunk.ID,
Object: "response.chunk",
Created: time.Now().Unix(),
Model: chunk.Model,
Provider: Name,
Delta: delta,
for _, choice := range chunk.Choices {
if choice.Delta.Content == "" {
continue
}
select {
case chunkChan <- streamChunk:
case deltaChan <- &api.ProviderStreamDelta{
ID: chunk.ID,
Model: chunk.Model,
Text: choice.Delta.Content,
}:
case <-ctx.Done():
errChan <- ctx.Err()
return
@@ -216,15 +215,15 @@ func (p *Provider) GenerateStream(ctx context.Context, req *api.ResponseRequest)
return
}
// Send final chunk
// Send final delta
select {
case chunkChan <- &api.StreamChunk{Object: "response.chunk", Done: true}:
case deltaChan <- &api.ProviderStreamDelta{Done: true}:
case <-ctx.Done():
errChan <- ctx.Err()
}
}()
return chunkChan, errChan
return deltaChan, errChan
}
func chooseModel(requested, defaultModel string) string {

View File

@@ -14,8 +14,8 @@ import (
// Provider represents a unified interface that each LLM provider must implement.
type Provider interface {
Name() string
Generate(ctx context.Context, req *api.ResponseRequest) (*api.Response, error)
GenerateStream(ctx context.Context, req *api.ResponseRequest) (<-chan *api.StreamChunk, <-chan error)
Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error)
GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error)
}
// Registry keeps track of registered providers and model-to-provider mappings.