Add reasoning effort

This commit is contained in:
dwrz
2026-07-07 15:14:07 +00:00
parent c730ecdc22
commit 27fa702071
9 changed files with 192 additions and 122 deletions

View File

@@ -157,11 +157,12 @@ func (j *Job) Run(ctx context.Context) Result {
} }
res, err := j.llm.Completions( res, err := j.llm.Completions(
ctx, ctx, llm.CompletionsParams{
j.model, Model: j.model,
j.SystemMessage(), SystemMessage: j.SystemMessage(),
j.MaxIterations(), MaxIterations: j.MaxIterations(),
messages, Messages: messages,
},
) )
duration := time.Since(start) duration := time.Since(start)

View File

@@ -25,6 +25,15 @@ type CompletionsResult struct {
Timings *Timings Timings *Timings
} }
// CompletionsParams holds parameters for Completions and CompletionsStream.
type CompletionsParams struct {
Model string
SystemMessage string
MaxIterations int
Messages []Message
ThinkingBudgetTokens *int
}
// Completions sends messages to the LLM using the specified model. // Completions sends messages to the LLM using the specified model.
// If systemMessage is non-empty, it overrides the configured system message. // If systemMessage is non-empty, it overrides the configured system message.
// maxIterations caps the number of agent loop iterations. // maxIterations caps the number of agent loop iterations.
@@ -32,15 +41,14 @@ type CompletionsResult struct {
// Returns all messages generated during the completions request, including // Returns all messages generated during the completions request, including
// tool calls and tool results, along with usage and timings from the final response. // tool calls and tool results, along with usage and timings from the final response.
func (c *Client) Completions( func (c *Client) Completions(
ctx context.Context, ctx context.Context, p CompletionsParams,
model string,
systemMessage string,
maxIterations int,
messages []Message,
) (*CompletionsResult, error) { ) (*CompletionsResult, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeout) ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel() defer cancel()
model, systemMessage, maxIterations, messages, thinkingBudgetTokens :=
p.Model, p.SystemMessage, p.MaxIterations, p.Messages, p.ThinkingBudgetTokens
// Use request system message if provided, config default otherwise. // Use request system message if provided, config default otherwise.
smsg := c.systemMessage smsg := c.systemMessage
if systemMessage != "" { if systemMessage != "" {
@@ -68,7 +76,7 @@ func (c *Client) Completions(
var finalUsage *Usage var finalUsage *Usage
var finalTimings *Timings var finalTimings *Timings
for i := 0; maxIterations == 0 || i < maxIterations; i++ { for i := 0; maxIterations == 0 || i < maxIterations; i++ {
cr, err := c.completions(ctx, model, messages) cr, err := c.completions(ctx, model, thinkingBudgetTokens, messages)
if err != nil { if err != nil {
return nil, fmt.Errorf("completions: %w", err) return nil, fmt.Errorf("completions: %w", err)
} }
@@ -106,12 +114,18 @@ func (c *Client) Completions(
// completions sends a single non-streaming request and returns the // completions sends a single non-streaming request and returns the
// full API response. // full API response.
func (c *Client) completions( func (c *Client) completions(
ctx context.Context, model string, messages []Message, ctx context.Context, model string, thinkingBudgetTokens *int, messages []Message,
) (*ChatResponse, error) { ) (*ChatResponse, error) {
kwargs := map[string]any{}
if thinkingBudgetTokens != nil {
kwargs["enable_thinking"] = *thinkingBudgetTokens != 0
}
body, err := json.Marshal(ChatRequest{ body, err := json.Marshal(ChatRequest{
Model: model, Model: model,
Messages: messages, Messages: messages,
Tools: c.tools, Tools: c.tools,
ThinkingBudgetTokens: thinkingBudgetTokens,
ChatTemplateKWARGS: kwargs,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -232,17 +246,15 @@ const (
// means unlimited (no cap). // means unlimited (no cap).
// Returns ErrMaxIterations if the iteration limit is exceeded. // Returns ErrMaxIterations if the iteration limit is exceeded.
func (c *Client) CompletionsStream( func (c *Client) CompletionsStream(
ctx context.Context, ctx context.Context, p CompletionsParams, events chan<- StreamEvent,
model string,
systemMessage string,
maxIterations int,
messages []Message,
events chan<- StreamEvent,
) error { ) error {
defer close(events) defer close(events)
ctx, cancel := context.WithTimeout(ctx, c.timeout) ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel() defer cancel()
model, systemMessage, maxIterations, messages, thinkingBudgetTokens :=
p.Model, p.SystemMessage, p.MaxIterations, p.Messages, p.ThinkingBudgetTokens
// Use request system message if provided, config default otherwise. // Use request system message if provided, config default otherwise.
smsg := c.systemMessage smsg := c.systemMessage
if systemMessage != "" { if systemMessage != "" {
@@ -265,7 +277,7 @@ func (c *Client) CompletionsStream(
// Loop for tool calls. maxIterations == 0 means unlimited. // Loop for tool calls. maxIterations == 0 means unlimited.
for i := 0; maxIterations == 0 || i < maxIterations; i++ { for i := 0; maxIterations == 0 || i < maxIterations; i++ {
choices, err := c.completionsStream(ctx, messages, model, events) choices, err := c.completionsStream(ctx, messages, model, thinkingBudgetTokens, events)
if err != nil { if err != nil {
return fmt.Errorf("completions stream: %w", err) return fmt.Errorf("completions stream: %w", err)
} }
@@ -304,13 +316,19 @@ type streamAccum struct {
// response, and returns all assistant messages from the response choices. // response, and returns all assistant messages from the response choices.
// Sends each message to the events channel. Usage and timings are included // Sends each message to the events channel. Usage and timings are included
// on the done event for the last message. // on the done event for the last message.
func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, events chan<- StreamEvent) ([]Message, error) { func (c *Client) completionsStream(ctx context.Context, messages []Message, model string, thinkingBudgetTokens *int, events chan<- StreamEvent) ([]Message, error) {
kwargs := map[string]any{}
if thinkingBudgetTokens != nil {
kwargs["enable_thinking"] = *thinkingBudgetTokens != 0
}
body, err := json.Marshal(ChatRequest{ body, err := json.Marshal(ChatRequest{
Model: model, Model: model,
Messages: messages, Messages: messages,
Stream: true, Stream: true,
Tools: c.tools, Tools: c.tools,
StreamOptions: &StreamOptions{IncludeUsage: true}, ThinkingBudgetTokens: thinkingBudgetTokens,
ChatTemplateKWARGS: kwargs,
StreamOptions: &StreamOptions{IncludeUsage: true},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(

View File

@@ -35,18 +35,18 @@ func TestCompletionsStreamStreamsReasoningDeltas(t *testing.T) {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- client.CompletionsStream( errCh <- client.CompletionsStream(
context.Background(), context.Background(), CompletionsParams{
"test-model", Model: "test-model",
"", SystemMessage: "",
0, MaxIterations: 0,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: ContentTypeText, Type: ContentTypeText,
Text: "hello", Text: "hello",
}},
}}, }},
}}, }, events,
events,
) )
}() }()
@@ -131,17 +131,18 @@ func TestCompletionsReturnsUsageAndTimings(t *testing.T) {
} }
res, err := client.Completions( res, err := client.Completions(
context.Background(), context.Background(), CompletionsParams{
"test-model", Model: "test-model",
"", SystemMessage: "",
0, MaxIterations: 0,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: ContentTypeText, Type: ContentTypeText,
Text: "hi", Text: "hi",
}},
}}, }},
}}, },
) )
if err != nil { if err != nil {
t.Fatalf("Completions: %v", err) t.Fatalf("Completions: %v", err)
@@ -205,18 +206,18 @@ func TestCompletionsStreamEmitsUsageEvent(t *testing.T) {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- client.CompletionsStream( errCh <- client.CompletionsStream(
context.Background(), context.Background(), CompletionsParams{
"test-model", Model: "test-model",
"", SystemMessage: "",
0, MaxIterations: 0,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: ContentTypeText, Type: ContentTypeText,
Text: "hi", Text: "hi",
}},
}}, }},
}}, }, events,
events,
) )
}() }()

View File

@@ -36,27 +36,28 @@ type APITool struct {
// ChatRequest is the request body for chat completions. // ChatRequest is the request body for chat completions.
type ChatRequest struct { type ChatRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
Temperature *float32 `json:"temperature,omitempty"` Temperature *float32 `json:"temperature,omitempty"`
TopP *float32 `json:"top_p,omitempty"` TopP *float32 `json:"top_p,omitempty"`
N int `json:"n,omitempty"` N int `json:"n,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Stop []string `json:"stop,omitempty"` Stop []string `json:"stop,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"` MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
ResponseFormat *ResponseFormat `json:"response_format,omitempty"` ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
Seed *int `json:"seed,omitempty"` Seed *int `json:"seed,omitempty"`
PresencePenalty *float32 `json:"presence_penalty,omitempty"` PresencePenalty *float32 `json:"presence_penalty,omitempty"`
FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"` FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"`
LogProbs bool `json:"logprobs,omitempty"` LogProbs bool `json:"logprobs,omitempty"`
TopLogProbs int `json:"top_logprobs,omitempty"` TopLogProbs int `json:"top_logprobs,omitempty"`
Tools []APITool `json:"tools,omitempty"` Tools []APITool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"` ToolChoice any `json:"tool_choice,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"` StreamOptions *StreamOptions `json:"stream_options,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"` ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitempty"`
ServiceTier string `json:"service_tier,omitempty"` ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
} }
// Usage represents token usage from the API response. // Usage represents token usage from the API response.

View File

@@ -77,17 +77,18 @@ func TestIntegration_Completions(t *testing.T) {
} }
res, err := client.Completions( res, err := client.Completions(
context.Background(), context.Background(), CompletionsParams{
testModel(t), Model: testModel(t),
"", SystemMessage: "",
0, MaxIterations: 0,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: "text", Type: "text",
Text: "Say hello in three words.", Text: "Say hello in three words.",
}},
}}, }},
}}, },
) )
if err != nil { if err != nil {
t.Fatalf("Completions: %v", err) t.Fatalf("Completions: %v", err)
@@ -117,18 +118,18 @@ func TestIntegration_CompletionsStream(t *testing.T) {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
errCh <- client.CompletionsStream( errCh <- client.CompletionsStream(
context.Background(), context.Background(), CompletionsParams{
testModel(t), Model: testModel(t),
"", SystemMessage: "",
0, MaxIterations: 0,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: "text", Type: "text",
Text: "Say hello in three words.", Text: "Say hello in three words.",
}},
}}, }},
}}, }, events,
events,
) )
}() }()
@@ -182,17 +183,18 @@ func TestIntegration_ToolCalling(t *testing.T) {
} }
res, err := client.Completions( res, err := client.Completions(
context.Background(), context.Background(), CompletionsParams{
testModel(t), Model: testModel(t),
"", SystemMessage: "",
10, MaxIterations: 10,
[]Message{{ Messages: []Message{{
Role: RoleUser, Role: RoleUser,
ContentParts: []ContentPart{{ ContentParts: []ContentPart{{
Type: "text", Type: "text",
Text: "Use the echo tool to say 'hello'", Text: "Use the echo tool to say 'hello'",
}},
}}, }},
}}, },
) )
if err != nil { if err != nil {
t.Fatalf("Completions: %v", err) t.Fatalf("Completions: %v", err)

View File

@@ -450,6 +450,8 @@ type Request struct {
SystemMessage string `json:"system_message,omitempty"` SystemMessage string `json:"system_message,omitempty"`
// MaxIterations caps the number of agent loop turns (0 = unlimited). // MaxIterations caps the number of agent loop turns (0 = unlimited).
MaxIterations int `json:"max_iterations,omitempty"` MaxIterations int `json:"max_iterations,omitempty"`
// ThinkingBudgetTokens is the llama.cpp reasoning budget (-1 = unlimited, 0 = off).
ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"`
} }
// Response is the response format for chat and voice endpoints. // Response is the response format for chat and voice endpoints.
@@ -545,7 +547,13 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) {
} }
res, err := llmc.Completions( res, err := llmc.Completions(
ctx, model, req.SystemMessage, req.MaxIterations, req.Messages, ctx, llm.CompletionsParams{
Model: model,
SystemMessage: req.SystemMessage,
MaxIterations: req.MaxIterations,
Messages: req.Messages,
ThinkingBudgetTokens: req.ThinkingBudgetTokens,
},
) )
if err != nil { if err != nil {
log.ErrorContext( log.ErrorContext(
@@ -717,7 +725,13 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) {
) )
go func() { go func() {
errs <- llmc.CompletionsStream( errs <- llmc.CompletionsStream(
ctx, model, req.SystemMessage, req.MaxIterations, req.Messages, events, ctx, llm.CompletionsParams{
Model: model,
SystemMessage: req.SystemMessage,
MaxIterations: req.MaxIterations,
Messages: req.Messages,
ThinkingBudgetTokens: req.ThinkingBudgetTokens,
}, events,
) )
close(errs) close(errs)
}() }()

View File

@@ -730,7 +730,7 @@ body {
.settings-label__sub { .settings-label__sub {
display: block; display: block;
font-size: var(--s-2); font-size: var(--s-1);
font-weight: 400; font-weight: 400;
color: var(--color-text-muted); color: var(--color-text-muted);
margin-top: 0.125rem; margin-top: 0.125rem;
@@ -756,10 +756,6 @@ body {
outline-offset: 2px; outline-offset: 2px;
} }
.settings-field {
margin-bottom: var(--s1);
}
.settings-textarea { .settings-textarea {
display: block; display: block;
width: 100%; width: 100%;

View File

@@ -15,6 +15,7 @@ import { WakeLock } from './wakelock.js';
const STREAM_ENDPOINT = '/v1/chat/voice/stream'; const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const MAX_TURNS_KEY = 'odidere_max_turns'; const MAX_TURNS_KEY = 'odidere_max_turns';
const REASONING_BUDGET_KEY = 'odidere_reasoning_budget';
const MUTE_KEY = 'odidere_mute'; const MUTE_KEY = 'odidere_mute';
const ICONS_URL = '/static/icons.svg'; const ICONS_URL = '/static/icons.svg';
@@ -111,6 +112,9 @@ class Odidere {
'compose-settings-close', 'compose-settings-close',
); );
this.$maxTurnsInput = document.getElementById('max-turns-input'); this.$maxTurnsInput = document.getElementById('max-turns-input');
this.$reasoningBudgetInput = document.getElementById(
'reasoning-budget-input',
);
this.$compose = document.querySelector('.compose'); this.$compose = document.querySelector('.compose');
this.init(); this.init();
@@ -268,9 +272,6 @@ class Odidere {
if (e.target === this.$composeSettingsOverlay) if (e.target === this.$composeSettingsOverlay)
this._closeComposeSettings(); this._closeComposeSettings();
}); });
this.$maxTurnsInput.addEventListener('input', () => {
this._setMaxTurns(this.$maxTurnsInput.value);
});
// --- Model/voice change persistence --- // --- Model/voice change persistence ---
this.settings.$model.addEventListener('change', () => this.settings.$model.addEventListener('change', () =>
@@ -640,6 +641,11 @@ class Odidere {
payload.max_iterations = maxTurns; payload.max_iterations = maxTurns;
} }
const reasoningBudget = this._getReasoningBudget();
if (reasoningBudget !== null) {
payload.thinking_budget_tokens = reasoningBudget;
}
// Clear attachments and location after building payload. // Clear attachments and location after building payload.
this.clearAttachments(); this.clearAttachments();
this.clearLocation(); this.clearLocation();
@@ -1170,11 +1176,14 @@ class Odidere {
_openComposeSettings() { _openComposeSettings() {
this.$maxTurnsInput.value = this._getMaxTurns(); this.$maxTurnsInput.value = this._getMaxTurns();
this.$reasoningBudgetInput.value = this._getReasoningBudget() ?? -1;
this.$composeSettingsOverlay.classList.add('open'); this.$composeSettingsOverlay.classList.add('open');
this.$composeSettingsClose.focus(); this.$composeSettingsClose.focus();
} }
_closeComposeSettings() { _closeComposeSettings() {
this._setMaxTurns(this.$maxTurnsInput.value);
this._setReasoningBudget(this.$reasoningBudgetInput.value);
this.$composeSettingsOverlay.classList.remove('open'); this.$composeSettingsOverlay.classList.remove('open');
this.$composeSettings.focus(); this.$composeSettings.focus();
} }
@@ -1188,6 +1197,16 @@ class Odidere {
localStorage.setItem(MAX_TURNS_KEY, String(value)); localStorage.setItem(MAX_TURNS_KEY, String(value));
} }
_getReasoningBudget() {
const stored = localStorage.getItem(REASONING_BUDGET_KEY);
if (stored === null) return null;
return Number(stored);
}
_setReasoningBudget(value) {
localStorage.setItem(REASONING_BUDGET_KEY, String(value));
}
// ==================== // ====================
// TWO-PRESS SAFETY PATTERNS // TWO-PRESS SAFETY PATTERNS
// ==================== // ====================

View File

@@ -29,6 +29,24 @@
aria-label="Max iterations" aria-label="Max iterations"
/> />
</div> </div>
<div class="settings-field">
<label class="settings-label" for="reasoning-budget-input">
Reasoning Budget
<span class="settings-label__sub">
Only supported by some models and inference engines.
<br>
-1 = unlimited, 0 = off · 512 ≈ low · 2048 ≈ med · 8192 ≈ high
</span>
</label>
<input
type="number"
class="settings-select"
id="reasoning-budget-input"
value="-1"
aria-label="Reasoning budget"
/>
</div>
</div> </div>
</div> </div>
{{ end }} {{ end }}