Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Multi-provider Go SDK for LLM inference endpoints — **OpenAI, Google Gemini, D
- **Multiple authenticated endpoints at once** — auto-discovered from `<PROVIDER>_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).

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 25 additions & 9 deletions anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"strings"
"time"
)
Expand Down Expand Up @@ -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"`
}
Expand All @@ -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 {
Expand All @@ -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,
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 24 additions & 3 deletions anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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": [
Expand Down Expand Up @@ -249,16 +266,20 @@ 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" {
w.WriteHeader(400)
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()
Expand All @@ -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)
}
}
3 changes: 3 additions & 0 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 27 additions & 12 deletions gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"strings"
)

Expand Down Expand Up @@ -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"`
}

Expand All @@ -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:
Expand Down Expand Up @@ -191,18 +195,29 @@ 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 {
t = 0
}
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)
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 24 additions & 1 deletion gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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": [{
Expand Down Expand Up @@ -213,16 +232,20 @@ 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" {
w.WriteHeader(400)
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()
Expand Down
7 changes: 3 additions & 4 deletions llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading