diff --git a/Makefile b/Makefile index c7be65d..fd4cdeb 100644 --- a/Makefile +++ b/Makefile @@ -12,10 +12,10 @@ lint: golangci-lint run ./... test: - $(GO) test ./... -count=1 + $(GO) test ./... -count=1 -timeout 120s test-race: - $(GO) test ./... -race -count=1 + $(GO) test ./... -race -count=1 -timeout 120s quality: fmt vet test diff --git a/README.md b/README.md index a9ca0ed..0bfdd72 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Multi-provider Go SDK for LLM inference endpoints — **OpenAI, Google Gemini, D - **Multiple authenticated endpoints at once** — auto-discovered from `_API_KEY` environment variables (aliases supported). - **Dynamic model discovery** — `ListModels` returns what the account can actually access. No static model tables. - **One canonical API** — OpenAI-shaped requests and responses; Anthropic and Gemini wire formats are translated for you. +- **Portable generation controls** — token limits, temperature, top-p, stop sequences, thinking, and tools map to each provider's native fields. - **Production streaming** — SSE with an idle watchdog and a hard wall-clock deadline, abort-with-partial-result, retries that never duplicate partial output, premature-close detection, and learn-once fallbacks for providers that reject `stream_options`, streaming, or `reasoning_effort`+tools. - **Predictable under load** — goroutine-leak-free streaming, race-clean shared state, and a canonical-only error vocabulary (API keys never leak into error text). @@ -114,6 +115,8 @@ type ChatRequest struct { ThinkingBudget int // explicit token budget where the provider supports it MaxTokens int // routed to max_completion_tokens on o-series/gpt-5 Temperature float64 // 0 = provider default; negative = explicit 0 + TopP float64 // 0 = provider default; negative = explicit 0 + Stop []string // provider-native stop / stop_sequences / stopSequences } type ChatResult struct { @@ -165,10 +168,12 @@ On Gemini, a tool result's `ToolName` may be omitted — the SDK recovers the fu ## Extended thinking -- **Anthropic** — `thinking` blocks are parsed in both buffered and streaming modes. `ChatResult.ThinkingSignature` carries the provider signature; for tool loops, replay it on the assistant message (`Message.ReasoningContent` + `Message.ThinkingSignature`) — the SDK re-serializes it as the first block, as Anthropic's API requires. Omitting it makes extended-thinking tool loops fail mid-conversation. +- **Anthropic** — `thinking` blocks are parsed in both buffered and streaming modes. `ChatResult.ThinkingSignature` carries the provider signature; for tool loops, replay it on the assistant message (`Message.ReasoningContent` + `Message.ThinkingSignature`) — the SDK re-serializes it as the first block, as Anthropic's API requires. Unsigned thinking replay is rejected locally with `ConfigError`. - **DeepSeek / GLM** — reasoning streams as `DeltaReasoning` fragments and lands in `ReasoningContent`. Assistant-turn replay echoes it as `reasoning_content` (required for DeepSeek/GLM tool loops). GLM maps thinking `medium` → `reasoning_effort` `high` (no medium level) and `max` → `max`. - **Gemini** — `thought: true` parts map to reasoning deltas; `thinkingConfig` is derived from `Thinking` / `ThinkingBudget`. +`ThinkingBudget`, when positive, overrides the selected non-disabled thinking preset (Anthropic enforces its 1024-token minimum). Canonical `max` selects the highest portable preset: OpenAI `high`, Gemini 24576, Anthropic 16384; GLM retains its native `max`. + ## Learn-once fallbacks When a provider rejects a request pattern, the SDK learns the constraint **once per provider** (shared across every `ChatClient` you mint) and never re-pays the failed round-trip: diff --git a/anthropic.go b/anthropic.go index 5bd9759..cdb182a 100644 --- a/anthropic.go +++ b/anthropic.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + neturl "net/url" "strings" "time" ) @@ -66,6 +67,8 @@ type anRequest struct { System []anSysBlock `json:"system,omitempty"` Tools []anTool `json:"tools,omitempty"` Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stop []string `json:"stop_sequences,omitempty"` Stream bool `json:"stream,omitempty"` Thinking *anThinking `json:"thinking,omitempty"` } @@ -75,21 +78,23 @@ const anthropicDefaultMaxTokens = 8192 // anthropicThinkingBudget maps canonical thinking levels to budgets. // Anthropic requires budget_tokens >= 1024. func anthropicThinkingBudget(level string, explicit int) (int, bool) { + var budget int switch level { case "enabled": - if explicit > 0 { - return maxInt(explicit, 1024), true - } - return 5000, true + budget = 5000 case "low": - return 1024, true + budget = 1024 case "medium": - return 8192, true - case "high": - return 16384, true + budget = 8192 + case "high", "max": + budget = 16384 default: // "", "disabled" return 0, false } + if explicit > 0 { + budget = maxInt(explicit, 1024) + } + return budget, true } func maxInt(a, b int) int { @@ -104,6 +109,7 @@ func buildAnthropicRequest(req *ChatRequest, model string, stream bool) ([]byte, out := anRequest{ Model: model, MaxTokens: req.MaxTokens, + Stop: req.Stop, Stream: stream, } @@ -124,6 +130,13 @@ func buildAnthropicRequest(req *ChatRequest, model string, stream bool) ([]byte, } out.Temperature = &t } + if req.TopP != 0 { + p := req.TopP + if p < 0 { + p = 0 + } + out.TopP = &p + } if out.MaxTokens <= 0 { out.MaxTokens = anthropicDefaultMaxTokens } @@ -162,6 +175,9 @@ func buildAnthropicRequest(req *ChatRequest, model string, stream bool) ([]byte, Content: []anBlock{blk}, }) case RoleAssistant: + if m.ReasoningContent != "" && m.ThinkingSignature == "" { + return nil, &ConfigError{Msg: fmt.Sprintf("message %d: Anthropic thinking replay requires ThinkingSignature", i)} + } var blocks []anBlock if m.ReasoningContent != "" && m.ThinkingSignature != "" { // Anthropic requires a replayed thinking block to be the @@ -420,7 +436,7 @@ func listModelsAnthropic(ctx context.Context, pc *providerClient) ([]Model, erro for page := 0; page < 10; page++ { url := pc.base + "/v1/models?limit=100" if pageID != "" { - url += "&after_id=" + pageID + url += "&after_id=" + neturl.QueryEscape(pageID) } data, _, err := pc.get(ctx, url) if err != nil { diff --git a/anthropic_test.go b/anthropic_test.go index 4f95648..77e23a9 100644 --- a/anthropic_test.go +++ b/anthropic_test.go @@ -136,7 +136,7 @@ func TestBuildAnthropicRequest_EmptyAssistantPlaceholder(t *testing.T) { } func TestBuildAnthropicRequest_ThinkingLevels(t *testing.T) { - cases := map[string]int{"low": 1024, "medium": 8192, "high": 16384} + cases := map[string]int{"low": 1024, "medium": 8192, "high": 16384, "max": 16384} for level, want := range cases { req := &ChatRequest{Messages: []Message{{Role: RoleUser, Content: "x"}}, Thinking: level} body, _ := buildAnthropicRequest(req, "claude", false) @@ -157,6 +157,23 @@ func TestBuildAnthropicRequest_ThinkingLevels(t *testing.T) { } } +func TestBuildAnthropicRequest_ThinkingBudgetOverridesPreset(t *testing.T) { + req := &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "x"}}, + Thinking: "low", + ThinkingBudget: 4096, + } + body, err := buildAnthropicRequest(req, "claude", false) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + thinking := got["thinking"].(map[string]any) + if thinking["budget_tokens"] != float64(4096) { + t.Errorf("budget_tokens = %v, want explicit 4096", thinking["budget_tokens"]) + } +} + func TestParseAnthropicResponse(t *testing.T) { body := []byte(`{ "content": [ @@ -249,6 +266,7 @@ func TestMapAnthropicStreamEvent_Error(t *testing.T) { func TestListModelsAnthropic_Pagination(t *testing.T) { var paths []string + const pageToken = "claude+/=&" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.RequestURI()) if r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") != "2023-06-01" { @@ -256,9 +274,12 @@ func TestListModelsAnthropic_Pagination(t *testing.T) { return } if r.URL.Query().Get("after_id") == "" { - fmt.Fprint(w, `{"data":[{"id":"claude-a","display_name":"Claude A","created_at":"2026-01-02T15:04:05Z"}],"has_more":true,"last_id":"claude-a"}`) + fmt.Fprintf(w, `{"data":[{"id":"claude-a","display_name":"Claude A","created_at":"2026-01-02T15:04:05Z"}],"has_more":true,"last_id":%q}`, pageToken) return } + if got := r.URL.Query().Get("after_id"); got != pageToken { + t.Errorf("after_id = %q, want %q", got, pageToken) + } fmt.Fprint(w, `{"data":[{"id":"claude-b"}],"has_more":false}`) })) defer srv.Close() @@ -275,7 +296,7 @@ func TestListModelsAnthropic_Pagination(t *testing.T) { if models[0].CreatedAt.IsZero() { t.Errorf("CreatedAt = %v, want parsed RFC3339", models[0].CreatedAt) } - if len(paths) != 2 || paths[1] != "/v1/models?limit=100&after_id=claude-a" { + if len(paths) != 2 { t.Errorf("requests = %v, want after_id follow-up", paths) } } diff --git a/chat.go b/chat.go index fc09907..3aa04c4 100644 --- a/chat.go +++ b/chat.go @@ -127,6 +127,9 @@ func (pc *providerClient) requestTimeout() time.Duration { // buildChatRequest dispatches format-specific serialization. stream=false // yields the buffered request; stream=true the SSE request. func (pc *providerClient) buildChatRequest(req *ChatRequest, model string, stream bool) ([]byte, string, error) { + if req == nil { + return nil, "", &ConfigError{Msg: "chat request is nil"} + } // Reject unknown roles loudly: OpenAI would silently send them as user // messages and Anthropic/Gemini would silently drop them. for i, m := range req.Messages { diff --git a/gemini.go b/gemini.go index c6e8530..50e6d98 100644 --- a/gemini.go +++ b/gemini.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + neturl "net/url" "strings" ) @@ -54,6 +55,8 @@ type gmThinkCfg struct { type gmGenCfg struct { MaxOutputTokens int `json:"maxOutputTokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` ThinkingConfig *gmThinkCfg `json:"thinkingConfig,omitempty"` } @@ -67,24 +70,25 @@ type gmRequest struct { // geminiThinkingConfig maps canonical thinking to thinkingConfig. // Budgets: low 1024, medium 8192, high 24576; -1 = dynamic (provider-decided). func geminiThinkingConfig(level string, explicit int) *gmThinkCfg { + var budget int switch level { case "enabled": - b := -1 - if explicit > 0 { - b = explicit - } - return &gmThinkCfg{ThinkingBudget: b, IncludeThoughts: true} + budget = -1 case "disabled": return &gmThinkCfg{ThinkingBudget: 0} case "low": - return &gmThinkCfg{ThinkingBudget: 1024, IncludeThoughts: true} + budget = 1024 case "medium": - return &gmThinkCfg{ThinkingBudget: 8192, IncludeThoughts: true} - case "high": - return &gmThinkCfg{ThinkingBudget: 24576, IncludeThoughts: true} + budget = 8192 + case "high", "max": + budget = 24576 default: // "" return nil } + if explicit > 0 { + budget = explicit + } + return &gmThinkCfg{ThinkingBudget: budget, IncludeThoughts: true} } // wrapToolResponse ensures functionResponse.response is a JSON object: @@ -191,7 +195,10 @@ func buildGeminiRequest(req *ChatRequest, model string, stream bool) ([]byte, er out.Tools = []gmToolGroup{g} } - cfg := gmGenCfg{MaxOutputTokens: req.MaxTokens} + cfg := gmGenCfg{ + MaxOutputTokens: req.MaxTokens, + StopSequences: req.Stop, + } if req.Temperature != 0 { t := req.Temperature if t < 0 { @@ -199,10 +206,18 @@ func buildGeminiRequest(req *ChatRequest, model string, stream bool) ([]byte, er } cfg.Temperature = &t } + if req.TopP != 0 { + p := req.TopP + if p < 0 { + p = 0 + } + cfg.TopP = &p + } if tc := geminiThinkingConfig(req.Thinking, req.ThinkingBudget); tc != nil { cfg.ThinkingConfig = tc } - if cfg.MaxOutputTokens != 0 || cfg.Temperature != nil || cfg.ThinkingConfig != nil { + if cfg.MaxOutputTokens != 0 || cfg.Temperature != nil || cfg.TopP != nil || + len(cfg.StopSequences) > 0 || cfg.ThinkingConfig != nil { out.GenerationConfig = &cfg } return json.Marshal(out) @@ -368,7 +383,7 @@ func listModelsGemini(ctx context.Context, pc *providerClient) ([]Model, error) for page := 0; page < 10; page++ { url := pc.base + "/v1beta/models?pageSize=100" if pageToken != "" { - url += "&pageToken=" + pageToken + url += "&pageToken=" + neturl.QueryEscape(pageToken) } data, _, err := pc.get(ctx, url) if err != nil { diff --git a/gemini_test.go b/gemini_test.go index 87d4479..df22b85 100644 --- a/gemini_test.go +++ b/gemini_test.go @@ -103,6 +103,7 @@ func TestBuildGeminiRequest_ThinkingVariants(t *testing.T) { {"disabled", 0, false}, {"enabled", -1, false}, {"high", 24576, false}, + {"max", 24576, false}, {"", 0, true}, } for _, c := range cases { @@ -129,6 +130,24 @@ func TestBuildGeminiRequest_ThinkingVariants(t *testing.T) { } } +func TestBuildGeminiRequest_ThinkingBudgetOverridesPreset(t *testing.T) { + req := &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "x"}}, + Thinking: "low", + ThinkingBudget: 4096, + } + body, err := buildGeminiRequest(req, "gemini-2.5-pro", false) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + cfg := got["generationConfig"].(map[string]any) + thinking := cfg["thinkingConfig"].(map[string]any) + if thinking["thinkingBudget"] != float64(4096) { + t.Errorf("thinkingBudget = %v, want explicit 4096", thinking["thinkingBudget"]) + } +} + func TestParseGeminiResponse(t *testing.T) { body := []byte(`{ "candidates": [{ @@ -213,6 +232,7 @@ func TestMapGeminiStreamEvent_Chunks(t *testing.T) { func TestListModelsGemini_PaginationAndLimits(t *testing.T) { var calls int + const pageToken = "p+/=&" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if r.Header.Get("x-goog-api-key") != "k" { @@ -220,9 +240,12 @@ func TestListModelsGemini_PaginationAndLimits(t *testing.T) { return } if r.URL.Query().Get("pageToken") == "" { - fmt.Fprint(w, `{"models":[{"name":"models/gemini-2.5-pro","displayName":"Gemini 2.5 Pro","inputTokenLimit":1048576,"outputTokenLimit":65536,"supportedGenerationMethods":["generateContent","embedContent"]}],"nextPageToken":"p2"}`) + fmt.Fprintf(w, `{"models":[{"name":"models/gemini-2.5-pro","displayName":"Gemini 2.5 Pro","inputTokenLimit":1048576,"outputTokenLimit":65536,"supportedGenerationMethods":["generateContent","embedContent"]}],"nextPageToken":%q}`, pageToken) return } + if got := r.URL.Query().Get("pageToken"); got != pageToken { + t.Errorf("pageToken = %q, want %q", got, pageToken) + } fmt.Fprint(w, `{"models":[{"name":"models/gemini-2.5-flash","inputTokenLimit":1048576,"outputTokenLimit":65536}]}`) })) defer srv.Close() diff --git a/llm.go b/llm.go index 4dc7db8..8a87a56 100644 --- a/llm.go +++ b/llm.go @@ -289,10 +289,9 @@ func (p *Provider) ListModels(ctx context.Context, opts ...ListOption) ([]Model, // ── ChatClient ─────────────────────────────────────────────────────────── -// ChatClient runs chat completions against one provider+model pair. Each -// client carries its own learn-once fallbacks and request timeout; it is -// safe for concurrent use but SetRequestTimeout must be called before the -// first request. +// ChatClient runs chat completions against one provider+model pair. +// Learn-once fallbacks are shared by every client from the same Provider; +// request timeouts are per client. It is safe for concurrent use. type ChatClient struct { pc *providerClient model string diff --git a/message.go b/message.go index deab76c..f603195 100644 --- a/message.go +++ b/message.go @@ -104,9 +104,9 @@ type Usage struct { // ChatRequest is the canonical request. Model is filled from the ChatClient // when empty. Thinking accepts "", "enabled", "disabled", "low", "medium", -// "high", "max" and is translated per provider format. Temperature: 0 means use -// the provider default (field omitted); use a negative value to explicitly -// send 0. +// "high", "max" and is translated per provider format. Temperature and TopP: +// 0 means use the provider default (field omitted); use a negative value to +// explicitly send 0. Stop maps to each provider's stop-sequence field. type ChatRequest struct { Model string Messages []Message @@ -116,6 +116,8 @@ type ChatRequest struct { ThinkingBudget int MaxTokens int Temperature float64 + TopP float64 + Stop []string } // ChatResult is the canonical response for both buffered and streaming calls. diff --git a/openai.go b/openai.go index 7519704..00d436f 100644 --- a/openai.go +++ b/openai.go @@ -57,6 +57,8 @@ type oaRequest struct { MaxTokens int `json:"max_tokens,omitempty"` MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` Stream bool `json:"stream,omitempty"` StreamOptions *oaStreamOptions `json:"stream_options,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` @@ -68,6 +70,7 @@ func buildOpenAIRequest(cfg ProviderConfig, req *ChatRequest, model string, stre q := cfg.Quirks out := oaRequest{ Model: model, + Stop: req.Stop, Stream: stream, } // OpenAI o-series/gpt-5 models reject max_tokens in favor of @@ -141,6 +144,13 @@ func buildOpenAIRequest(cfg ProviderConfig, req *ChatRequest, model string, stre } out.Temperature = &t } + if req.TopP != 0 && !modelForbidsTemperature(model) { + p := req.TopP + if p < 0 { + p = 0 + } + out.TopP = &p + } if stream && includeStreamOptions { out.StreamOptions = &oaStreamOptions{IncludeUsage: true} @@ -180,6 +190,10 @@ func buildOpenAIRequest(cfg ProviderConfig, req *ChatRequest, model string, stre out.ReasoningEffort = "medium" case "low", "medium", "high": out.ReasoningEffort = req.Thinking + case "max": + // "max" is the canonical highest level. OpenAI's portable + // reasoning_effort vocabulary tops out at "high". + out.ReasoningEffort = "high" // "disabled" and "" → omit (provider default) } default: diff --git a/openai_test.go b/openai_test.go index 36759ff..81a2d67 100644 --- a/openai_test.go +++ b/openai_test.go @@ -140,7 +140,7 @@ func TestBuildOpenAIRequest_ThinkingVariants(t *testing.T) { thinking string wantEff string }{ - {"enabled", "medium"}, {"low", "low"}, {"medium", "medium"}, {"high", "high"}, {"disabled", ""}, {"", ""}, + {"enabled", "medium"}, {"low", "low"}, {"medium", "medium"}, {"high", "high"}, {"max", "high"}, {"disabled", ""}, {"", ""}, } for _, c := range cases { r := *base diff --git a/request_controls_test.go b/request_controls_test.go new file mode 100644 index 0000000..d426f57 --- /dev/null +++ b/request_controls_test.go @@ -0,0 +1,177 @@ +package llm + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +func decodeObject(t *testing.T, body []byte) map[string]any { + t.Helper() + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("decode request: %v\nbody: %s", err, body) + } + return got +} + +func TestRequestControlsMapAcrossFormats(t *testing.T) { + req := &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "hello"}}, + TopP: -1, // canonical explicit zero + Stop: []string{"END", "DONE"}, + } + + t.Run("openai", func(t *testing.T) { + wire := buildOpenAIRequest( + ProviderConfig{ID: "openai", Format: FormatOpenAI}, + req, + "gpt-4o", + false, + false, + ) + body, err := json.Marshal(wire) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + assertSamplingControls(t, got, "top_p", "stop") + }) + + t.Run("gemini", func(t *testing.T) { + body, err := buildGeminiRequest(req, "gemini-2.5-pro", false) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + cfg := got["generationConfig"].(map[string]any) + assertSamplingControls(t, cfg, "topP", "stopSequences") + }) + + t.Run("anthropic", func(t *testing.T) { + body, err := buildAnthropicRequest(req, "claude-sonnet-4", false) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + assertSamplingControls(t, got, "top_p", "stop_sequences") + }) +} + +func assertSamplingControls(t *testing.T, got map[string]any, topPKey, stopKey string) { + t.Helper() + if got[topPKey] != float64(0) { + t.Errorf("%s = %v, want explicit 0", topPKey, got[topPKey]) + } + stop, ok := got[stopKey].([]any) + if !ok || len(stop) != 2 || stop[0] != "END" || stop[1] != "DONE" { + t.Errorf("%s = %#v, want [END DONE]", stopKey, got[stopKey]) + } +} + +func TestRequestControlsOmitDefaultsAcrossFormats(t *testing.T) { + req := &ChatRequest{Messages: []Message{{Role: RoleUser, Content: "hello"}}} + tests := []struct { + name string + build func(t *testing.T) map[string]any + topPKey string + stopKey string + }{ + { + name: "openai", + build: func(t *testing.T) map[string]any { + body, err := json.Marshal(buildOpenAIRequest(ProviderConfig{Format: FormatOpenAI}, req, "gpt-4o", false, false)) + if err != nil { + t.Fatal(err) + } + return decodeObject(t, body) + }, + topPKey: "top_p", + stopKey: "stop", + }, + { + name: "gemini", + build: func(t *testing.T) map[string]any { + body, err := buildGeminiRequest(req, "gemini-2.5-pro", false) + if err != nil { + t.Fatal(err) + } + got := decodeObject(t, body) + cfg, _ := got["generationConfig"].(map[string]any) + return cfg + }, + topPKey: "topP", + stopKey: "stopSequences", + }, + { + name: "anthropic", + build: func(t *testing.T) map[string]any { + body, err := buildAnthropicRequest(req, "claude-sonnet-4", false) + if err != nil { + t.Fatal(err) + } + return decodeObject(t, body) + }, + topPKey: "top_p", + stopKey: "stop_sequences", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.build(t) + if _, ok := got[tt.topPKey]; ok { + t.Errorf("%s must be omitted at its default", tt.topPKey) + } + if _, ok := got[tt.stopKey]; ok { + t.Errorf("%s must be omitted when empty", tt.stopKey) + } + }) + } +} + +func TestChatClientRejectsNilRequest(t *testing.T) { + pc := newProviderClient( + ProviderConfig{ID: "openai", Format: FormatOpenAI, BaseURL: "http://127.0.0.1:1", APIKey: "k"}, + nil, + nil, + ) + client := &ChatClient{pc: pc, model: "gpt-4o"} + + tests := []struct { + name string + call func() error + }{ + { + name: "buffered", + call: func() error { + _, err := client.Call(context.Background(), nil) + return err + }, + }, + { + name: "streaming", + call: func() error { + _, err := client.CallStream(context.Background(), nil, func(Delta) error { return nil }) + return err + }, + }, + { + name: "nil handler", + call: func() error { + _, err := client.CallStream(context.Background(), nil, nil) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfgErr *ConfigError + if err := tt.call(); !errors.As(err, &cfgErr) { + t.Fatalf("error = %v, want ConfigError", err) + } + }) + } +} diff --git a/translation_test.go b/translation_test.go index ecd9a85..dc01e7e 100644 --- a/translation_test.go +++ b/translation_test.go @@ -77,6 +77,22 @@ func TestAnthropicThinkingRoundTripBuffered(t *testing.T) { } } +func TestAnthropicThinkingReplayRequiresSignature(t *testing.T) { + req := &ChatRequest{Messages: []Message{ + {Role: RoleUser, Content: "q"}, + { + Role: RoleAssistant, + ReasoningContent: "unsigned thinking", + ToolCalls: []ToolCall{{ID: "tu_1", Name: "f", Arguments: `{}`}}, + }, + }} + _, err := buildAnthropicRequest(req, "claude", false) + var cfgErr *ConfigError + if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), "ThinkingSignature") { + t.Fatalf("error = %v, want ConfigError naming ThinkingSignature", err) + } +} + // Streaming: signature_delta must be captured into the result's // ThinkingSignature, mirroring the buffered path. func TestAnthropicStreamSignatureCapture(t *testing.T) { diff --git a/transport.go b/transport.go index 18a170f..18c87b5 100644 --- a/transport.go +++ b/transport.go @@ -31,7 +31,6 @@ func newPooledTransport() *http.Transport { DialContext: (&net.Dialer{ Timeout: defaultDialTimeout, KeepAlive: defaultKeepAlive, - DualStack: true, }).DialContext, ForceAttemptHTTP2: true, }