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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ go test -tags e2e -run 'TestE2E' -timeout 15m -v . # LIVE provider e2e (see be
| `message.go` | Canonical types: `ChatRequest`, `Message`, `ChatResult`, `Delta`, `Usage`, `ToolDef` |
| `chat.go` | `providerClient`: retry orchestration (buffered + streaming), error classification, learn-once consumption, SSE pump wiring, `httpError` parsing |
| `openai.go` / `gemini.go` / `anthropic.go` | Per-format request builders, response/stream mappers, model listing |
| `responses.go` | OpenAI Responses API (`/v1/responses`) for GPT-5.6+ tools+reasoning |
| `sse.go` | SSE parser (abort-safe via `done` channel) + idle-watchdog pump |
| `retry.go` | Backoff/jitter/`Retry-After`/`retrySleep` (8 attempts, cap 30s) |
| `provider.go` | Built-in registry, quirks flags, config validation |
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ Multi-provider Go SDK for LLM inference endpoints — **OpenAI, Google Gemini, D
- **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.
- **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 (GPT-5.6+ retries on `/v1/responses` so reasoning stays on).
- **Predictable under load** — goroutine-leak-free streaming, race-clean shared state, and a canonical-only error vocabulary (API keys never leak into error text).

## Install

```bash
go get github.com/BackendStack21/go-llm-sdk@v0.2.2
go get github.com/BackendStack21/go-llm-sdk@v0.3.2
```

Requires Go 1.25+. No dependencies beyond the standard library.
Expand Down Expand Up @@ -168,6 +168,7 @@ On Gemini, a tool result's `ToolName` may be omitted — the SDK recovers the fu

## Extended thinking

