Files
go-llm/v2/chat.go
T
steve cbaf41f50c
CI / Root Module (push) Failing after 1m30s
CI / Lint (push) Failing after 1m1s
CI / V2 Module (push) Successful in 3m41s
feat(v2): add ReasoningLevel option; thinking/reasoning across providers
Introduces an opt-in level-based reasoning toggle (low/medium/high) that
each provider translates to its native parameter:

- Anthropic: thinking.budget_tokens (1024/8000/24000), with temperature
  forced to default and MaxTokens auto-grown above the budget.
- OpenAI/xAI/Groq via openaicompat: reasoning_effort string, gated by a
  new Rules.SupportsReasoning predicate so non-reasoning models don't
  receive the parameter. xAI uses Rules.MapReasoningEffort to remap
  "medium" to "high" since its API only accepts low|high.
- Google: thinking_config.thinking_budget + include_thoughts:true.
- DeepSeek: SupportsReasoning=false (reasoner is always-on; the
  reasoning_content trace was already extracted via openaicompat).

Reasoning content is surfaced as Response.Thinking on Complete and as
StreamEventThinking deltas during streaming. Provider-side: extracted
from Anthropic thinking content blocks, Google's part.Thought=true
parts, and the non-standard reasoning_content field that DeepSeek and
Groq emit (parsed out of raw JSON since openai-go doesn't type it).

Public API:
  - llm.ReasoningLevel + ReasoningLow/Medium/High constants
  - llm.WithReasoning(level) request option
  - Model.WithReasoning(level) for baked-in defaults
  - provider.Request.Reasoning, provider.Response.Thinking
  - provider.StreamEventThinking

Tests cover Rules-based gating, MapReasoningEffort, reasoning_content
extraction (Complete + Stream), Anthropic budget mapping, and
temperature suppression when thinking is enabled. Existing behavior is
unchanged when Reasoning is the empty string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 03:58:42 +00:00

155 lines
4.2 KiB
Go

package llm
import (
"context"
"fmt"
)
// Chat manages a multi-turn conversation with automatic history tracking
// and optional automatic tool-call execution.
type Chat struct {
model *Model
messages []Message
tools *ToolBox
opts []RequestOption
}
// NewChat creates a new conversation with the given model.
func NewChat(model *Model, opts ...RequestOption) *Chat {
return &Chat{
model: model,
opts: opts,
}
}
// SetSystem sets or replaces the system message.
func (c *Chat) SetSystem(text string) {
filtered := make([]Message, 0, len(c.messages)+1)
for _, m := range c.messages {
if m.Role != RoleSystem {
filtered = append(filtered, m)
}
}
c.messages = append([]Message{SystemMessage(text)}, filtered...)
}
// SetTools configures the tools available for this chat.
func (c *Chat) SetTools(tb *ToolBox) {
c.tools = tb
}
// Send sends a user message and returns the assistant's text response along with
// accumulated token usage from all iterations of the tool-call loop.
// If the model calls tools, they are executed automatically and the loop
// continues until the model produces a text response (the "agent loop").
func (c *Chat) Send(ctx context.Context, text string) (string, *Usage, error) {
return c.SendMessage(ctx, UserMessage(text))
}
// SendWithImages sends a user message with images attached.
func (c *Chat) SendWithImages(ctx context.Context, text string, images ...Image) (string, *Usage, error) {
return c.SendMessage(ctx, UserMessageWithImages(text, images...))
}
// SendMessage sends an arbitrary message and returns the final text response along with
// accumulated token usage from all iterations of the tool-call loop.
// Handles the full tool-call loop automatically.
func (c *Chat) SendMessage(ctx context.Context, msg Message) (string, *Usage, error) {
c.messages = append(c.messages, msg)
opts := c.buildOpts()
var totalUsage *Usage
for {
resp, err := c.model.Complete(ctx, c.messages, opts...)
if err != nil {
return "", totalUsage, fmt.Errorf("completion failed: %w", err)
}
totalUsage = addUsage(totalUsage, resp.Usage)
c.messages = append(c.messages, resp.Message())
if !resp.HasToolCalls() {
return resp.Text, totalUsage, nil
}
if c.tools == nil {
return "", totalUsage, ErrNoToolsConfigured
}
toolResults, err := c.tools.ExecuteAll(ctx, resp.ToolCalls)
if err != nil {
return "", totalUsage, fmt.Errorf("tool execution failed: %w", err)
}
c.messages = append(c.messages, toolResults...)
}
}
// SendRaw sends a message and returns the raw Response without automatic tool execution.
// Useful when you want to handle tool calls manually.
func (c *Chat) SendRaw(ctx context.Context, msg Message) (Response, error) {
c.messages = append(c.messages, msg)
opts := c.buildOpts()
resp, err := c.model.Complete(ctx, c.messages, opts...)
if err != nil {
return Response{}, err
}
c.messages = append(c.messages, resp.Message())
return resp, nil
}
// SendStream sends a user message and returns a StreamReader for streaming responses.
func (c *Chat) SendStream(ctx context.Context, text string) (*StreamReader, error) {
c.messages = append(c.messages, UserMessage(text))
cfg := c.model.newRequestConfig(c.buildOpts())
req := buildProviderRequest(c.model.model, c.messages, cfg)
return newStreamReader(ctx, c.model.provider, req)
}
// AddToolResults manually adds tool results to the conversation.
// Use with SendRaw when handling tool calls manually.
func (c *Chat) AddToolResults(results ...Message) {
c.messages = append(c.messages, results...)
}
// Messages returns the current conversation history (read-only copy).
func (c *Chat) Messages() []Message {
cp := make([]Message, len(c.messages))
copy(cp, c.messages)
return cp
}
// Reset clears the conversation history.
func (c *Chat) Reset() {
c.messages = nil
}
// Fork creates a copy of this chat with identical history, for branching conversations.
func (c *Chat) Fork() *Chat {
c2 := &Chat{
model: c.model,
messages: make([]Message, len(c.messages)),
tools: c.tools,
opts: c.opts,
}
copy(c2.messages, c.messages)
return c2
}
func (c *Chat) buildOpts() []RequestOption {
opts := make([]RequestOption, len(c.opts))
copy(opts, c.opts)
if c.tools != nil {
opts = append(opts, WithTools(c.tools))
}
return opts
}