feat: add opencode-zen provider with multi-endpoint support
Add support for OpenCode Zen API which uses different endpoints
depending on the model type:
- OpenAI models (gpt-*) use /responses endpoint
- Anthropic models (claude-*) use /messages endpoint
- Gemini models (gemini-*) use /models/{model} endpoint
- Other models use /chat/completions endpoint
Includes proper request/response format handling for each
endpoint type and comprehensive test coverage.
This commit is contained in:
parent
bea238c337
commit
bce265f71e
5 changed files with 1548 additions and 1 deletions
|
|
@ -429,6 +429,7 @@ type ProvidersConfig struct {
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
|
Opencode ProviderConfig `json:"opencode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -452,7 +453,8 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
|
p.Opencode.APIKey == "" && p.Opencode.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
}
|
}
|
||||||
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
|
sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
|
||||||
return sel, nil
|
return sel, nil
|
||||||
|
case "opencode", "opencode-zen":
|
||||||
|
if cfg.Providers.Opencode.APIKey != "" {
|
||||||
|
sel.apiKey = cfg.Providers.Opencode.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Opencode.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Opencode.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://opencode.ai/zen/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +309,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
|
||||||
if sel.apiBase == "" {
|
if sel.apiBase == "" {
|
||||||
sel.apiBase = "https://api.mistral.ai/v1"
|
sel.apiBase = "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
|
case cfg.Providers.Opencode.APIKey != "" && cfg.Providers.Opencode.APIBase != "":
|
||||||
|
sel.apiKey = cfg.Providers.Opencode.APIKey
|
||||||
|
sel.apiBase = cfg.Providers.Opencode.APIBase
|
||||||
|
sel.proxy = cfg.Providers.Opencode.Proxy
|
||||||
|
if sel.apiBase == "" {
|
||||||
|
sel.apiBase = "https://opencode.ai/zen/v1"
|
||||||
|
}
|
||||||
case cfg.Providers.VLLM.APIBase != "":
|
case cfg.Providers.VLLM.APIBase != "":
|
||||||
sel.apiKey = cfg.Providers.VLLM.APIKey
|
sel.apiKey = cfg.Providers.VLLM.APIKey
|
||||||
sel.apiBase = cfg.Providers.VLLM.APIBase
|
sel.apiBase = cfg.Providers.VLLM.APIBase
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/opencode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
|
|
@ -168,6 +169,19 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
}
|
}
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
|
case "opencode", "opencode-zen":
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = "https://opencode.ai/zen/v1"
|
||||||
|
}
|
||||||
|
return opencode.NewProviderWithMaxTokensFieldAndTimeout(
|
||||||
|
cfg.APIKey,
|
||||||
|
apiBase,
|
||||||
|
cfg.Proxy,
|
||||||
|
cfg.MaxTokensField,
|
||||||
|
cfg.RequestTimeout,
|
||||||
|
), modelID, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1020
pkg/providers/opencode/provider.go
Normal file
1020
pkg/providers/opencode/provider.go
Normal file
File diff suppressed because it is too large
Load diff
495
pkg/providers/opencode/provider_test.go
Normal file
495
pkg/providers/opencode/provider_test.go
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProvider_GetDefaultModel(t *testing.T) {
|
||||||
|
p := NewProvider("test-key", "", "")
|
||||||
|
if got := p.GetDefaultModel(); got != "kimi-k2.5" {
|
||||||
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "kimi-k2.5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_OpenAICompatibleEndpoint(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
var requestPath string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"message": map[string]any{"content": "Hello from kimi"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 5,
|
||||||
|
"total_tokens": 15,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"kimi-k2.5",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use /chat/completions endpoint for kimi models
|
||||||
|
if requestPath != "/chat/completions" {
|
||||||
|
t.Errorf("request path = %q, want %q", requestPath, "/chat/completions")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "Hello from kimi" {
|
||||||
|
t.Errorf("Content = %q, want %q", out.Content, "Hello from kimi")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Usage == nil {
|
||||||
|
t.Fatal("Usage should not be nil")
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 10 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 10", out.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_AnthropicMessagesEndpoint(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
var requestPath string
|
||||||
|
var apiKey string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
apiKey = r.Header.Get("x-api-key")
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"content": []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello from claude",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": 20,
|
||||||
|
"output_tokens": 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"claude-opus-4-6",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use /messages endpoint for claude models
|
||||||
|
if requestPath != "/messages" {
|
||||||
|
t.Errorf("request path = %q, want %q", requestPath, "/messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use x-api-key header
|
||||||
|
if apiKey != "test-key" {
|
||||||
|
t.Errorf("API key = %q, want %q", apiKey, "test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "Hello from claude" {
|
||||||
|
t.Errorf("Content = %q, want %q", out.Content, "Hello from claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Usage == nil {
|
||||||
|
t.Fatal("Usage should not be nil")
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 20 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 20", out.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_OpenAIResponsesEndpoint(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
var requestPath string
|
||||||
|
var authHeader string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"output": []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello from GPT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": 15,
|
||||||
|
"output_tokens": 8,
|
||||||
|
"total_tokens": 23,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gpt-5.3-codex",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use /responses endpoint for GPT models
|
||||||
|
if requestPath != "/responses" {
|
||||||
|
t.Errorf("request path = %q, want %q", requestPath, "/responses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use Authorization Bearer header
|
||||||
|
if authHeader != "Bearer test-key" {
|
||||||
|
t.Errorf("Authorization header = %q, want %q", authHeader, "Bearer test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "Hello from GPT" {
|
||||||
|
t.Errorf("Content = %q, want %q", out.Content, "Hello from GPT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Usage == nil {
|
||||||
|
t.Fatal("Usage should not be nil")
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 15 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 15", out.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_WithTools(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"message": map[string]any{
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": []map[string]any{
|
||||||
|
{
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": `{"location":"NYC"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunctionDefinition{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather for a location",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"location": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "What's the weather?"}},
|
||||||
|
tools,
|
||||||
|
"kimi-k2.5",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.ToolCalls[0].Name != "get_weather" {
|
||||||
|
t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.ToolCalls[0].Arguments["location"] != "NYC" {
|
||||||
|
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", out.ToolCalls[0].Arguments["location"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.FinishReason != "tool_calls" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", out.FinishReason, "tool_calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_WithTemperatureAndMaxTokens(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"choices": []map[string]any{
|
||||||
|
{
|
||||||
|
"message": map[string]any{"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
_, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"kimi-k2.5",
|
||||||
|
map[string]any{
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kimi k2 models should always use temperature=1.0
|
||||||
|
if requestBody["temperature"] != 1.0 {
|
||||||
|
t.Errorf("temperature = %v, want 1.0", requestBody["temperature"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestBody["max_tokens"] != float64(100) {
|
||||||
|
t.Errorf("max_tokens = %v, want 100", requestBody["max_tokens"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_GeminiModelsEndpoint(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
var requestPath string
|
||||||
|
var authHeader string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"candidates": []map[string]any{
|
||||||
|
{
|
||||||
|
"content": map[string]any{
|
||||||
|
"parts": []map[string]any{
|
||||||
|
{"text": "Hello from Gemini"},
|
||||||
|
},
|
||||||
|
"role": "model",
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usageMetadata": map[string]any{
|
||||||
|
"promptTokenCount": 25,
|
||||||
|
"candidatesTokenCount": 12,
|
||||||
|
"totalTokenCount": 37,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
"gemini-3.1-pro",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use /models/{model} endpoint for Gemini models
|
||||||
|
if requestPath != "/models/gemini-3.1-pro" {
|
||||||
|
t.Errorf("request path = %q, want %q", requestPath, "/models/gemini-3.1-pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use Authorization Bearer header
|
||||||
|
if authHeader != "Bearer test-key" {
|
||||||
|
t.Errorf("Authorization header = %q, want %q", authHeader, "Bearer test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "Hello from Gemini" {
|
||||||
|
t.Errorf("Content = %q, want %q", out.Content, "Hello from Gemini")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Usage == nil {
|
||||||
|
t.Fatal("Usage should not be nil")
|
||||||
|
}
|
||||||
|
if out.Usage.PromptTokens != 25 {
|
||||||
|
t.Errorf("PromptTokens = %d, want 25", out.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
if out.Usage.CompletionTokens != 12 {
|
||||||
|
t.Errorf("CompletionTokens = %d, want 12", out.Usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_GeminiWithTools(t *testing.T) {
|
||||||
|
var requestBody map[string]any
|
||||||
|
var requestPath string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestPath = r.URL.Path
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"candidates": []map[string]any{
|
||||||
|
{
|
||||||
|
"content": map[string]any{
|
||||||
|
"parts": []map[string]any{
|
||||||
|
{
|
||||||
|
"functionCall": map[string]any{
|
||||||
|
"name": "get_weather",
|
||||||
|
"args": map[string]any{
|
||||||
|
"location": "NYC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"role": "model",
|
||||||
|
},
|
||||||
|
"finishReason": "STOP",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
p := NewProvider("test-key", server.URL, "")
|
||||||
|
tools := []ToolDefinition{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolFunctionDefinition{
|
||||||
|
Name: "get_weather",
|
||||||
|
Description: "Get weather for a location",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"location": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := p.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "What's the weather?"}},
|
||||||
|
tools,
|
||||||
|
"gemini-3-flash",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should use /models/{model} endpoint
|
||||||
|
if requestPath != "/models/gemini-3-flash" {
|
||||||
|
t.Errorf("request path = %q, want %q", requestPath, "/models/gemini-3-flash")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.ToolCalls[0].Name != "get_weather" {
|
||||||
|
t.Errorf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.ToolCalls[0].Arguments["location"] != "NYC" {
|
||||||
|
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", out.ToolCalls[0].Arguments["location"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.FinishReason != "tool_calls" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", out.FinishReason, "tool_calls")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderChat_DefaultAPIBase(t *testing.T) {
|
||||||
|
p := NewProvider("test-key", "", "")
|
||||||
|
if p.apiBase != "https://opencode.ai/zen/v1" {
|
||||||
|
t.Errorf("apiBase = %q, want %q", p.apiBase, "https://opencode.ai/zen/v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue