fix(llamacpp,anthropic): SSE headroom, no-choices guard, real context window

- llamacpp: 4MB SSE scanner buffer (the 64KB bufio.Scanner default killed
  streams whose single line exceeded it, e.g. a write tool call carrying a
  whole file) and an empty-choices guard in toResponse instead of a panic;
  request payload now uses bytes.NewReader (drops a full string copy).
- anthropic: Capabilities() reported a 1M-token context window for any
  non-haiku model. Callers use that number to decide when to compact, so
  compaction would have fired far too late and requests overflowed the
  real window. Default is now the standard 200k, configurable via
  Config.ContextWindow for extended-window models/plans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Victor Hugo Vargas Servin 2026-07-12 16:14:15 -07:00
parent 838eef642a
commit e6830161bc
2 changed files with 115 additions and 93 deletions

View file

@ -7,8 +7,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http"
"iter" "iter"
"net/http"
"strings" "strings"
"github.com/VictorVargas/rony-llm-agent/pkg/llm" "github.com/VictorVargas/rony-llm-agent/pkg/llm"
@ -19,6 +19,13 @@ const (
defaultModel = "claude-opus-4-8" defaultModel = "claude-opus-4-8"
defaultMaxTokens = 8192 defaultMaxTokens = 8192
anthropicVersion = "2023-06-01" anthropicVersion = "2023-06-01"
// defaultContextWindow is what Capabilities() reports when
// Config.ContextWindow is unset: 200k tokens, the standard window for
// Claude models. Callers use this number to decide when to compact
// their conversation, so over-reporting it (the old code assumed 1M
// for anything that wasn't haiku) meant compaction fired far too late
// and requests started overflowing the real window.
defaultContextWindow = 200000
) )
// Config holds the settings needed to create an Anthropic client. // Config holds the settings needed to create an Anthropic client.
@ -27,6 +34,7 @@ type Config struct {
Model string // defaults to claude-opus-4-8 Model string // defaults to claude-opus-4-8
BaseURL string // defaults to https://api.anthropic.com/v1 BaseURL string // defaults to https://api.anthropic.com/v1
MaxTokens int // default max_tokens sent on every request (Anthropic requires one); 0 = defaultMaxTokens MaxTokens int // default max_tokens sent on every request (Anthropic requires one); 0 = defaultMaxTokens
ContextWindow int // model's context window in tokens (0 = defaultContextWindow); raise it only for models/plans with an extended window
Temperature *float32 Temperature *float32
TopP *float32 TopP *float32
} }
@ -37,6 +45,7 @@ type Client struct {
baseURL string baseURL string
model string model string
maxTokens int maxTokens int
contextWindow int
temperature *float32 temperature *float32
topP *float32 topP *float32
http *http.Client http *http.Client
@ -63,11 +72,17 @@ func New(cfg Config) (*Client, error) {
maxTokens = defaultMaxTokens maxTokens = defaultMaxTokens
} }
contextWindow := cfg.ContextWindow
if contextWindow == 0 {
contextWindow = defaultContextWindow
}
return &Client{ return &Client{
apiKey: cfg.APIKey, apiKey: cfg.APIKey,
baseURL: baseURL, baseURL: baseURL,
model: model, model: model,
maxTokens: maxTokens, maxTokens: maxTokens,
contextWindow: contextWindow,
temperature: cfg.Temperature, temperature: cfg.Temperature,
topP: cfg.TopP, topP: cfg.TopP,
http: http.DefaultClient, http: http.DefaultClient,
@ -257,15 +272,11 @@ func (c *Client) Name() string {
} }
func (c *Client) Capabilities() llm.ProviderCapabilities { func (c *Client) Capabilities() llm.ProviderCapabilities {
maxContext := 1000000
if strings.Contains(c.model, "haiku") {
maxContext = 200000
}
return llm.ProviderCapabilities{ return llm.ProviderCapabilities{
SupportsTools: true, SupportsTools: true,
SupportsVision: true, SupportsVision: true,
SupportsJSON: true, SupportsJSON: true,
MaxContextWindow: maxContext, MaxContextWindow: c.contextWindow,
} }
} }

View file

@ -2,12 +2,13 @@ package llamacpp
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http"
"iter" "iter"
"net/http"
"strings" "strings"
"time" "time"
@ -123,7 +124,7 @@ func (c *Client) Generate(ctx context.Context, req llm.CompletionRequest) (llm.C
return llm.CompletionResponse{}, fmt.Errorf("decoding response: %w", err) return llm.CompletionResponse{}, fmt.Errorf("decoding response: %w", err)
} }
return c.toResponse(apiResp), nil return c.toResponse(apiResp)
} }
func (c *Client) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] { func (c *Client) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] {
@ -191,6 +192,11 @@ func (c *Client) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq
} }
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
// A single SSE line can exceed bufio.Scanner's 64KB default cap
// (e.g. a large tool-call arguments delta or a long reasoning
// event), which would kill the stream mid-turn with "token too
// long" — same headroom the anthropic client already reserves.
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if !strings.HasPrefix(line, "data: ") { if !strings.HasPrefix(line, "data: ") {
@ -370,11 +376,16 @@ func (c *Client) buildRequest(req llm.CompletionRequest, stream bool) (io.Reader
if err != nil { if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err) return nil, fmt.Errorf("marshaling request: %w", err)
} }
return strings.NewReader(string(data)), nil return bytes.NewReader(data), nil
} }
// toResponse converts a llama.cpp API response to our CompletionResponse. // toResponse converts a llama.cpp API response to our CompletionResponse.
func (c *Client) toResponse(resp llamaChatResponse) llm.CompletionResponse { func (c *Client) toResponse(resp llamaChatResponse) (llm.CompletionResponse, error) {
// Guard against a 200 response with no choices (e.g. a misbehaving
// server or proxy) — indexing Choices[0] blindly panics the whole app.
if len(resp.Choices) == 0 {
return llm.CompletionResponse{}, fmt.Errorf("llamacpp: response contained no choices")
}
choice := resp.Choices[0] choice := resp.Choices[0]
result := llm.CompletionResponse{ result := llm.CompletionResponse{
ID: resp.ID, ID: resp.ID,
@ -398,7 +409,7 @@ func (c *Client) toResponse(resp llamaChatResponse) llm.CompletionResponse {
TotalTokens: resp.Usage.TotalTokens, TotalTokens: resp.Usage.TotalTokens,
} }
return result return result, nil
} }
// llama.cpp API types // llama.cpp API types