- **OpenAI GPT-5.6+** — function tools plus reasoning cannot ride Chat Completions (`reasoning_effort` 400s). Those calls go to `POST /v1/responses` with `reasoning.effort` and `reasoning.summary=auto`; summaries land in `ReasoningContent` and encrypted reasoning replays via `ThinkingSignature`. `Thinking: disabled` stays on Chat Completions with `reasoning_effort: none`. Other OpenAI models keep `reasoning_effort` on Chat Completions.
- **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`.
Expand All @@ -181,7 +182,8 @@ When a provider rejects a request pattern, the SDK learns the constraint **once
| Trigger (provider 400) | Learned fallback |
|---|---|
| Rejects `stream_options` | omit `stream_options` from streaming requests |
| Rejects `reasoning_effort` + tools | pin `reasoning_effort: "none"` |
| Names `/v1/responses` as the tools+reasoning path | retry on `POST /responses` (keeps reasoning on) |
| Rejects `reasoning_effort` + tools (legacy) | pin `reasoning_effort: "none"` |
| Rejects streaming itself | downgrade to buffered calls permanently |
| Answers a streamed request with a non-SSE body | downgrade to buffered calls permanently |

Expand Down Expand Up @@ -248,7 +250,7 @@ See [AGENTS.md](AGENTS.md) for the architecture map, invariants, testing convent

## Status

v0.2.2 — API may shift until the odek integration lands, then v1.0.
v0.3.2 — API may shift until v1.0.

## License

Expand Down
51 changes: 46 additions & 5 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,17 @@ import (
// - Consumer abort (delta handler error) returns the partial result
// alongside *StreamAbortedError.
// - Learn-once fallbacks: drop stream_options (field level), fall back to
// the buffered path (provider rejects streaming), pin reasoning_effort
// "none" (provider rejects effort combined with tools).
// the buffered path (provider rejects streaming), POST /responses
// (GPT-5.6+ rejects effort+tools on Chat Completions), pin
// reasoning_effort "none" (legacy gateways that reject effort+tools).
//
// learnOnce holds the learn-once fallback flags. They live on the Provider
// (shared across every ChatClient minted from it) so a constraint the
// provider teaches one client is honored by all of them.
type learnOnce struct {
dropStreamOptions atomic.Bool
forceBuffered atomic.Bool
forceResponses atomic.Bool
forceNoneEffort atomic.Bool
}

Expand Down Expand Up @@ -156,6 +158,10 @@ func (pc *providerClient) buildChatRequest(req *ChatRequest, model string, strea
}
return body, fmt.Sprintf("%s/v1beta/models/%s:generateContent", pc.base, model), err
default: // FormatOpenAI
if useResponsesAPI(pc.learn, pc.cfg.Format, model, req) {
body, err := json.Marshal(buildResponsesRequest(req, model, stream))
return body, pc.base + "/responses", err
}
oa := buildOpenAIRequest(pc.cfg, req, model, stream, !pc.learn.dropStreamOptions.Load())
if pc.learn.forceNoneEffort.Load() && len(req.Tools) > 0 {
oa = reasoningEffortNonePatched(oa)
Expand Down Expand Up @@ -292,9 +298,23 @@ func reasoningEffortRejected(err error) bool {
if !errors.As(err, &e) || e.Status != http.StatusBadRequest {
return false
}
if responsesRequired(err) {
return false
}
return strings.Contains(e.Message, "reasoning_effort")
}

// responsesRequired reports a 400 that names /v1/responses as the way to
// keep function tools together with reasoning (GPT-5.6+ Chat Completions).
func responsesRequired(err error) bool {
var e *APIError
if !errors.As(err, &e) || e.Status != http.StatusBadRequest {
return false
}
m := strings.ToLower(e.Message)
return strings.Contains(m, "/v1/responses")
}

// streamOptionsRejected classifies a 400 naming stream_options.
func streamOptionsRejected(e *APIError) bool {
return e != nil && e.Status == http.StatusBadRequest &&
Expand Down Expand Up @@ -387,6 +407,17 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
return nil, ctx.Err()
}
continue
case apiErr.Status == http.StatusBadRequest && len(req.Tools) > 0 &&
!pc.learn.forceResponses.Load() && responsesRequired(apiErr):
// GPT-5.6+ (and some 5.4/5.5 payloads) reject
// effort+tools on Chat Completions; retry on /responses
// so reasoning stays on.
pc.learn.forceResponses.Store(true)
lastErr = apiErr
if attempt < maxRetries {
continue
}
return nil, apiErr
case apiErr.Status == http.StatusBadRequest && len(req.Tools) > 0 &&
!pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(apiErr):
// Learn the constraint once; retry immediately with
Expand Down Expand Up @@ -417,7 +448,7 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
}
return nil, fmt.Errorf("llm: retry exhausted (%d attempts): %w", maxRetries+1, err)
}
return pc.parseResponse(data)
return pc.parseResponse(data, url)
}
if rateErr != nil {
return nil, &RateLimitError{APIError: *rateErr, Attempts: maxRetries + 1, RetryAfter: rateRA}
Expand All @@ -426,13 +457,16 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
}

// parseResponse dispatches format-specific buffered parsing.
func (pc *providerClient) parseResponse(data []byte) (*ChatResult, error) {
func (pc *providerClient) parseResponse(data []byte, url string) (*ChatResult, error) {
switch pc.cfg.Format {
case FormatAnthropic:
return parseAnthropicResponse(data)
case FormatGemini:
return parseGeminiResponse(data)
default:
if isResponsesURL(url) {
return parseResponsesAPI(data)
}
return parseOpenAIResponse(data)
}
}
Expand Down Expand Up @@ -482,8 +516,12 @@ func (pc *providerClient) callStream(ctx context.Context, req *ChatRequest, mode
if err != nil {
return nil, err
}
attemptMapper := mapper
if isResponsesURL(url) {
attemptMapper = mapResponsesStreamEvent
}

out := pc.attemptStream(deadlineCtx, url, body, mapper, onDelta, len(req.Tools) > 0)
out := pc.attemptStream(deadlineCtx, url, body, attemptMapper, onDelta, len(req.Tools) > 0)
switch {
case out.success():
return out.result, nil
Expand Down Expand Up @@ -581,6 +619,9 @@ func (pc *providerClient) attemptStream(ctx context.Context, url string, body []
case streamOptionsRejected(e):
pc.learn.dropStreamOptions.Store(true)
return streamOutcome{learnRetry: true, apiErr: e}
case learnEffort && !pc.learn.forceResponses.Load() && responsesRequired(e):
pc.learn.forceResponses.Store(true)
return streamOutcome{learnRetry: true, apiErr: e}
case learnEffort && !pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(e):
pc.learn.forceNoneEffort.Store(true)
return streamOutcome{learnRetry: true, apiErr: e}
Expand Down
4 changes: 4 additions & 0 deletions dispatch_edges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ func TestReasoningEffortRejectedShape(t *testing.T) {
if !reasoningEffortRejected(&APIError{Status: 400, Message: "reasoning_effort unsupported"}) {
t.Error("400 + reasoning_effort must classify")
}
gpt56 := "Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'."
if reasoningEffortRejected(&APIError{Status: 400, Message: gpt56}) {
t.Error("gpt-5.6 responses-required 400 must not pin effort none")
}
}

func TestReasoningEffortNonePatchedClearsThinking(t *testing.T) {
Expand Down
191 changes: 191 additions & 0 deletions e2e_openai_reasoning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//go:build e2e

package llm

import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"time"
)

// OpenAI reasoning arms. OPENAI_API_KEY comes from env or the repo .env
// (never logged). Default model is a reasoning-capable one; override with
// OPENAI_E2E_MODEL. Per house rules: probe softly — assert only what the
// SDK guarantees (call success, parsing, canonical finish). Whether the
// provider returns reasoning *text* or reasoning *tokens* is server-side
// behavior, not an SDK contract.

type pathSpy struct {
http.RoundTripper
paths []string
}

func (s *pathSpy) RoundTrip(req *http.Request) (*http.Response, error) {
s.paths = append(s.paths, req.Method+" "+req.URL.Path)
rt := s.RoundTripper
if rt == nil {
rt = http.DefaultTransport
}
return rt.RoundTrip(req)
}

func e2eOpenAIChat(t *testing.T) *ChatClient {
t.Helper()
return e2eOpenAIChatSpy(t, &pathSpy{})
}

func e2eOpenAIChatSpy(t *testing.T, spy *pathSpy) *ChatClient {
t.Helper()
e2eEnvKey(t, "OPENAI_API_KEY")
base := http.DefaultTransport
if spy.RoundTripper != nil {
base = spy.RoundTripper
}
spy.RoundTripper = base
sdk := New(FromEnv(), WithTransport(spy))
cc, err := sdk.Chat("openai", openaiE2EModel())
if err != nil {
t.Fatalf("Chat(openai, %s): %v", openaiE2EModel(), err)
}
return cc
}

func openaiE2EModel() string {
if v := strings.TrimSpace(os.Getenv("OPENAI_E2E_MODEL")); v != "" {
return v
}
return "gpt-5-mini"
}

// OpenAI reasoning, buffered: Thinking=medium must produce a successful
// call with canonical finish; reasoning must be observable somewhere —
// reasoning_content text or Usage.ReasoningTokens.
func TestE2EOpenAIReasoningBuffered(t *testing.T) {
cc := e2eOpenAIChat(t)
res, err := cc.Call(e2eCtx(t, 180*time.Second), &ChatRequest{
Messages: []Message{{Role: RoleUser, Content: "A clock shows 3:15. What is the angle in degrees between the hour and minute hands? Work it out, then answer with the number only."}},
Thinking: "medium",
MaxTokens: 2000,
})
if err != nil {
t.Fatalf("Call: %v", err)
}
if res.FinishReason != FinishStop && res.FinishReason != FinishLength {
t.Errorf("finish = %q, want stop or length", res.FinishReason)
}
if res.ReasoningContent == "" && res.Usage.ReasoningTokens == 0 {
t.Errorf("no reasoning observable: ReasoningContent=%q ReasoningTokens=%d",
res.ReasoningContent, res.Usage.ReasoningTokens)
}
if res.ReasoningContent != "" {
t.Logf("reasoning text captured (%d chars)", len(res.ReasoningContent))
}
if res.Usage.ReasoningTokens > 0 {
t.Logf("reasoning tokens: %d", res.Usage.ReasoningTokens)
}
}

// OpenAI reasoning, streaming: reasoning deltas (DeltaReasoning) and/or the
// usage chunk's reasoning tokens must be observable; canonical finish.
func TestE2EOpenAIReasoningStreaming(t *testing.T) {
cc := e2eOpenAIChat(t)
var sawReasoningDeltas bool
res, err := cc.CallStream(e2eCtx(t, 180*time.Second), &ChatRequest{
Messages: []Message{{Role: RoleUser, Content: "A water lily patch doubles in size every day. It covers the whole lake on day 48. On which day was it half covered? Answer with the day number only."}},
Thinking: "medium",
MaxTokens: 2000,
}, func(d Delta) error {
if d.Kind == DeltaReasoning && d.Text != "" {
sawReasoningDeltas = true
}
return nil
})
if err != nil {
t.Fatalf("CallStream: %v", err)
}
if res.FinishReason != FinishStop && res.FinishReason != FinishLength {
t.Errorf("finish = %q, want stop or length", res.FinishReason)
}
if res.Usage.ReasoningTokens == 0 && !sawReasoningDeltas {
// Provider-side: gpt-5-mini sometimes skips reasoning entirely on
// short prompts. Capture paths are unit-covered (openai_test.go:
// stream usage details + reasoning deltas); live is a probe.
t.Logf("no reasoning observable on stream (server-side elision): deltas=%v ReasoningTokens=%d",
sawReasoningDeltas, res.Usage.ReasoningTokens)
}
if sawReasoningDeltas {
t.Log("reasoning deltas captured")
}
t.Logf("usage: %+v content=%q finish=%q", res.Usage, res.Content, res.FinishReason)
if res.Usage.ReasoningTokens > 0 {
t.Logf("reasoning tokens: %d", res.Usage.ReasoningTokens)
}
}

// Thinking=disabled on a reasoning model must still succeed with a clean
// call (reasoning_effort omitted / none path), proving the effort control
// round-trips both ways.
func TestE2EOpenAIReasoningDisabled(t *testing.T) {
cc := e2eOpenAIChat(t)
res, err := cc.Call(e2eCtx(t, 120*time.Second), &ChatRequest{
Messages: []Message{{Role: RoleUser, Content: "Reply with exactly: OK"}},
Thinking: "disabled",
MaxTokens: 500,
})
if err != nil {
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusBadRequest {
t.Fatalf("disabled thinking rejected by provider: %v (SDK must omit effort on disabled)", err)
}
t.Fatalf("Call: %v", err)
}
if !strings.Contains(strings.ToUpper(res.Content), "OK") {
t.Errorf("content = %q, want it to contain OK", res.Content)
}
}

// Tools + thinking on GPT-5.6 must stay on /v1/responses (Chat Completions
// 400s and the old learn-once path pinned effort none). A dummy tool is
// enough: the request carries tools even if the model never calls it.
func TestE2EOpenAIReasoningWithTools(t *testing.T) {
spy := &pathSpy{}
cc := e2eOpenAIChatSpy(t, spy)
res, err := cc.Call(e2eCtx(t, 180*time.Second), &ChatRequest{
Messages: []Message{{Role: RoleUser, Content: "A clock shows 3:15. What is the angle in degrees between the hour and minute hands? Work it out, then answer with the number only."}},
Tools: []ToolDef{{
Name: "noop",
Description: "Do nothing. Never call this.",
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
}},
Thinking: "medium",
MaxTokens: 2000,
})
t.Logf("paths=%v", spy.paths)
if err != nil {
var ae *APIError
if errors.As(err, &ae) && ae.Status == http.StatusBadRequest {
t.Fatalf("tools+thinking rejected (must use /responses, not pin none): %v", err)
}
t.Fatalf("Call: %v", err)
}
joined := strings.Join(spy.paths, " ")
if strings.Contains(openaiE2EModel(), "gpt-5.6") && !strings.Contains(joined, "/responses") {
t.Errorf("gpt-5.6 tools+thinking must POST /responses, got %v", spy.paths)
}
if strings.Count(joined, "/chat/completions") > 0 && strings.Contains(openaiE2EModel(), "gpt-5.6") {
t.Errorf("gpt-5.6 tools+thinking must not fall back to chat/completions, got %v", spy.paths)
}
if res.FinishReason != FinishStop && res.FinishReason != FinishLength && res.FinishReason != FinishToolCalls {
t.Errorf("finish = %q, want stop, length, or tool_calls", res.FinishReason)
}
if res.ReasoningContent == "" && res.Usage.ReasoningTokens == 0 {
t.Errorf("no reasoning with tools: ReasoningContent=%q ReasoningTokens=%d usage=%+v",
res.ReasoningContent, res.Usage.ReasoningTokens, res.Usage)
}
t.Logf("content=%q reasoning_chars=%d reasoning_tokens=%d finish=%q tools=%d usage=%+v",
res.Content, len(res.ReasoningContent), res.Usage.ReasoningTokens, res.FinishReason, len(res.ToolCalls), res.Usage)
}
3 changes: 2 additions & 1 deletion message.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ type Message struct {
Content string
ReasoningContent string
// ThinkingSignature authenticates ReasoningContent for providers that
// require thinking to be replayed verbatim (Anthropic signature).
// require thinking to be replayed verbatim (Anthropic signature, OpenAI
// Responses encrypted_content).
ThinkingSignature string
ToolCalls []ToolCall
ToolCallID string
Expand Down
Loading