From badce93d9a4f221854a6e995e099212e30953b87 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Wed, 25 Feb 2026 17:30:24 +0100 Subject: [PATCH] refactor: extract duplicated Responses API adapters into shared helpers The three Responses-to-Chat conversion helpers were byte-for-byte identical across Gemini, Groq, and Ollama. Extract them into providers.ResponsesViaChat/StreamResponsesViaChat so each provider's Responses methods become one-line delegations, and consolidate the duplicated unit tests into a single test file. Co-Authored-By: Claude Opus 4.6 --- internal/providers/gemini/gemini.go | 128 +-------- internal/providers/gemini/gemini_test.go | 127 --------- internal/providers/groq/groq.go | 130 +-------- internal/providers/groq/groq_test.go | 272 ------------------ internal/providers/ollama/ollama.go | 130 +-------- internal/providers/ollama/ollama_test.go | 272 ------------------ internal/providers/responses_adapter.go | 146 ++++++++++ internal/providers/responses_adapter_test.go | 280 +++++++++++++++++++ 8 files changed, 432 insertions(+), 1053 deletions(-) create mode 100644 internal/providers/responses_adapter.go create mode 100644 internal/providers/responses_adapter_test.go diff --git a/internal/providers/gemini/gemini.go b/internal/providers/gemini/gemini.go index d47e2394..f7e8299d 100644 --- a/internal/providers/gemini/gemini.go +++ b/internal/providers/gemini/gemini.go @@ -8,8 +8,6 @@ import ( "strings" "time" - "github.com/google/uuid" - "gomodel/internal/core" "gomodel/internal/llmclient" "gomodel/internal/providers" @@ -198,134 +196,12 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) }, nil } -// convertResponsesRequestToChat converts a ResponsesRequest to ChatRequest for Gemini -func convertResponsesRequestToChat(req *core.ResponsesRequest) *core.ChatRequest { - chatReq := &core.ChatRequest{ - Model: req.Model, - Messages: make([]core.Message, 0), - Temperature: req.Temperature, - Stream: req.Stream, - } - - if req.MaxOutputTokens != nil { - chatReq.MaxTokens = req.MaxOutputTokens - } - - // Add system instruction if provided - if req.Instructions != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "system", - Content: req.Instructions, - }) - } - - // Convert input to messages - switch input := req.Input.(type) { - case string: - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "user", - Content: input, - }) - case []interface{}: - for _, item := range input { - if msgMap, ok := item.(map[string]interface{}); ok { - role, _ := msgMap["role"].(string) - content := extractContentFromInput(msgMap["content"]) - if role != "" && content != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: role, - Content: content, - }) - } - } - } - } - - return chatReq -} - -// extractContentFromInput extracts text content from responses input -func extractContentFromInput(content interface{}) string { - switch c := content.(type) { - case string: - return c - case []interface{}: - // Array of content parts - extract text - var texts []string - for _, part := range c { - if partMap, ok := part.(map[string]interface{}); ok { - if text, ok := partMap["text"].(string); ok { - texts = append(texts, text) - } - } - } - return strings.Join(texts, " ") - } - return "" -} - -// convertChatResponseToResponses converts a ChatResponse to ResponsesResponse -func convertChatResponseToResponses(resp *core.ChatResponse) *core.ResponsesResponse { - content := "" - if len(resp.Choices) > 0 { - content = resp.Choices[0].Message.Content - } - - return &core.ResponsesResponse{ - ID: resp.ID, - Object: "response", - CreatedAt: resp.Created, - Model: resp.Model, - Provider: resp.Provider, - Status: "completed", - Output: []core.ResponsesOutputItem{ - { - ID: "msg_" + uuid.New().String(), - Type: "message", - Role: "assistant", - Status: "completed", - Content: []core.ResponsesContentItem{ - { - Type: "output_text", - Text: content, - Annotations: []string{}, - }, - }, - }, - }, - Usage: &core.ResponsesUsage{ - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - }, - } -} - // Responses sends a Responses API request to Gemini (converted to chat format) func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - - // Use the existing ChatCompletion method - chatResp, err := p.ChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - return convertChatResponseToResponses(chatResp), nil + return providers.ResponsesViaChat(ctx, p, req) } // StreamResponses returns a raw response body for streaming Responses API (caller must close) func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - - // Get the streaming response from chat completions - stream, err := p.StreamChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - // Wrap the stream to convert chat completion format to Responses API format - return providers.NewOpenAIResponsesStreamConverter(stream, req.Model, "gemini"), nil + return providers.StreamResponsesViaChat(ctx, p, req, "gemini") } diff --git a/internal/providers/gemini/gemini_test.go b/internal/providers/gemini/gemini_test.go index d8cbd84a..3e3f27e6 100644 --- a/internal/providers/gemini/gemini_test.go +++ b/internal/providers/gemini/gemini_test.go @@ -479,130 +479,3 @@ data: [DONE] t.Error("response should end with [DONE]") } } - -func TestConvertResponsesRequestToChat(t *testing.T) { - temp := 0.7 - maxTokens := 1024 - - tests := []struct { - name string - input *core.ResponsesRequest - checkFn func(*testing.T, *core.ChatRequest) - }{ - { - name: "string input", - input: &core.ResponsesRequest{ - Model: "gemini-2.0-flash", - Input: "Hello", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Model != "gemini-2.0-flash" { - t.Errorf("Model = %q, want %q", req.Model, "gemini-2.0-flash") - } - if len(req.Messages) != 1 { - t.Errorf("len(Messages) = %d, want 1", len(req.Messages)) - } - if req.Messages[0].Role != "user" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") - } - }, - }, - { - name: "with instructions", - input: &core.ResponsesRequest{ - Model: "gemini-2.0-flash", - Input: "Hello", - Instructions: "Be helpful", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if len(req.Messages) < 2 { - t.Fatalf("len(Messages) = %d, want at least 2", len(req.Messages)) - } - if req.Messages[0].Role != "system" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "system") - } - if req.Messages[0].Content != "Be helpful" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Be helpful") - } - }, - }, - { - name: "with parameters", - input: &core.ResponsesRequest{ - Model: "gemini-2.0-flash", - Input: "Hello", - Temperature: &temp, - MaxOutputTokens: &maxTokens, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Temperature == nil || *req.Temperature != 0.7 { - t.Errorf("Temperature = %v, want 0.7", req.Temperature) - } - if req.MaxTokens == nil || *req.MaxTokens != 1024 { - t.Errorf("MaxTokens = %v, want 1024", req.MaxTokens) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertResponsesRequestToChat(tt.input) - tt.checkFn(t, result) - }) - } -} - -func TestConvertChatResponseToResponses(t *testing.T) { - resp := &core.ChatResponse{ - ID: "gemini-123", - Object: "chat.completion", - Model: "gemini-2.0-flash", - Created: 1677652288, - Choices: []core.Choice{ - { - Index: 0, - Message: core.Message{ - Role: "assistant", - Content: "Hello! How can I help you today?", - }, - FinishReason: "stop", - }, - }, - Usage: core.Usage{ - PromptTokens: 10, - CompletionTokens: 20, - TotalTokens: 30, - }, - } - - result := convertChatResponseToResponses(resp) - - if result.ID != "gemini-123" { - t.Errorf("ID = %q, want %q", result.ID, "gemini-123") - } - if result.Object != "response" { - t.Errorf("Object = %q, want %q", result.Object, "response") - } - if result.Model != "gemini-2.0-flash" { - t.Errorf("Model = %q, want %q", result.Model, "gemini-2.0-flash") - } - if result.Status != "completed" { - t.Errorf("Status = %q, want %q", result.Status, "completed") - } - if len(result.Output) != 1 { - t.Fatalf("len(Output) = %d, want 1", len(result.Output)) - } - if result.Usage == nil { - t.Fatal("Usage should not be nil") - } - if result.Usage.InputTokens != 10 { - t.Errorf("InputTokens = %d, want 10", result.Usage.InputTokens) - } - if result.Usage.OutputTokens != 20 { - t.Errorf("OutputTokens = %d, want 20", result.Usage.OutputTokens) - } - if result.Usage.TotalTokens != 30 { - t.Errorf("TotalTokens = %d, want 30", result.Usage.TotalTokens) - } -} diff --git a/internal/providers/groq/groq.go b/internal/providers/groq/groq.go index c5195175..d8241c6e 100644 --- a/internal/providers/groq/groq.go +++ b/internal/providers/groq/groq.go @@ -5,9 +5,6 @@ import ( "context" "io" "net/http" - "strings" - - "github.com/google/uuid" "gomodel/internal/core" "gomodel/internal/llmclient" @@ -112,135 +109,12 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) return &resp, nil } -// convertResponsesRequestToChat converts a ResponsesRequest to ChatRequest for Groq -func convertResponsesRequestToChat(req *core.ResponsesRequest) *core.ChatRequest { - chatReq := &core.ChatRequest{ - Model: req.Model, - Messages: make([]core.Message, 0), - Temperature: req.Temperature, - Stream: req.Stream, - } - - if req.MaxOutputTokens != nil { - chatReq.MaxTokens = req.MaxOutputTokens - } - - // Add system instruction if provided - if req.Instructions != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "system", - Content: req.Instructions, - }) - } - - // Convert input to messages - switch input := req.Input.(type) { - case string: - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "user", - Content: input, - }) - case []interface{}: - for _, item := range input { - if msgMap, ok := item.(map[string]interface{}); ok { - role, _ := msgMap["role"].(string) - content := extractContentFromInput(msgMap["content"]) - if role != "" && content != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: role, - Content: content, - }) - } - } - } - } - - return chatReq -} - -// extractContentFromInput extracts text content from responses input -func extractContentFromInput(content interface{}) string { - switch c := content.(type) { - case string: - return c - case []interface{}: - // Array of content parts - extract text - var texts []string - for _, part := range c { - if partMap, ok := part.(map[string]interface{}); ok { - if text, ok := partMap["text"].(string); ok { - texts = append(texts, text) - } - } - } - return strings.Join(texts, " ") - } - return "" -} - -// convertChatResponseToResponses converts a ChatResponse to ResponsesResponse -func convertChatResponseToResponses(resp *core.ChatResponse) *core.ResponsesResponse { - content := "" - if len(resp.Choices) > 0 { - content = resp.Choices[0].Message.Content - } - - return &core.ResponsesResponse{ - ID: resp.ID, - Object: "response", - CreatedAt: resp.Created, - Model: resp.Model, - Provider: resp.Provider, - Status: "completed", - Output: []core.ResponsesOutputItem{ - { - ID: "msg_" + uuid.New().String(), - Type: "message", - Role: "assistant", - Status: "completed", - Content: []core.ResponsesContentItem{ - { - Type: "output_text", - Text: content, - Annotations: []string{}, - }, - }, - }, - }, - Usage: &core.ResponsesUsage{ - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - }, - } -} - // Responses sends a Responses API request to Groq (converted to chat format) func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - - // Use the existing ChatCompletion method - chatResp, err := p.ChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - return convertChatResponseToResponses(chatResp), nil + return providers.ResponsesViaChat(ctx, p, req) } // StreamResponses returns a raw response body for streaming Responses API (caller must close) func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - chatReq.Stream = true - - // Get the streaming response from chat completions - stream, err := p.StreamChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - // Wrap the stream to convert chat completion format to Responses API format - return providers.NewOpenAIResponsesStreamConverter(stream, req.Model, "groq"), nil + return providers.StreamResponsesViaChat(ctx, p, req, "groq") } diff --git a/internal/providers/groq/groq_test.go b/internal/providers/groq/groq_test.go index 57aab92d..0c51815a 100644 --- a/internal/providers/groq/groq_test.go +++ b/internal/providers/groq/groq_test.go @@ -614,278 +614,6 @@ func TestResponsesWithContext(t *testing.T) { } } -func TestConvertResponsesRequestToChat(t *testing.T) { - temp := 0.7 - maxTokens := 1024 - - tests := []struct { - name string - input *core.ResponsesRequest - checkFn func(*testing.T, *core.ChatRequest) - }{ - { - name: "string input", - input: &core.ResponsesRequest{ - Model: "llama-3.3-70b-versatile", - Input: "Hello", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Model != "llama-3.3-70b-versatile" { - t.Errorf("Model = %q, want %q", req.Model, "llama-3.3-70b-versatile") - } - if len(req.Messages) != 1 { - t.Errorf("len(Messages) = %d, want 1", len(req.Messages)) - } - if req.Messages[0].Role != "user" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") - } - if req.Messages[0].Content != "Hello" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") - } - }, - }, - { - name: "with instructions", - input: &core.ResponsesRequest{ - Model: "llama-3.3-70b-versatile", - Input: "Hello", - Instructions: "Be helpful", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if len(req.Messages) < 2 { - t.Fatalf("len(Messages) = %d, want at least 2", len(req.Messages)) - } - if req.Messages[0].Role != "system" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "system") - } - if req.Messages[0].Content != "Be helpful" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Be helpful") - } - }, - }, - { - name: "with parameters", - input: &core.ResponsesRequest{ - Model: "llama-3.3-70b-versatile", - Input: "Hello", - Temperature: &temp, - MaxOutputTokens: &maxTokens, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Temperature == nil || *req.Temperature != 0.7 { - t.Errorf("Temperature = %v, want 0.7", req.Temperature) - } - if req.MaxTokens == nil || *req.MaxTokens != 1024 { - t.Errorf("MaxTokens = %v, want 1024", req.MaxTokens) - } - }, - }, - { - name: "with streaming enabled", - input: &core.ResponsesRequest{ - Model: "llama-3.3-70b-versatile", - Input: "Hello", - Stream: true, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if !req.Stream { - t.Error("Stream should be true") - } - }, - }, - { - name: "array input with messages", - input: &core.ResponsesRequest{ - Model: "llama-3.3-70b-versatile", - Input: []interface{}{ - map[string]interface{}{ - "role": "user", - "content": "Hello", - }, - map[string]interface{}{ - "role": "assistant", - "content": "Hi there!", - }, - }, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if len(req.Messages) != 2 { - t.Fatalf("len(Messages) = %d, want 2", len(req.Messages)) - } - if req.Messages[0].Role != "user" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") - } - if req.Messages[0].Content != "Hello" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") - } - if req.Messages[1].Role != "assistant" { - t.Errorf("Messages[1].Role = %q, want %q", req.Messages[1].Role, "assistant") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertResponsesRequestToChat(tt.input) - tt.checkFn(t, result) - }) - } -} - -func TestConvertChatResponseToResponses(t *testing.T) { - resp := &core.ChatResponse{ - ID: "chatcmpl-123", - Object: "chat.completion", - Model: "llama-3.3-70b-versatile", - Created: 1677652288, - Choices: []core.Choice{ - { - Index: 0, - Message: core.Message{ - Role: "assistant", - Content: "Hello! How can I help you today?", - }, - FinishReason: "stop", - }, - }, - Usage: core.Usage{ - PromptTokens: 10, - CompletionTokens: 20, - TotalTokens: 30, - }, - } - - result := convertChatResponseToResponses(resp) - - if result.ID != "chatcmpl-123" { - t.Errorf("ID = %q, want %q", result.ID, "chatcmpl-123") - } - if result.Object != "response" { - t.Errorf("Object = %q, want %q", result.Object, "response") - } - if result.Model != "llama-3.3-70b-versatile" { - t.Errorf("Model = %q, want %q", result.Model, "llama-3.3-70b-versatile") - } - if result.Status != "completed" { - t.Errorf("Status = %q, want %q", result.Status, "completed") - } - if len(result.Output) != 1 { - t.Fatalf("len(Output) = %d, want 1", len(result.Output)) - } - if result.Output[0].Type != "message" { - t.Errorf("Output[0].Type = %q, want %q", result.Output[0].Type, "message") - } - if result.Output[0].Role != "assistant" { - t.Errorf("Output[0].Role = %q, want %q", result.Output[0].Role, "assistant") - } - if result.Output[0].Status != "completed" { - t.Errorf("Output[0].Status = %q, want %q", result.Output[0].Status, "completed") - } - if len(result.Output[0].Content) != 1 { - t.Fatalf("len(Output[0].Content) = %d, want 1", len(result.Output[0].Content)) - } - if result.Output[0].Content[0].Type != "output_text" { - t.Errorf("Content[0].Type = %q, want %q", result.Output[0].Content[0].Type, "output_text") - } - if result.Output[0].Content[0].Text != "Hello! How can I help you today?" { - t.Errorf("Content[0].Text = %q, want %q", result.Output[0].Content[0].Text, "Hello! How can I help you today?") - } - if result.Usage == nil { - t.Fatal("Usage should not be nil") - } - if result.Usage.InputTokens != 10 { - t.Errorf("InputTokens = %d, want 10", result.Usage.InputTokens) - } - if result.Usage.OutputTokens != 20 { - t.Errorf("OutputTokens = %d, want 20", result.Usage.OutputTokens) - } - if result.Usage.TotalTokens != 30 { - t.Errorf("TotalTokens = %d, want 30", result.Usage.TotalTokens) - } -} - -func TestConvertChatResponseToResponses_EmptyChoices(t *testing.T) { - resp := &core.ChatResponse{ - ID: "chatcmpl-123", - Object: "chat.completion", - Model: "llama-3.3-70b-versatile", - Created: 1677652288, - Choices: []core.Choice{}, - Usage: core.Usage{ - PromptTokens: 10, - CompletionTokens: 0, - TotalTokens: 10, - }, - } - - result := convertChatResponseToResponses(resp) - - if len(result.Output) != 1 { - t.Fatalf("len(Output) = %d, want 1", len(result.Output)) - } - // Content should be empty string when no choices - if result.Output[0].Content[0].Text != "" { - t.Errorf("Content[0].Text = %q, want empty string", result.Output[0].Content[0].Text) - } -} - -func TestExtractContentFromInput(t *testing.T) { - tests := []struct { - name string - input interface{} - expected string - }{ - { - name: "string input", - input: "Hello world", - expected: "Hello world", - }, - { - name: "array with text parts", - input: []interface{}{ - map[string]interface{}{ - "type": "text", - "text": "Hello", - }, - map[string]interface{}{ - "type": "text", - "text": "world", - }, - }, - expected: "Hello world", - }, - { - name: "nil input", - input: nil, - expected: "", - }, - { - name: "unsupported type", - input: 12345, - expected: "", - }, - { - name: "array with non-text parts", - input: []interface{}{ - map[string]interface{}{ - "type": "image", - "url": "http://example.com/image.png", - }, - }, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractContentFromInput(tt.input) - if result != tt.expected { - t.Errorf("extractContentFromInput(%v) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} func TestGroqResponsesStreamConverter(t *testing.T) { // Test the stream converter with mock chat completion stream diff --git a/internal/providers/ollama/ollama.go b/internal/providers/ollama/ollama.go index eeddd345..d2977dd6 100644 --- a/internal/providers/ollama/ollama.go +++ b/internal/providers/ollama/ollama.go @@ -5,11 +5,8 @@ import ( "context" "io" "net/http" - "strings" "time" - "github.com/google/uuid" - "gomodel/internal/core" "gomodel/internal/llmclient" "gomodel/internal/providers" @@ -126,135 +123,12 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) return &resp, nil } -// convertResponsesRequestToChat converts a ResponsesRequest to ChatRequest for Ollama -func convertResponsesRequestToChat(req *core.ResponsesRequest) *core.ChatRequest { - chatReq := &core.ChatRequest{ - Model: req.Model, - Messages: make([]core.Message, 0), - Temperature: req.Temperature, - Stream: req.Stream, - } - - if req.MaxOutputTokens != nil { - chatReq.MaxTokens = req.MaxOutputTokens - } - - // Add system instruction if provided - if req.Instructions != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "system", - Content: req.Instructions, - }) - } - - // Convert input to messages - switch input := req.Input.(type) { - case string: - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: "user", - Content: input, - }) - case []interface{}: - for _, item := range input { - if msgMap, ok := item.(map[string]interface{}); ok { - role, _ := msgMap["role"].(string) - content := extractContentFromInput(msgMap["content"]) - if role != "" && content != "" { - chatReq.Messages = append(chatReq.Messages, core.Message{ - Role: role, - Content: content, - }) - } - } - } - } - - return chatReq -} - -// extractContentFromInput extracts text content from responses input -func extractContentFromInput(content interface{}) string { - switch c := content.(type) { - case string: - return c - case []interface{}: - // Array of content parts - extract text - var texts []string - for _, part := range c { - if partMap, ok := part.(map[string]interface{}); ok { - if text, ok := partMap["text"].(string); ok { - texts = append(texts, text) - } - } - } - return strings.Join(texts, " ") - } - return "" -} - -// convertChatResponseToResponses converts a ChatResponse to ResponsesResponse -func convertChatResponseToResponses(resp *core.ChatResponse) *core.ResponsesResponse { - content := "" - if len(resp.Choices) > 0 { - content = resp.Choices[0].Message.Content - } - - return &core.ResponsesResponse{ - ID: resp.ID, - Object: "response", - CreatedAt: resp.Created, - Model: resp.Model, - Provider: resp.Provider, - Status: "completed", - Output: []core.ResponsesOutputItem{ - { - ID: "msg_" + uuid.New().String(), - Type: "message", - Role: "assistant", - Status: "completed", - Content: []core.ResponsesContentItem{ - { - Type: "output_text", - Text: content, - Annotations: []string{}, - }, - }, - }, - }, - Usage: &core.ResponsesUsage{ - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - }, - } -} - // Responses sends a Responses API request to Ollama (converted to chat format) func (p *Provider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - - // Use the existing ChatCompletion method - chatResp, err := p.ChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - return convertChatResponseToResponses(chatResp), nil + return providers.ResponsesViaChat(ctx, p, req) } // StreamResponses returns a raw response body for streaming Responses API (caller must close) func (p *Provider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - // Convert ResponsesRequest to ChatRequest - chatReq := convertResponsesRequestToChat(req) - chatReq.Stream = true - - // Get the streaming response from chat completions - stream, err := p.StreamChatCompletion(ctx, chatReq) - if err != nil { - return nil, err - } - - // Wrap the stream to convert chat completion format to Responses API format - return providers.NewOpenAIResponsesStreamConverter(stream, req.Model, "ollama"), nil + return providers.StreamResponsesViaChat(ctx, p, req, "ollama") } diff --git a/internal/providers/ollama/ollama_test.go b/internal/providers/ollama/ollama_test.go index a2a14e29..820feef2 100644 --- a/internal/providers/ollama/ollama_test.go +++ b/internal/providers/ollama/ollama_test.go @@ -674,278 +674,6 @@ func TestResponsesWithContext(t *testing.T) { } } -func TestConvertResponsesRequestToChat(t *testing.T) { - temp := 0.7 - maxTokens := 1024 - - tests := []struct { - name string - input *core.ResponsesRequest - checkFn func(*testing.T, *core.ChatRequest) - }{ - { - name: "string input", - input: &core.ResponsesRequest{ - Model: "llama3.2", - Input: "Hello", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Model != "llama3.2" { - t.Errorf("Model = %q, want %q", req.Model, "llama3.2") - } - if len(req.Messages) != 1 { - t.Errorf("len(Messages) = %d, want 1", len(req.Messages)) - } - if req.Messages[0].Role != "user" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") - } - if req.Messages[0].Content != "Hello" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") - } - }, - }, - { - name: "with instructions", - input: &core.ResponsesRequest{ - Model: "llama3.2", - Input: "Hello", - Instructions: "Be helpful", - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if len(req.Messages) < 2 { - t.Fatalf("len(Messages) = %d, want at least 2", len(req.Messages)) - } - if req.Messages[0].Role != "system" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "system") - } - if req.Messages[0].Content != "Be helpful" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Be helpful") - } - }, - }, - { - name: "with parameters", - input: &core.ResponsesRequest{ - Model: "llama3.2", - Input: "Hello", - Temperature: &temp, - MaxOutputTokens: &maxTokens, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if req.Temperature == nil || *req.Temperature != 0.7 { - t.Errorf("Temperature = %v, want 0.7", req.Temperature) - } - if req.MaxTokens == nil || *req.MaxTokens != 1024 { - t.Errorf("MaxTokens = %v, want 1024", req.MaxTokens) - } - }, - }, - { - name: "with streaming enabled", - input: &core.ResponsesRequest{ - Model: "llama3.2", - Input: "Hello", - Stream: true, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if !req.Stream { - t.Error("Stream should be true") - } - }, - }, - { - name: "array input with messages", - input: &core.ResponsesRequest{ - Model: "llama3.2", - Input: []interface{}{ - map[string]interface{}{ - "role": "user", - "content": "Hello", - }, - map[string]interface{}{ - "role": "assistant", - "content": "Hi there!", - }, - }, - }, - checkFn: func(t *testing.T, req *core.ChatRequest) { - if len(req.Messages) != 2 { - t.Fatalf("len(Messages) = %d, want 2", len(req.Messages)) - } - if req.Messages[0].Role != "user" { - t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") - } - if req.Messages[0].Content != "Hello" { - t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") - } - if req.Messages[1].Role != "assistant" { - t.Errorf("Messages[1].Role = %q, want %q", req.Messages[1].Role, "assistant") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := convertResponsesRequestToChat(tt.input) - tt.checkFn(t, result) - }) - } -} - -func TestConvertChatResponseToResponses(t *testing.T) { - resp := &core.ChatResponse{ - ID: "chatcmpl-123", - Object: "chat.completion", - Model: "llama3.2", - Created: 1677652288, - Choices: []core.Choice{ - { - Index: 0, - Message: core.Message{ - Role: "assistant", - Content: "Hello! How can I help you today?", - }, - FinishReason: "stop", - }, - }, - Usage: core.Usage{ - PromptTokens: 10, - CompletionTokens: 20, - TotalTokens: 30, - }, - } - - result := convertChatResponseToResponses(resp) - - if result.ID != "chatcmpl-123" { - t.Errorf("ID = %q, want %q", result.ID, "chatcmpl-123") - } - if result.Object != "response" { - t.Errorf("Object = %q, want %q", result.Object, "response") - } - if result.Model != "llama3.2" { - t.Errorf("Model = %q, want %q", result.Model, "llama3.2") - } - if result.Status != "completed" { - t.Errorf("Status = %q, want %q", result.Status, "completed") - } - if len(result.Output) != 1 { - t.Fatalf("len(Output) = %d, want 1", len(result.Output)) - } - if result.Output[0].Type != "message" { - t.Errorf("Output[0].Type = %q, want %q", result.Output[0].Type, "message") - } - if result.Output[0].Role != "assistant" { - t.Errorf("Output[0].Role = %q, want %q", result.Output[0].Role, "assistant") - } - if result.Output[0].Status != "completed" { - t.Errorf("Output[0].Status = %q, want %q", result.Output[0].Status, "completed") - } - if len(result.Output[0].Content) != 1 { - t.Fatalf("len(Output[0].Content) = %d, want 1", len(result.Output[0].Content)) - } - if result.Output[0].Content[0].Type != "output_text" { - t.Errorf("Content[0].Type = %q, want %q", result.Output[0].Content[0].Type, "output_text") - } - if result.Output[0].Content[0].Text != "Hello! How can I help you today?" { - t.Errorf("Content[0].Text = %q, want %q", result.Output[0].Content[0].Text, "Hello! How can I help you today?") - } - if result.Usage == nil { - t.Fatal("Usage should not be nil") - } - if result.Usage.InputTokens != 10 { - t.Errorf("InputTokens = %d, want 10", result.Usage.InputTokens) - } - if result.Usage.OutputTokens != 20 { - t.Errorf("OutputTokens = %d, want 20", result.Usage.OutputTokens) - } - if result.Usage.TotalTokens != 30 { - t.Errorf("TotalTokens = %d, want 30", result.Usage.TotalTokens) - } -} - -func TestConvertChatResponseToResponses_EmptyChoices(t *testing.T) { - resp := &core.ChatResponse{ - ID: "chatcmpl-123", - Object: "chat.completion", - Model: "llama3.2", - Created: 1677652288, - Choices: []core.Choice{}, - Usage: core.Usage{ - PromptTokens: 10, - CompletionTokens: 0, - TotalTokens: 10, - }, - } - - result := convertChatResponseToResponses(resp) - - if len(result.Output) != 1 { - t.Fatalf("len(Output) = %d, want 1", len(result.Output)) - } - // Content should be empty string when no choices - if result.Output[0].Content[0].Text != "" { - t.Errorf("Content[0].Text = %q, want empty string", result.Output[0].Content[0].Text) - } -} - -func TestExtractContentFromInput(t *testing.T) { - tests := []struct { - name string - input interface{} - expected string - }{ - { - name: "string input", - input: "Hello world", - expected: "Hello world", - }, - { - name: "array with text parts", - input: []interface{}{ - map[string]interface{}{ - "type": "text", - "text": "Hello", - }, - map[string]interface{}{ - "type": "text", - "text": "world", - }, - }, - expected: "Hello world", - }, - { - name: "nil input", - input: nil, - expected: "", - }, - { - name: "unsupported type", - input: 12345, - expected: "", - }, - { - name: "array with non-text parts", - input: []interface{}{ - map[string]interface{}{ - "type": "image", - "url": "http://example.com/image.png", - }, - }, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractContentFromInput(tt.input) - if result != tt.expected { - t.Errorf("extractContentFromInput(%v) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} func TestOllamaResponsesStreamConverter(t *testing.T) { // Test the stream converter with mock chat completion stream diff --git a/internal/providers/responses_adapter.go b/internal/providers/responses_adapter.go new file mode 100644 index 00000000..52708cc8 --- /dev/null +++ b/internal/providers/responses_adapter.go @@ -0,0 +1,146 @@ +package providers + +import ( + "context" + "io" + "strings" + + "github.com/google/uuid" + + "gomodel/internal/core" +) + +// ChatProvider is the minimal interface needed by the shared Responses-to-Chat adapter. +// Any provider that supports ChatCompletion and StreamChatCompletion can use the +// ResponsesViaChat and StreamResponsesViaChat helpers to implement the Responses API. +type ChatProvider interface { + ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) + StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) +} + +// ConvertResponsesRequestToChat converts a ResponsesRequest to a ChatRequest. +func ConvertResponsesRequestToChat(req *core.ResponsesRequest) *core.ChatRequest { + chatReq := &core.ChatRequest{ + Model: req.Model, + Messages: make([]core.Message, 0), + Temperature: req.Temperature, + Stream: req.Stream, + } + + if req.MaxOutputTokens != nil { + chatReq.MaxTokens = req.MaxOutputTokens + } + + // Add system instruction if provided + if req.Instructions != "" { + chatReq.Messages = append(chatReq.Messages, core.Message{ + Role: "system", + Content: req.Instructions, + }) + } + + // Convert input to messages + switch input := req.Input.(type) { + case string: + chatReq.Messages = append(chatReq.Messages, core.Message{ + Role: "user", + Content: input, + }) + case []interface{}: + for _, item := range input { + if msgMap, ok := item.(map[string]interface{}); ok { + role, _ := msgMap["role"].(string) + content := ExtractContentFromInput(msgMap["content"]) + if role != "" && content != "" { + chatReq.Messages = append(chatReq.Messages, core.Message{ + Role: role, + Content: content, + }) + } + } + } + } + + return chatReq +} + +// ExtractContentFromInput extracts text content from responses input. +func ExtractContentFromInput(content interface{}) string { + switch c := content.(type) { + case string: + return c + case []interface{}: + // Array of content parts - extract text + var texts []string + for _, part := range c { + if partMap, ok := part.(map[string]interface{}); ok { + if text, ok := partMap["text"].(string); ok { + texts = append(texts, text) + } + } + } + return strings.Join(texts, " ") + } + return "" +} + +// ConvertChatResponseToResponses converts a ChatResponse to a ResponsesResponse. +func ConvertChatResponseToResponses(resp *core.ChatResponse) *core.ResponsesResponse { + content := "" + if len(resp.Choices) > 0 { + content = resp.Choices[0].Message.Content + } + + return &core.ResponsesResponse{ + ID: resp.ID, + Object: "response", + CreatedAt: resp.Created, + Model: resp.Model, + Provider: resp.Provider, + Status: "completed", + Output: []core.ResponsesOutputItem{ + { + ID: "msg_" + uuid.New().String(), + Type: "message", + Role: "assistant", + Status: "completed", + Content: []core.ResponsesContentItem{ + { + Type: "output_text", + Text: content, + Annotations: []string{}, + }, + }, + }, + }, + Usage: &core.ResponsesUsage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + }, + } +} + +// ResponsesViaChat implements the Responses API by converting to/from Chat format. +func ResponsesViaChat(ctx context.Context, p ChatProvider, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + chatReq := ConvertResponsesRequestToChat(req) + + chatResp, err := p.ChatCompletion(ctx, chatReq) + if err != nil { + return nil, err + } + + return ConvertChatResponseToResponses(chatResp), nil +} + +// StreamResponsesViaChat implements streaming Responses API by converting to/from Chat format. +func StreamResponsesViaChat(ctx context.Context, p ChatProvider, req *core.ResponsesRequest, providerName string) (io.ReadCloser, error) { + chatReq := ConvertResponsesRequestToChat(req) + + stream, err := p.StreamChatCompletion(ctx, chatReq) + if err != nil { + return nil, err + } + + return NewOpenAIResponsesStreamConverter(stream, req.Model, providerName), nil +} diff --git a/internal/providers/responses_adapter_test.go b/internal/providers/responses_adapter_test.go new file mode 100644 index 00000000..d5c9376b --- /dev/null +++ b/internal/providers/responses_adapter_test.go @@ -0,0 +1,280 @@ +package providers + +import ( + "testing" + + "gomodel/internal/core" +) + +func TestConvertResponsesRequestToChat(t *testing.T) { + temp := 0.7 + maxTokens := 1024 + + tests := []struct { + name string + input *core.ResponsesRequest + checkFn func(*testing.T, *core.ChatRequest) + }{ + { + name: "string input", + input: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + }, + checkFn: func(t *testing.T, req *core.ChatRequest) { + if req.Model != "test-model" { + t.Errorf("Model = %q, want %q", req.Model, "test-model") + } + if len(req.Messages) != 1 { + t.Errorf("len(Messages) = %d, want 1", len(req.Messages)) + } + if req.Messages[0].Role != "user" { + t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") + } + if req.Messages[0].Content != "Hello" { + t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") + } + }, + }, + { + name: "with instructions", + input: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Instructions: "Be helpful", + }, + checkFn: func(t *testing.T, req *core.ChatRequest) { + if len(req.Messages) < 2 { + t.Fatalf("len(Messages) = %d, want at least 2", len(req.Messages)) + } + if req.Messages[0].Role != "system" { + t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "system") + } + if req.Messages[0].Content != "Be helpful" { + t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Be helpful") + } + }, + }, + { + name: "with parameters", + input: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Temperature: &temp, + MaxOutputTokens: &maxTokens, + }, + checkFn: func(t *testing.T, req *core.ChatRequest) { + if req.Temperature == nil || *req.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7", req.Temperature) + } + if req.MaxTokens == nil || *req.MaxTokens != 1024 { + t.Errorf("MaxTokens = %v, want 1024", req.MaxTokens) + } + }, + }, + { + name: "with streaming enabled", + input: &core.ResponsesRequest{ + Model: "test-model", + Input: "Hello", + Stream: true, + }, + checkFn: func(t *testing.T, req *core.ChatRequest) { + if !req.Stream { + t.Error("Stream should be true") + } + }, + }, + { + name: "array input with messages", + input: &core.ResponsesRequest{ + Model: "test-model", + Input: []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "Hello", + }, + map[string]interface{}{ + "role": "assistant", + "content": "Hi there!", + }, + }, + }, + checkFn: func(t *testing.T, req *core.ChatRequest) { + if len(req.Messages) != 2 { + t.Fatalf("len(Messages) = %d, want 2", len(req.Messages)) + } + if req.Messages[0].Role != "user" { + t.Errorf("Messages[0].Role = %q, want %q", req.Messages[0].Role, "user") + } + if req.Messages[0].Content != "Hello" { + t.Errorf("Messages[0].Content = %q, want %q", req.Messages[0].Content, "Hello") + } + if req.Messages[1].Role != "assistant" { + t.Errorf("Messages[1].Role = %q, want %q", req.Messages[1].Role, "assistant") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertResponsesRequestToChat(tt.input) + tt.checkFn(t, result) + }) + } +} + +func TestConvertChatResponseToResponses(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-123", + Object: "chat.completion", + Model: "test-model", + Created: 1677652288, + Choices: []core.Choice{ + { + Index: 0, + Message: core.Message{ + Role: "assistant", + Content: "Hello! How can I help you today?", + }, + FinishReason: "stop", + }, + }, + Usage: core.Usage{ + PromptTokens: 10, + CompletionTokens: 20, + TotalTokens: 30, + }, + } + + result := ConvertChatResponseToResponses(resp) + + if result.ID != "chatcmpl-123" { + t.Errorf("ID = %q, want %q", result.ID, "chatcmpl-123") + } + if result.Object != "response" { + t.Errorf("Object = %q, want %q", result.Object, "response") + } + if result.Model != "test-model" { + t.Errorf("Model = %q, want %q", result.Model, "test-model") + } + if result.Status != "completed" { + t.Errorf("Status = %q, want %q", result.Status, "completed") + } + if len(result.Output) != 1 { + t.Fatalf("len(Output) = %d, want 1", len(result.Output)) + } + if result.Output[0].Type != "message" { + t.Errorf("Output[0].Type = %q, want %q", result.Output[0].Type, "message") + } + if result.Output[0].Role != "assistant" { + t.Errorf("Output[0].Role = %q, want %q", result.Output[0].Role, "assistant") + } + if result.Output[0].Status != "completed" { + t.Errorf("Output[0].Status = %q, want %q", result.Output[0].Status, "completed") + } + if len(result.Output[0].Content) != 1 { + t.Fatalf("len(Output[0].Content) = %d, want 1", len(result.Output[0].Content)) + } + if result.Output[0].Content[0].Type != "output_text" { + t.Errorf("Content[0].Type = %q, want %q", result.Output[0].Content[0].Type, "output_text") + } + if result.Output[0].Content[0].Text != "Hello! How can I help you today?" { + t.Errorf("Content[0].Text = %q, want %q", result.Output[0].Content[0].Text, "Hello! How can I help you today?") + } + if result.Usage == nil { + t.Fatal("Usage should not be nil") + } + if result.Usage.InputTokens != 10 { + t.Errorf("InputTokens = %d, want 10", result.Usage.InputTokens) + } + if result.Usage.OutputTokens != 20 { + t.Errorf("OutputTokens = %d, want 20", result.Usage.OutputTokens) + } + if result.Usage.TotalTokens != 30 { + t.Errorf("TotalTokens = %d, want 30", result.Usage.TotalTokens) + } +} + +func TestConvertChatResponseToResponses_EmptyChoices(t *testing.T) { + resp := &core.ChatResponse{ + ID: "chatcmpl-123", + Object: "chat.completion", + Model: "test-model", + Created: 1677652288, + Choices: []core.Choice{}, + Usage: core.Usage{ + PromptTokens: 10, + CompletionTokens: 0, + TotalTokens: 10, + }, + } + + result := ConvertChatResponseToResponses(resp) + + if len(result.Output) != 1 { + t.Fatalf("len(Output) = %d, want 1", len(result.Output)) + } + // Content should be empty string when no choices + if result.Output[0].Content[0].Text != "" { + t.Errorf("Content[0].Text = %q, want empty string", result.Output[0].Content[0].Text) + } +} + +func TestExtractContentFromInput(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + { + name: "string input", + input: "Hello world", + expected: "Hello world", + }, + { + name: "array with text parts", + input: []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "Hello", + }, + map[string]interface{}{ + "type": "text", + "text": "world", + }, + }, + expected: "Hello world", + }, + { + name: "nil input", + input: nil, + expected: "", + }, + { + name: "unsupported type", + input: 12345, + expected: "", + }, + { + name: "array with non-text parts", + input: []interface{}{ + map[string]interface{}{ + "type": "image", + "url": "http://example.com/image.png", + }, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractContentFromInput(tt.input) + if result != tt.expected { + t.Errorf("ExtractContentFromInput(%v) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +}