diff --git a/internal/job/job.go b/internal/job/job.go index a2c0650..4ffc233 100644 --- a/internal/job/job.go +++ b/internal/job/job.go @@ -157,11 +157,12 @@ func (j *Job) Run(ctx context.Context) Result { } res, err := j.llm.Completions( - ctx, - j.model, - j.SystemMessage(), - j.MaxIterations(), - messages, + ctx, llm.CompletionsParams{ + Model: j.model, + SystemMessage: j.SystemMessage(), + MaxIterations: j.MaxIterations(), + Messages: messages, + }, ) duration := time.Since(start) diff --git a/internal/llm/client_completions.go b/internal/llm/client_completions.go index 0a12b3e..cc29ec0 100644 --- a/internal/llm/client_completions.go +++ b/internal/llm/client_completions.go @@ -25,6 +25,15 @@ type CompletionsResult struct { 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. // If systemMessage is non-empty, it overrides the configured system message. // maxIterations caps the number of agent loop iterations. @@ -32,15 +41,14 @@ type CompletionsResult struct { // Returns all messages generated during the completions request, including // tool calls and tool results, along with usage and timings from the final response. func (c *Client) Completions( - ctx context.Context, - model string, - systemMessage string, - maxIterations int, - messages []Message, + ctx context.Context, p CompletionsParams, ) (*CompletionsResult, error) { ctx, cancel := context.WithTimeout(ctx, c.timeout) 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. smsg := c.systemMessage if systemMessage != "" { @@ -68,7 +76,7 @@ func (c *Client) Completions( var finalUsage *Usage var finalTimings *Timings 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 { 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 // full API response. func (c *Client) completions( - ctx context.Context, model string, messages []Message, + ctx context.Context, model string, thinkingBudgetTokens *int, messages []Message, ) (*ChatResponse, error) { + kwargs := map[string]any{} + if thinkingBudgetTokens != nil { + kwargs["enable_thinking"] = *thinkingBudgetTokens != 0 + } body, err := json.Marshal(ChatRequest{ - Model: model, - Messages: messages, - Tools: c.tools, + Model: model, + Messages: messages, + Tools: c.tools, + ThinkingBudgetTokens: thinkingBudgetTokens, + ChatTemplateKWARGS: kwargs, }) if err != nil { return nil, fmt.Errorf( @@ -232,17 +246,15 @@ const ( // means unlimited (no cap). // Returns ErrMaxIterations if the iteration limit is exceeded. func (c *Client) CompletionsStream( - ctx context.Context, - model string, - systemMessage string, - maxIterations int, - messages []Message, - events chan<- StreamEvent, + ctx context.Context, p CompletionsParams, events chan<- StreamEvent, ) error { defer close(events) ctx, cancel := context.WithTimeout(ctx, c.timeout) 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. smsg := c.systemMessage if systemMessage != "" { @@ -265,7 +277,7 @@ func (c *Client) CompletionsStream( // Loop for tool calls. maxIterations == 0 means unlimited. 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 { return fmt.Errorf("completions stream: %w", err) } @@ -304,13 +316,19 @@ type streamAccum struct { // response, and returns all assistant messages from the response choices. // Sends each message to the events channel. Usage and timings are included // 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{ - Model: model, - Messages: messages, - Stream: true, - Tools: c.tools, - StreamOptions: &StreamOptions{IncludeUsage: true}, + Model: model, + Messages: messages, + Stream: true, + Tools: c.tools, + ThinkingBudgetTokens: thinkingBudgetTokens, + ChatTemplateKWARGS: kwargs, + StreamOptions: &StreamOptions{IncludeUsage: true}, }) if err != nil { return nil, fmt.Errorf( diff --git a/internal/llm/client_completions_test.go b/internal/llm/client_completions_test.go index 9989af2..be1e785 100644 --- a/internal/llm/client_completions_test.go +++ b/internal/llm/client_completions_test.go @@ -35,18 +35,18 @@ func TestCompletionsStreamStreamsReasoningDeltas(t *testing.T) { errCh := make(chan error, 1) go func() { errCh <- client.CompletionsStream( - context.Background(), - "test-model", - "", - 0, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: ContentTypeText, - Text: "hello", + context.Background(), CompletionsParams{ + Model: "test-model", + SystemMessage: "", + MaxIterations: 0, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: ContentTypeText, + Text: "hello", + }}, }}, - }}, - events, + }, events, ) }() @@ -131,17 +131,18 @@ func TestCompletionsReturnsUsageAndTimings(t *testing.T) { } res, err := client.Completions( - context.Background(), - "test-model", - "", - 0, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: ContentTypeText, - Text: "hi", + context.Background(), CompletionsParams{ + Model: "test-model", + SystemMessage: "", + MaxIterations: 0, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: ContentTypeText, + Text: "hi", + }}, }}, - }}, + }, ) if err != nil { t.Fatalf("Completions: %v", err) @@ -205,18 +206,18 @@ func TestCompletionsStreamEmitsUsageEvent(t *testing.T) { errCh := make(chan error, 1) go func() { errCh <- client.CompletionsStream( - context.Background(), - "test-model", - "", - 0, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: ContentTypeText, - Text: "hi", + context.Background(), CompletionsParams{ + Model: "test-model", + SystemMessage: "", + MaxIterations: 0, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: ContentTypeText, + Text: "hi", + }}, }}, - }}, - events, + }, events, ) }() diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 9893005..4120782 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -36,27 +36,28 @@ type APITool struct { // ChatRequest is the request body for chat completions. type ChatRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Temperature *float32 `json:"temperature,omitempty"` - TopP *float32 `json:"top_p,omitempty"` - N int `json:"n,omitempty"` - Stream bool `json:"stream,omitempty"` - Stop []string `json:"stop,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` - ResponseFormat *ResponseFormat `json:"response_format,omitempty"` - Seed *int `json:"seed,omitempty"` - PresencePenalty *float32 `json:"presence_penalty,omitempty"` - FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"` - LogProbs bool `json:"logprobs,omitempty"` - TopLogProbs int `json:"top_logprobs,omitempty"` - Tools []APITool `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - ReasoningEffort string `json:"reasoning_effort,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` + Model string `json:"model"` + Messages []Message `json:"messages"` + Temperature *float32 `json:"temperature,omitempty"` + TopP *float32 `json:"top_p,omitempty"` + N int `json:"n,omitempty"` + Stream bool `json:"stream,omitempty"` + Stop []string `json:"stop,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + Seed *int `json:"seed,omitempty"` + PresencePenalty *float32 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float32 `json:"frequency_penalty,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Tools []APITool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + ChatTemplateKWARGS map[string]any `json:"chat_template_kwargs,omitempty"` + ThinkingBudgetTokens *int `json:"thinking_budget_tokens,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` } // Usage represents token usage from the API response. diff --git a/internal/llm/llm_integration_test.go b/internal/llm/llm_integration_test.go index 5574ff2..0fd3c91 100644 --- a/internal/llm/llm_integration_test.go +++ b/internal/llm/llm_integration_test.go @@ -77,17 +77,18 @@ func TestIntegration_Completions(t *testing.T) { } res, err := client.Completions( - context.Background(), - testModel(t), - "", - 0, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: "text", - Text: "Say hello in three words.", + context.Background(), CompletionsParams{ + Model: testModel(t), + SystemMessage: "", + MaxIterations: 0, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: "text", + Text: "Say hello in three words.", + }}, }}, - }}, + }, ) if err != nil { t.Fatalf("Completions: %v", err) @@ -117,18 +118,18 @@ func TestIntegration_CompletionsStream(t *testing.T) { errCh := make(chan error, 1) go func() { errCh <- client.CompletionsStream( - context.Background(), - testModel(t), - "", - 0, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: "text", - Text: "Say hello in three words.", + context.Background(), CompletionsParams{ + Model: testModel(t), + SystemMessage: "", + MaxIterations: 0, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: "text", + Text: "Say hello in three words.", + }}, }}, - }}, - events, + }, events, ) }() @@ -182,17 +183,18 @@ func TestIntegration_ToolCalling(t *testing.T) { } res, err := client.Completions( - context.Background(), - testModel(t), - "", - 10, - []Message{{ - Role: RoleUser, - ContentParts: []ContentPart{{ - Type: "text", - Text: "Use the echo tool to say 'hello'", + context.Background(), CompletionsParams{ + Model: testModel(t), + SystemMessage: "", + MaxIterations: 10, + Messages: []Message{{ + Role: RoleUser, + ContentParts: []ContentPart{{ + Type: "text", + Text: "Use the echo tool to say 'hello'", + }}, }}, - }}, + }, ) if err != nil { t.Fatalf("Completions: %v", err) diff --git a/internal/service/service.go b/internal/service/service.go index 3c3c4d5..9c2a735 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -450,6 +450,8 @@ type Request struct { SystemMessage string `json:"system_message,omitempty"` // MaxIterations caps the number of agent loop turns (0 = unlimited). 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. @@ -545,7 +547,13 @@ func (svc *Service) chat(w http.ResponseWriter, r *http.Request) { } 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 { log.ErrorContext( @@ -717,7 +725,13 @@ func (svc *Service) chatStream(w http.ResponseWriter, r *http.Request) { ) go func() { 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) }() diff --git a/internal/service/static/main.css b/internal/service/static/main.css index d477337..86c8d4a 100644 --- a/internal/service/static/main.css +++ b/internal/service/static/main.css @@ -730,7 +730,7 @@ body { .settings-label__sub { display: block; - font-size: var(--s-2); + font-size: var(--s-1); font-weight: 400; color: var(--color-text-muted); margin-top: 0.125rem; @@ -756,10 +756,6 @@ body { outline-offset: 2px; } -.settings-field { - margin-bottom: var(--s1); -} - .settings-textarea { display: block; width: 100%; diff --git a/internal/service/static/main.js b/internal/service/static/main.js index 0a553a2..ec7ed17 100644 --- a/internal/service/static/main.js +++ b/internal/service/static/main.js @@ -15,6 +15,7 @@ import { WakeLock } from './wakelock.js'; const STREAM_ENDPOINT = '/v1/chat/voice/stream'; const SYSTEM_MESSAGE_KEY = 'odidere_system_message'; const MAX_TURNS_KEY = 'odidere_max_turns'; +const REASONING_BUDGET_KEY = 'odidere_reasoning_budget'; const MUTE_KEY = 'odidere_mute'; const ICONS_URL = '/static/icons.svg'; @@ -111,6 +112,9 @@ class Odidere { 'compose-settings-close', ); this.$maxTurnsInput = document.getElementById('max-turns-input'); + this.$reasoningBudgetInput = document.getElementById( + 'reasoning-budget-input', + ); this.$compose = document.querySelector('.compose'); this.init(); @@ -268,9 +272,6 @@ class Odidere { if (e.target === this.$composeSettingsOverlay) this._closeComposeSettings(); }); - this.$maxTurnsInput.addEventListener('input', () => { - this._setMaxTurns(this.$maxTurnsInput.value); - }); // --- Model/voice change persistence --- this.settings.$model.addEventListener('change', () => @@ -640,6 +641,11 @@ class Odidere { payload.max_iterations = maxTurns; } + const reasoningBudget = this._getReasoningBudget(); + if (reasoningBudget !== null) { + payload.thinking_budget_tokens = reasoningBudget; + } + // Clear attachments and location after building payload. this.clearAttachments(); this.clearLocation(); @@ -1170,11 +1176,14 @@ class Odidere { _openComposeSettings() { this.$maxTurnsInput.value = this._getMaxTurns(); + this.$reasoningBudgetInput.value = this._getReasoningBudget() ?? -1; this.$composeSettingsOverlay.classList.add('open'); this.$composeSettingsClose.focus(); } _closeComposeSettings() { + this._setMaxTurns(this.$maxTurnsInput.value); + this._setReasoningBudget(this.$reasoningBudgetInput.value); this.$composeSettingsOverlay.classList.remove('open'); this.$composeSettings.focus(); } @@ -1188,6 +1197,16 @@ class Odidere { 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 // ==================== diff --git a/internal/service/templates/static/modal/compose-settings.gohtml b/internal/service/templates/static/modal/compose-settings.gohtml index 6827339..7d76f2a 100644 --- a/internal/service/templates/static/modal/compose-settings.gohtml +++ b/internal/service/templates/static/modal/compose-settings.gohtml @@ -29,6 +29,24 @@ aria-label="Max iterations" /> + +