From 8cb852d2a8f0e0af60b89a6d4f1ff434af1dcfad Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 25 Jun 2026 09:15:11 +0800 Subject: [PATCH 1/4] feat(provider): redesign model provider config & per-model reasoning effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure provider/model configuration around four UX goals: 1. setup no longer forces a model pick — a default is auto-selected (DefaultEnabled → Recommended → first) for registry providers 2. provider editing works end-to-end, incl. a base_url overwrite bug fix 3. custom (OpenAI-compatible) providers are first-class (name/endpoint/model + optional reasoning flag), both in first-run setup and settings 4. capabilities (vision/thinking/reasoning_effort) are model-level, not provider-level — exposed via a per-model effort control in the chat picker Backend - model/registry.go: PickDefaultModel(); custom models flagged reasoning get standard effort options - model/chatmodel.go: NewChatModelFromProvider is the single ProviderConfig → ChatModelConfig map; custom headers / reasoning_effort / thinking / vision flow into the request - config/model_state.go: per-model EffortOverrides + ResolveEffort precedence - config/config.go: relax the empty-Model boot check to a warning - command/{web,interactive,acp,commands}.go + model/factory.go: every agent construction path resolves per-model effort before building the model - web/server.go: setup/complete accepts advanced fields + optional model; providers CRUD returns name/custom; new POST /api/model-state/effort; /api/models now emits reasoning_options per model - model/validate.go: ValidateProvider honors custom headers - tui/setup.go: drop the model-selection state, keep a model-id step only for custom providers Frontend - SetupView.vue: provider → apikey (no model step); advanced panel (endpoint + headers); custom provider entry; reasoning flag for custom models - SettingsDialog.vue: add/edit provider with custom-provider entry; edit preserves base_url on empty submit; capabilities block removed from the provider form (model-level now) - ChatInput.vue: per-model reasoning-effort control next to the model picker, driven by each model's reasoning_options, remembered per model - stores/chat.ts + composables/api.ts + types/api.ts: effort overrides load/ save; ProviderAdvanced/ProviderDetail drop the capability fields i18n (en/ja/ko/zh-Hans/zh-Hant): add custom-provider + effort keys, remove the now-unused provider-level capability labels --- internal/command/acp.go | 8 +- internal/command/commands.go | 8 +- internal/command/interactive.go | 26 +- internal/command/web.go | 9 +- internal/config/config.go | 24 +- internal/config/model_state.go | 45 +++ internal/model/chatmodel.go | 106 ++++++- internal/model/factory.go | 10 +- internal/model/registry.go | 60 ++++ internal/model/validate.go | 9 +- internal/tui/setup.go | 188 +++++------- internal/web/server.go | 419 ++++++++++++++++++++++---- script/generate_models.go | 67 +++- web/src/components/ChatInput.vue | 127 +++++++- web/src/components/SettingsDialog.vue | 321 ++++++++++++++++---- web/src/components/SetupView.vue | 327 +++++++++++++------- web/src/composables/api.ts | 18 +- web/src/i18n/locales/en.ts | 57 +++- web/src/i18n/locales/ja.ts | 55 +++- web/src/i18n/locales/ko.ts | 55 +++- web/src/i18n/locales/zh-Hans.ts | 55 +++- web/src/i18n/locales/zh-Hant.ts | 55 +++- web/src/stores/chat.ts | 29 ++ web/src/types/api.ts | 27 ++ 24 files changed, 1700 insertions(+), 405 deletions(-) diff --git a/internal/command/acp.go b/internal/command/acp.go index bc06a280..c49bdd01 100644 --- a/internal/command/acp.go +++ b/internal/command/acp.go @@ -332,9 +332,11 @@ func (a *acpAgent) buildAgentSession( baseURL = registry.GetProviderAPI(providerName) } - chatModel, err := internalmodel.NewChatModel(ctx, &internalmodel.ChatModelConfig{ - Model: modelName, APIKey: providerCfg.APIKey, BaseURL: baseURL, - }) + // Apply a per-model reasoning-effort override (set from the chat picker) + // over the provider-level default before constructing the model. + acpEffortCfg := *providerCfg + acpEffortCfg.ReasoningEffort = config.ResolveEffort(providerName, modelName, providerCfg.ReasoningEffort) + chatModel, err := internalmodel.NewChatModelFromProvider(ctx, modelName, baseURL, &acpEffortCfg) if err != nil { return nil, fmt.Errorf("error creating model: %w", err) } diff --git a/internal/command/commands.go b/internal/command/commands.go index 119071b2..a121a0b7 100644 --- a/internal/command/commands.go +++ b/internal/command/commands.go @@ -71,9 +71,11 @@ func runDoctorMode() { } fmt.Println("\n [1] Model Connection") - chatModel, err := internalmodel.NewChatModel(context.Background(), &internalmodel.ChatModelConfig{ - Model: modelName, APIKey: providerCfg.APIKey, BaseURL: baseURL, - }) + // Apply a per-model reasoning-effort override (set from the chat picker) + // over the provider-level default before constructing the model. + docEffortCfg := *providerCfg + docEffortCfg.ReasoningEffort = config.ResolveEffort(providerName, modelName, providerCfg.ReasoningEffort) + chatModel, err := internalmodel.NewChatModelFromProvider(context.Background(), modelName, baseURL, &docEffortCfg) if err != nil { fmt.Printf(" ✗ Failed to initialize: %v\n", err) } else { diff --git a/internal/command/interactive.go b/internal/command/interactive.go index 4c12e685..0f3dfbc1 100644 --- a/internal/command/interactive.go +++ b/internal/command/interactive.go @@ -587,9 +587,11 @@ func (s *interactiveState) handleConfig(cfgMsg *config.Config) { if newBaseURL == "" { newBaseURL = s.registry.GetProviderAPI(newProvName) } - newChatModel, err := internalmodel.NewChatModel(s.ctx, &internalmodel.ChatModelConfig{ - Model: newModelName, APIKey: newProvCfg.APIKey, BaseURL: newBaseURL, - }) + // Apply a per-model reasoning-effort override (set from the chat picker) + // over the provider-level default before constructing the model. + newEffortCfg := *newProvCfg + newEffortCfg.ReasoningEffort = config.ResolveEffort(newProvName, newModelName, newProvCfg.ReasoningEffort) + newChatModel, err := internalmodel.NewChatModelFromProvider(s.ctx, newModelName, newBaseURL, &newEffortCfg) if err != nil { return } @@ -664,9 +666,9 @@ func (s *interactiveState) handleAddModel() { if newBaseURL == "" { newBaseURL = s.registry.GetProviderAPI(newProvName) } - newChatModel, cmErr := internalmodel.NewChatModel(s.ctx, &internalmodel.ChatModelConfig{ - Model: newModelName, APIKey: newProvCfg.APIKey, BaseURL: newBaseURL, - }) + newEffortCfg2 := *newProvCfg + newEffortCfg2.ReasoningEffort = config.ResolveEffort(newProvName, newModelName, newProvCfg.ReasoningEffort) + newChatModel, cmErr := internalmodel.NewChatModelFromProvider(s.ctx, newModelName, newBaseURL, &newEffortCfg2) if cmErr != nil { return } @@ -918,9 +920,9 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { baseURL = registry.GetProviderAPI(providerName) } - chatModel, err := internalmodel.NewChatModel(ctx, &internalmodel.ChatModelConfig{ - Model: modelName, APIKey: providerCfg.APIKey, BaseURL: baseURL, - }) + effortCfg := *providerCfg + effortCfg.ReasoningEffort = config.ResolveEffort(providerName, modelName, providerCfg.ReasoningEffort) + chatModel, err := internalmodel.NewChatModelFromProvider(ctx, modelName, baseURL, &effortCfg) if err != nil { return fmt.Errorf("error creating model: %w", err) } @@ -1045,9 +1047,9 @@ func RunInteractive(prompt, resumeUUID string, unsafe bool) error { if bURL == "" { bURL = registry.GetProviderAPI(pName) } - return internalmodel.NewChatModel(mCtx, &internalmodel.ChatModelConfig{ - Model: modelID, APIKey: pCfg.APIKey, BaseURL: bURL, - }) + pEffortCfg := *pCfg + pEffortCfg.ReasoningEffort = config.ResolveEffort(pName, modelID, pCfg.ReasoningEffort) + return internalmodel.NewChatModelFromProvider(mCtx, modelID, bURL, &pEffortCfg) }, PromptBuilder: func(agentType, agentPwd, agentPlatform string) string { return prompts.GetSystemPrompt(agentPlatform, agentPwd, "local", nil, "") diff --git a/internal/command/web.go b/internal/command/web.go index c4a23b7f..d9c14d6b 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -216,9 +216,12 @@ func runWebServer(port int, host string, openBrowser bool) error { if bURL == "" { bURL = registry.GetProviderAPI(prov) } - cm, err := internalmodel.NewChatModel(ctx, &internalmodel.ChatModelConfig{ - Model: mod, APIKey: provCfg.APIKey, BaseURL: bURL, - }) + // Apply a per-model reasoning-effort override (set from the chat picker) + // over the provider-level default before constructing the model. + pcEffort := config.ResolveEffort(prov, mod, provCfg.ReasoningEffort) + effortCfg := *provCfg + effortCfg.ReasoningEffort = pcEffort + cm, err := internalmodel.NewChatModelFromProvider(ctx, mod, bURL, &effortCfg) if err != nil { return nil, 0, fmt.Errorf("create model %s/%s: %w", prov, mod, err) } diff --git a/internal/config/config.go b/internal/config/config.go index 46dc096f..64d711d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,21 @@ type ProviderConfig struct { BaseURL string `json:"base_url,omitempty"` // Name is an optional display name for custom providers not in the registry. Name string `json:"name,omitempty"` + // Headers are extra HTTP headers injected into every request to this + // provider's endpoint (e.g. a gateway's "X-Api-Key" or "X-Org-Id"). Values + // may be secrets — they are masked by the API and never logged. + Headers map[string]string `json:"headers,omitempty"` + // Vision, when non-nil, overrides registry detection of image-input support + // for this provider. nil ⇒ defer to registry metadata (default: allow images). + Vision *bool `json:"vision,omitempty"` + // Thinking, when non-nil, explicitly toggles extended reasoning for this + // provider. It is sent as the OpenAI-compatible chat_template_kwargs + // {"enable_thinking": } extension (e.g. qwen3 gateways). nil ⇒ omit. + Thinking *bool `json:"thinking,omitempty"` + // ReasoningEffort controls thinking depth via the OpenAI-compatible + // "reasoning_effort" parameter. One of "", "low", "medium", "high". + // Empty ⇒ omit the parameter. + ReasoningEffort string `json:"reasoning_effort,omitempty"` // Deprecated: model lists are now sourced from the models.dev registry. // Preserved for backward compatibility with existing config files. Models []string `json:"models,omitempty"` @@ -327,9 +342,14 @@ func LoadConfig() (*Config, error) { cfg.Model = cfg.Provider + "/" + cfg.Model } - // Validate Model field is set + // Validate Model field is set. This is no longer a hard error: setup no + // longer forces a model selection, and some agent-construction paths can + // pick a default at runtime. We only warn so a stale/legacy config with + // providers but no active model doesn't prevent the app from booting — the + // caller resolves a concrete model (or surfaces a clearer error) when it + // actually builds an agent. if cfg.Model == "" { - return nil, fmt.Errorf("model not configured: set 'model' field in 'provider/model' format in %s", cfgPath) + Logger().Printf("[config] warning: no active model set in %s; it will be resolved on first use", cfgPath) } if cfg.MaxIterations <= 0 { diff --git a/internal/config/model_state.go b/internal/config/model_state.go index ddc006cb..cb92047f 100644 --- a/internal/config/model_state.go +++ b/internal/config/model_state.go @@ -18,6 +18,11 @@ type ModelState struct { EnabledModels []ModelRef `json:"enabled_models,omitempty"` // DisabledModels lists models explicitly disabled by the user (hidden from model selector). DisabledModels []ModelRef `json:"disabled_models,omitempty"` + // EffortOverrides holds per-"provider/model" reasoning-effort choices made + // from the chat model picker. An empty value ("") means "use the model's + // default / unset" and clears any earlier override. This takes precedence + // over the provider-level ProviderConfig.ReasoningEffort when present. + EffortOverrides map[string]string `json:"effort_overrides,omitempty"` } // ModelRef uniquely identifies a model in "provider/model" format. @@ -153,3 +158,43 @@ func removeModelRef(refs []ModelRef, ref ModelRef) []ModelRef { } return result } + +// effortKey is the lookup key for per-model effort overrides: "provider/model". +func effortKey(ref ModelRef) string { return ref.Provider + "/" + ref.Model } + +// GetEffortOverride returns the user's per-model reasoning-effort choice, or "" +// when none is set (callers then fall back to the provider-level setting). +func (s *ModelState) GetEffortOverride(ref ModelRef) string { + if s == nil || s.EffortOverrides == nil { + return "" + } + return s.EffortOverrides[effortKey(ref)] +} + +// SetEffortOverride records the user's reasoning-effort choice for a model. +// An empty effort clears the override, restoring the default behavior. +func (s *ModelState) SetEffortOverride(ref ModelRef, effort string) { + if s.EffortOverrides == nil { + s.EffortOverrides = make(map[string]string) + } + key := effortKey(ref) + if effort == "" { + delete(s.EffortOverrides, key) + return + } + s.EffortOverrides[key] = effort +} + +// ResolveEffort returns the effective reasoning effort for a model: the +// per-model override (from the chat picker) if set, otherwise the provider-level +// fallback from ProviderConfig. Empty ("") means "send no effort parameter". +// This is the single place that defines override precedence so every +// entrypoint (web/TUI/ACP) applies the same value. +func ResolveEffort(prov, mod, providerEffort string) string { + if state, err := LoadModelState(); err == nil && state != nil { + if v := state.GetEffortOverride(ModelRef{Provider: prov, Model: mod}); v != "" { + return v + } + } + return providerEffort +} diff --git a/internal/model/chatmodel.go b/internal/model/chatmodel.go index 210a47d6..15998e59 100644 --- a/internal/model/chatmodel.go +++ b/internal/model/chatmodel.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "sync" "sync/atomic" @@ -300,12 +301,43 @@ type ChatModelConfig struct { Model string APIKey string BaseURL string + // Headers are extra HTTP headers injected into every request to the + // provider endpoint (custom gateways, auth proxies). Empty ⇒ none. + Headers map[string]string + // ReasoningEffort sets thinking depth via the "reasoning_effort" parameter: + // "", "low", "medium", or "high". Empty ⇒ parameter omitted. + ReasoningEffort string + // Thinking, when non-nil, sends chat_template_kwargs {"enable_thinking": v} + // to explicitly toggle extended reasoning on compatible gateways. + Thinking *bool + // Vision controls whether image parts are forwarded to the model. When + // false, multimodal image content is stripped to text before sending. + Vision bool } type chatModel struct { - client *openai.Client - model string - tools []openai.Tool + client *openai.Client + model string + tools []openai.Tool + reasoningEffort string + thinking *bool + vision bool +} + +// headerDoer wraps an http.Client to inject a fixed set of headers into every +// outgoing request. It satisfies go-openai's HTTPDoer interface so a provider's +// configured Headers reach the API. Set unconditionally so callers may override +// transport headers (including Authorization) for custom gateways. +type headerDoer struct { + base *http.Client + headers map[string]string +} + +func (h *headerDoer) Do(req *http.Request) (*http.Response, error) { + for k, v := range h.headers { + req.Header.Set(k, v) + } + return h.base.Do(req) } func NewChatModel(_ context.Context, cfg *ChatModelConfig) (einomodel.ToolCallingChatModel, error) { @@ -316,12 +348,40 @@ func NewChatModel(_ context.Context, cfg *ChatModelConfig) (einomodel.ToolCallin if cfg.BaseURL != "" { config.BaseURL = cfg.BaseURL } + if len(cfg.Headers) > 0 { + config.HTTPClient = &headerDoer{base: &http.Client{}, headers: cfg.Headers} + } return &chatModel{ - client: openai.NewClientWithConfig(config), - model: cfg.Model, + client: openai.NewClientWithConfig(config), + model: cfg.Model, + reasoningEffort: cfg.ReasoningEffort, + thinking: cfg.Thinking, + vision: cfg.Vision, }, nil } +// NewChatModelFromProvider builds a ChatModel from a provider config, applying +// its advanced settings (custom headers, thinking depth, explicit thinking +// toggle, and the vision capability — which defaults to enabled). baseURL is the +// already-resolved endpoint (config override or registry default). This is the +// single place that maps ProviderConfig → ChatModelConfig so every entrypoint +// (web, TUI, ACP, subagents) honors the same settings. +func NewChatModelFromProvider(ctx context.Context, modelName, baseURL string, pc *config.ProviderConfig) (einomodel.ToolCallingChatModel, error) { + vision := true + if pc.Vision != nil { + vision = *pc.Vision + } + return NewChatModel(ctx, &ChatModelConfig{ + Model: modelName, + APIKey: pc.APIKey, + BaseURL: baseURL, + Headers: pc.Headers, + ReasoningEffort: pc.ReasoningEffort, + Thinking: pc.Thinking, + Vision: vision, + }) +} + func (m *chatModel) WithTools(tools []*schema.ToolInfo) (einomodel.ToolCallingChatModel, error) { config.Logger().Printf("[chatmodel] WithTools called with %d tools", len(tools)) oaiTools := make([]openai.Tool, 0, len(tools)) @@ -346,7 +406,14 @@ func (m *chatModel) WithTools(tools []*schema.ToolInfo) (einomodel.ToolCallingCh for _, t := range oaiTools { config.Logger().Printf("[chatmodel] tool: %s", t.Function.Name) } - return &chatModel{client: m.client, model: m.model, tools: oaiTools}, nil + return &chatModel{ + client: m.client, + model: m.model, + tools: oaiTools, + reasoningEffort: m.reasoningEffort, + thinking: m.thinking, + vision: m.vision, + }, nil } // extractUsage maps a go-openai Usage onto AddParams. cache_creation tokens are @@ -494,7 +561,7 @@ func (m *chatModel) Stream(ctx context.Context, input []*schema.Message, opts .. func (m *chatModel) buildRequest(input []*schema.Message, stream bool, opts ...einomodel.Option) openai.ChatCompletionRequest { msgs := make([]openai.ChatCompletionMessage, 0, len(input)) for _, msg := range input { - msgs = append(msgs, toOpenAIMessage(msg)) + msgs = append(msgs, toOpenAIMessage(msg, m.vision)) } req := openai.ChatCompletionRequest{ Model: m.model, @@ -502,6 +569,17 @@ func (m *chatModel) buildRequest(input []*schema.Message, stream bool, opts ...e Stream: stream, } + // Thinking depth: forward reasoning_effort when configured. Sent for all + // models — reasoning models honor it; others ignore it (OpenAI-compatible). + if m.reasoningEffort != "" { + req.ReasoningEffort = m.reasoningEffort + } + // Explicit thinking toggle for gateways that gate reasoning behind + // chat_template_kwargs {"enable_thinking": } (e.g. qwen3). + if m.thinking != nil { + req.ChatTemplateKwargs = map[string]any{"enable_thinking": *m.thinking} + } + // Apply call-time options (e.g. model.WithTools from Eino framework). commonOpts := einomodel.GetCommonOptions(nil, opts...) if len(commonOpts.Tools) > 0 { @@ -533,7 +611,7 @@ func (m *chatModel) buildRequest(input []*schema.Message, stream bool, opts ...e return req } -func toOpenAIMessage(msg *schema.Message) openai.ChatCompletionMessage { +func toOpenAIMessage(msg *schema.Message, vision bool) openai.ChatCompletionMessage { m := openai.ChatCompletionMessage{ Role: string(msg.Role), Content: msg.Content, @@ -546,6 +624,18 @@ func toOpenAIMessage(msg *schema.Message) openai.ChatCompletionMessage { } // Convert multimodal content (text + images) to OpenAI MultiContent format. if len(msg.UserInputMultiContent) > 0 { + // Vision disabled: collapse to text-only so a non-vision endpoint + // doesn't 400 on image parts. Text segments are preserved. + if !vision { + var text string + for _, p := range msg.UserInputMultiContent { + if p.Type == schema.ChatMessagePartTypeText { + text += p.Text + } + } + m.Content = text + return m + } m.Content = "" parts := make([]openai.ChatMessagePart, 0, len(msg.UserInputMultiContent)) for _, p := range msg.UserInputMultiContent { diff --git a/internal/model/factory.go b/internal/model/factory.go index 6ace8916..1d03d227 100644 --- a/internal/model/factory.go +++ b/internal/model/factory.go @@ -72,11 +72,11 @@ func (f *ModelFactory) GetModel(ctx context.Context, providerModel string) (eino baseURL = f.registry.GetProviderAPI(provider) } - m, err := NewChatModel(ctx, &ChatModelConfig{ - Model: modelName, - APIKey: providerCfg.APIKey, - BaseURL: baseURL, - }) + // Apply a per-model reasoning-effort override (set from the chat picker) + // over the provider-level default before constructing the model. + facEffortCfg := *providerCfg + facEffortCfg.ReasoningEffort = config.ResolveEffort(provider, modelName, providerCfg.ReasoningEffort) + m, err := NewChatModelFromProvider(ctx, modelName, baseURL, &facEffortCfg) if err != nil { return nil, fmt.Errorf("failed to create model %q: %w", providerModel, err) } diff --git a/internal/model/registry.go b/internal/model/registry.go index 957ade44..9afc0982 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -38,8 +38,27 @@ type RegistryModel struct { Status string `json:"status,omitempty"` Recommended bool `json:"recommended,omitempty"` DefaultEnabled bool `json:"default_enabled,omitempty"` + // ReasoningOptions describes how this model exposes its thinking controls, + // mirroring models.dev's reasoning_options. Empty ⇒ no reasoning controls. + ReasoningOptions []ReasoningOption `json:"reasoning_options,omitempty"` } +// ReasoningOption is one reasoning/thinking control a model supports, from +// models.dev's reasoning_options. Type is one of: +// - "effort" — Values lists the supported effort levels (e.g. low/medium/high/xhigh/max) +// - "toggle" — reasoning can be switched on/off, no extra parameters +// - "budget_tokens" — a thinking token budget bounded by Min/Max (nil ⇒ open-ended) +type ReasoningOption struct { + Type string `json:"type"` + Values []string `json:"values,omitempty"` + Min *int `json:"min,omitempty"` + Max *int `json:"max,omitempty"` +} + +// intPtr returns a pointer to i. Used by the generated registry to carry +// nullable reasoning_options bounds (Min/Max). +func intPtr(i int) *int { return &i } + // ModelModalities describes input/output modalities. type ModelModalities struct { Input []string `json:"input,omitempty"` @@ -187,6 +206,13 @@ func (r *ModelRegistry) MergeConfigProviders(providers map[string]*config.Provid Reasoning: cm.Reasoning, DefaultEnabled: true, } + // A custom model flagged as reasoning gets the standard OpenAI-compatible + // effort levels, so the chat picker's effort control can render for it. + // Custom models not flagged reasoning stay without reasoning_options — + // the effort control is hidden for them, matching "not specified ⇒ none". + if cm.Reasoning { + rm.ReasoningOptions = standardEffortOptions() + } if cm.Context > 0 { rm.Limit = &ModelLimit{Context: cm.Context} } @@ -297,6 +323,30 @@ func (r *ModelRegistry) HasProvider(providerID string) bool { return r.GetProvider(providerID) != nil } +// PickDefaultModel returns the best default model id for a provider, used when +// setup completes without an explicit model selection (the wizard no longer +// forces a model pick). Selection order: first DefaultEnabled model, then the +// first Recommended model, then simply the first model. Returns "" when the +// provider is unknown or has no models (e.g. a custom OpenAI-compatible +// provider) — callers must then require an explicit model id. +func (r *ModelRegistry) PickDefaultModel(providerID string) string { + models := r.ListProviderModels(providerID, false) + for _, m := range models { + if m.DefaultEnabled { + return m.ID + } + } + for _, m := range models { + if m.Recommended { + return m.ID + } + } + if len(models) > 0 { + return models[0].ID + } + return "" +} + // ListProviders returns all providers in the curated display order. func (r *ModelRegistry) ListProviders() []*RegistryProvider { result := make([]*RegistryProvider, 0, len(r.providerOrder)) @@ -578,3 +628,13 @@ func applyRecommendedModels() { } } } + +// standardEffortOptions is the reasoning_options applied to custom models the +// user flags as reasoning-capable. These are the effort levels the +// OpenAI-compatible "reasoning_effort" parameter conventionally accepts. +func standardEffortOptions() []ReasoningOption { + return []ReasoningOption{{ + Type: "effort", + Values: []string{"minimal", "low", "medium", "high"}, + }} +} diff --git a/internal/model/validate.go b/internal/model/validate.go index ff147972..f474b0f1 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -8,8 +8,10 @@ import ( ) // ValidateProvider tests connectivity to a provider by making a lightweight -// GET /models request. Returns nil on success, or a descriptive error. -func ValidateProvider(ctx context.Context, apiKey, baseURL string) error { +// GET /models request. Custom headers (if any) are applied last so they can +// override the default Authorization for gateways. Returns nil on success, or +// a descriptive error. +func ValidateProvider(ctx context.Context, apiKey, baseURL string, headers map[string]string) error { if baseURL == "" { return fmt.Errorf("base URL is empty") } @@ -23,6 +25,9 @@ func ValidateProvider(ctx context.Context, apiKey, baseURL string) error { if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } + for k, v := range headers { + req.Header.Set(k, v) + } resp, err := client.Do(req) if err != nil { diff --git a/internal/tui/setup.go b/internal/tui/setup.go index b006b21d..8c585962 100644 --- a/internal/tui/setup.go +++ b/internal/tui/setup.go @@ -51,21 +51,11 @@ func (i providerItem) Description() string { } func (i providerItem) FilterValue() string { return i.profile.Name + " " + i.profile.ID } -type modelListItem struct { - name string - desc string -} - -func (i modelListItem) Title() string { return i.name } -func (i modelListItem) Description() string { return i.desc } -func (i modelListItem) FilterValue() string { return i.name } - type SetupState int const ( - StateProvider SetupState = iota - StateModel - StateCustomModel + StateProvider SetupState = iota + StateCustomModel // only for custom/OpenAI-compatible providers (model id required) StateURL StateAPIKey ) @@ -73,14 +63,13 @@ const ( type SetupModel struct { state SetupState providerList list.Model - modelList list.Model customModelIn textinput.Model urlIn textinput.Model keyIn textinput.Model registry *model.ModelRegistry selectedProvider *ProviderProfile - selectedModel string + selectedModel string // only set for custom providers finalURL string finalKey string @@ -154,13 +143,9 @@ func NewSetupModel() SetupModel { pl.SetShowHelp(false) m.providerList = pl - ml := list.New([]list.Item{}, del, 60, 15) - ml.SetShowHelp(false) - m.modelList = ml - m.customModelIn = textinput.New() - m.customModelIn.Placeholder = "Enter custom model name..." - m.customModelIn.Prompt = "Model Name: " + m.customModelIn.Placeholder = "Enter model id (e.g. gpt-4o)..." + m.customModelIn.Prompt = "Model ID: " m.customModelIn.SetWidth(50) m.urlIn = textinput.New() @@ -251,68 +236,25 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sel != nil { p := sel.(providerItem).profile m.selectedProvider = &p - - var mItems []list.Item - // Always try to load models from registry, regardless of initial FromRegistry flag. - // This handles cases where registry was temporarily unavailable during init. - models := m.registry.ListProviderModels(p.ID, false) - if len(models) > 0 { - for _, rm := range models { - desc := modelDescription(rm) - mItems = append(mItems, modelListItem{name: rm.ID, desc: desc}) - } - } - // Always offer "Custom..." as the last option - mItems = append(mItems, modelListItem{name: "Custom...", desc: "Enter a custom model name"}) - m.modelList.SetItems(mItems) - m.modelList.Title = "Select Model (" + p.Name + ")" - - m.state = StateModel - return m, nil + return m.advanceAfterProvider() } } var cmd tea.Cmd m.providerList, cmd = m.providerList.Update(msg) cmds = append(cmds, cmd) - case StateModel: - // When filtering, let keys pass through to the list - if m.modelList.FilterState() == list.Filtering { - var cmd tea.Cmd - m.modelList, cmd = m.modelList.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - if msg.String() == "enter" { - sel := m.modelList.SelectedItem() - if sel != nil { - name := sel.(modelListItem).name - if name == "Custom..." { - m.state = StateCustomModel - m.customModelIn.Focus() - } else { - m.selectedModel = name - return m.advanceAfterModel() - } - return m, nil - } - } else if msg.String() == "esc" { - m.state = StateProvider - return m, nil - } - var cmd tea.Cmd - m.modelList, cmd = m.modelList.Update(msg) - cmds = append(cmds, cmd) - case StateCustomModel: if msg.String() == "enter" { val := strings.TrimSpace(m.customModelIn.Value()) if val != "" { m.selectedModel = val - return m.advanceAfterModel() + // Custom providers always need a base URL next. + m.state = StateURL + m.urlIn.Focus() + return m, nil } } else if msg.String() == "esc" { - m.state = StateModel + m.state = StateProvider return m, nil } var cmd tea.Cmd @@ -330,10 +272,13 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.advanceAfterURL() } } else if msg.String() == "esc" { - if m.selectedModel == "Custom..." { + // Back to custom-model step if this is a custom provider, else + // straight to the provider list. + if m.selectedProvider.NeedURL { m.state = StateCustomModel + m.customModelIn.Focus() } else { - m.state = StateModel + m.state = StateProvider } return m, nil } @@ -355,11 +300,11 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case m.selectedProvider.NeedURL || m.selectedProvider.BaseURL == "": m.state = StateURL m.urlIn.Focus() - case m.selectedModel == "Custom...": + case m.selectedProvider.NeedURL: m.state = StateCustomModel m.customModelIn.Focus() default: - m.state = StateModel + m.state = StateProvider } return m, nil } @@ -372,21 +317,16 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.providerList.SetSize(msg.Width-4, 15) - m.modelList.SetSize(msg.Width-4, 15) } - // Forward non-key/non-mouse messages (e.g. list.FilterMatchesMsg) to active list + // Forward non-key/non-mouse messages (e.g. list.FilterMatchesMsg) to the + // active list (only the provider list remains now). if _, isKey := msg.(tea.KeyPressMsg); !isKey { if _, isMouse := msg.(tea.MouseMsg); !isMouse { - switch m.state { - case StateProvider: + if m.state == StateProvider { var cmd tea.Cmd m.providerList, cmd = m.providerList.Update(msg) cmds = append(cmds, cmd) - case StateModel: - var cmd tea.Cmd - m.modelList, cmd = m.modelList.Update(msg) - cmds = append(cmds, cmd) } } } @@ -394,16 +334,27 @@ func (m SetupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m SetupModel) advanceAfterModel() (tea.Model, tea.Cmd) { - if m.selectedProvider.NeedURL || m.selectedProvider.BaseURL == "" { +// advanceAfterProvider routes the wizard after a provider is picked. Registry +// providers don't need a model selection (a default is auto-picked on submit), +// so they go straight to URL/Key. Custom (OpenAI-compatible) providers need a +// model id and a base URL, so they start at the custom-model step. +func (m SetupModel) advanceAfterProvider() (tea.Model, tea.Cmd) { + p := m.selectedProvider + // Custom providers need a model id before anything else. + if p.NeedURL && m.selectedModel == "" { + m.state = StateCustomModel + m.customModelIn.Focus() + return m, nil + } + if p.NeedURL || p.BaseURL == "" { m.state = StateURL - if m.selectedProvider.BaseURL != "" { - m.urlIn.Placeholder = m.selectedProvider.BaseURL + if p.BaseURL != "" { + m.urlIn.Placeholder = p.BaseURL } m.urlIn.Focus() return m, nil } - m.finalURL = m.selectedProvider.BaseURL + m.finalURL = p.BaseURL return m.advanceAfterURL() } @@ -445,6 +396,17 @@ func (m SetupModel) findProviderAPIKey() string { return "" } +// containsCustomModel reports whether a model id is already registered on the +// provider config, used to avoid duplicate custom-model entries on re-setup. +func containsCustomModel(models []config.CustomModelConfig, id string) bool { + for _, cm := range models { + if cm.ID == id { + return true + } + } + return false +} + func (m SetupModel) submit() (tea.Model, tea.Cmd) { cfg, err := config.LoadConfig() if err != nil { @@ -474,8 +436,30 @@ func (m SetupModel) submit() (tea.Model, tea.Cmd) { pCfg.APIKey = m.finalKey pCfg.BaseURL = m.finalURL - // Set model in "provider/model" format - cfg.Model = pID + "/" + m.selectedModel + // Resolve the active model. For custom (OpenAI-compatible) providers the + // user entered a model id; for registry providers a default is auto-picked. + modelID := m.selectedModel + if modelID == "" && m.registry != nil { + modelID = m.registry.PickDefaultModel(pID) + } + if modelID == "" { + m.err = "No default model available for " + pID + "; please select a model in Settings" + return m, nil + } + // For custom providers, register the model so it's selectable afterwards. + if m.selectedProvider.NeedURL { + if pCfg.Name == "" { + pCfg.Name = m.selectedProvider.Name + } + if !containsCustomModel(pCfg.CustomModels, modelID) { + pCfg.CustomModels = append(pCfg.CustomModels, config.CustomModelConfig{ + ID: modelID, + Name: modelID, + ToolCall: true, + }) + } + } + cfg.Model = pID + "/" + modelID if err := config.SaveConfig(cfg); err != nil { m.err = fmt.Sprintf("Failed to save config: %v", err) @@ -486,27 +470,6 @@ func (m SetupModel) submit() (tea.Model, tea.Cmd) { return m, tea.Quit } -// modelDescription builds a short description for a registry model. -func modelDescription(rm *model.RegistryModel) string { - var parts []string - if rm.Limit != nil && rm.Limit.Context > 0 { - parts = append(parts, fmt.Sprintf("%dk ctx", rm.Limit.Context/1000)) - } - if rm.ToolCall { - parts = append(parts, "tool_call") - } - if rm.Reasoning { - parts = append(parts, "reasoning") - } - if rm.Cost != nil && rm.Cost.Input > 0 { - parts = append(parts, fmt.Sprintf("$%.2f/1M in", rm.Cost.Input)) - } - if len(parts) == 0 { - return rm.ID - } - return strings.Join(parts, " · ") -} - func (m SetupModel) View() tea.View { w := m.width if w <= 0 { @@ -522,10 +485,11 @@ func (m SetupModel) View() tea.View { switch m.state { case StateProvider: content = m.providerList.View() - case StateModel: - content = m.modelList.View() case StateCustomModel: content = m.customModelIn.View() + if m.selectedProvider != nil { + content += "\n " + lipgloss.NewStyle().Foreground(colorMuted).Render("Model id for "+m.selectedProvider.Name) + } case StateURL: content = m.urlIn.View() if m.selectedProvider.BaseURL != "" { @@ -535,7 +499,7 @@ func (m SetupModel) View() tea.View { content = m.keyIn.View() } - if m.state != StateProvider && m.state != StateModel { + if m.state != StateProvider { var helpText string if m.state == StateAPIKey { helpText = " Press Enter to submit, Esc to go back. Paste: Ctrl+Shift+V (Win/Linux) or Cmd+V (Mac)" diff --git a/internal/web/server.go b/internal/web/server.go index c8fc106d..b37d99bf 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -354,6 +354,7 @@ func (s *Server) Start(ctx context.Context) error { // Provider management API — add/remove providers after initial setup. mux.HandleFunc("GET /api/providers", s.handleListProviders) mux.HandleFunc("POST /api/providers", s.handleAddProvider) + mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider) mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider) // History management. @@ -363,6 +364,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/model-state", s.handleGetModelState) mux.HandleFunc("POST /api/model-state/favorite", s.handleToggleFavorite) mux.HandleFunc("POST /api/model-state/enabled", s.handleToggleModelEnabled) + mux.HandleFunc("POST /api/model-state/effort", s.handleSetModelEffort) // Serve embedded frontend (SPA with fallback to index.html) mux.Handle("GET /", newSPAHandler()) @@ -1138,15 +1140,16 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { } type modelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - ToolCall bool `json:"tool_call"` - ContextLimit int `json:"context_limit,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` - Recommended bool `json:"recommended,omitempty"` - DefaultEnabled bool `json:"default_enabled,omitempty"` - Enabled bool `json:"enabled"` - ImageSupport bool `json:"image_support,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Recommended bool `json:"recommended,omitempty"` + DefaultEnabled bool `json:"default_enabled,omitempty"` + Enabled bool `json:"enabled"` + ImageSupport bool `json:"image_support,omitempty"` + ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` } type providerInfo struct { ID string `json:"id"` @@ -1187,7 +1190,8 @@ func (s *Server) handleListModels(w http.ResponseWriter, r *http.Request) { ID: m.ID, Name: m.Name, ToolCall: m.ToolCall, ContextLimit: ctx, Reasoning: m.Reasoning, Recommended: m.Recommended, DefaultEnabled: m.DefaultEnabled, Enabled: enabled, - ImageSupport: imageSupport, + ImageSupport: imageSupport, + ReasoningOptions: m.ReasoningOptions, }) } result = append(result, pi) @@ -2819,9 +2823,10 @@ func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { // handleSetupValidate tests connectivity to a provider with the given API key. func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { var req struct { - Provider string `json:"provider"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) @@ -2841,7 +2846,7 @@ func (s *Server) handleSetupValidate(w http.ResponseWriter, r *http.Request) { return } - if err := model.ValidateProvider(r.Context(), req.APIKey, baseURL); err != nil { + if err := model.ValidateProvider(r.Context(), req.APIKey, baseURL, req.Headers); err != nil { writeJSON(w, http.StatusOK, map[string]any{ "valid": false, "error": err.Error(), @@ -2933,11 +2938,13 @@ func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Reques models := s.registry.ListProviderModels(providerID, true) type modelItem struct { - ID string `json:"id"` - Name string `json:"name"` - ToolCall bool `json:"tool_call"` - ContextLimit int `json:"context_limit,omitempty"` - Reasoning bool `json:"reasoning,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + ToolCall bool `json:"tool_call"` + ContextLimit int `json:"context_limit,omitempty"` + Reasoning bool `json:"reasoning,omitempty"` + Attachment bool `json:"attachment,omitempty"` + ReasoningOptions []model.ReasoningOption `json:"reasoning_options,omitempty"` } result := make([]modelItem, 0, len(models)) @@ -2947,11 +2954,13 @@ func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Reques ctx = m.Limit.Context } result = append(result, modelItem{ - ID: m.ID, - Name: m.Name, - ToolCall: m.ToolCall, - ContextLimit: ctx, - Reasoning: m.Reasoning, + ID: m.ID, + Name: m.Name, + ToolCall: m.ToolCall, + ContextLimit: ctx, + Reasoning: m.Reasoning, + Attachment: m.Attachment, + ReasoningOptions: m.ReasoningOptions, }) } @@ -2960,19 +2969,53 @@ func (s *Server) handleSetupProviderModels(w http.ResponseWriter, r *http.Reques // handleSetupComplete handles the initial setup submission. // It saves the provider config and creates the agent. +// +// The wizard no longer forces a model selection: for registry providers, a +// default model is auto-picked (DefaultEnabled → Recommended → first). A +// caller-supplied model always wins. Custom (non-registry) providers must send +// a model explicitly since none can be inferred. func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + ModelReasoning bool `json:"model_reasoning,omitempty"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` // custom provider display name + Headers map[string]string `json:"headers,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } - if req.Provider == "" || req.Model == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + if req.Provider == "" || req.APIKey == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and api_key are required"}) + return + } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } + + // Resolve the active model. An explicit model always wins; otherwise try to + // auto-pick a default for registry providers. Custom providers (not in the + // registry) cannot infer a model and require one from the caller. + resolvedModel := req.Model + isCustom := s.registry == nil || !s.registry.HasProvider(req.Provider) + if resolvedModel == "" { + if isCustom { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required for custom providers"}) + return + } + if s.registry != nil { + resolvedModel = s.registry.PickDefaultModel(req.Provider) + } + } + if resolvedModel == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no default model available for this provider; please choose one"}) return } @@ -2989,11 +3032,28 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { if cfg.Providers == nil { cfg.Providers = make(map[string]*config.ProviderConfig) } - cfg.Providers[req.Provider] = &config.ProviderConfig{ - APIKey: req.APIKey, - BaseURL: req.BaseURL, - } - cfg.Model = req.Provider + "/" + req.Model + setupPC := &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + Name: req.Name, + Headers: cleanHeaders(req.Headers), + Vision: req.Vision, + Thinking: req.Thinking, + ReasoningEffort: req.ReasoningEffort, + } + // For a custom provider, persist the model as a custom model so it survives + // a model switch (otherwise it exists only as the active-model string and + // vanishes from the picker once changed). + if isCustom && resolvedModel != "" { + setupPC.CustomModels = []config.CustomModelConfig{{ + ID: resolvedModel, + Name: resolvedModel, + ToolCall: true, + Reasoning: req.ModelReasoning, + }} + } + cfg.Providers[req.Provider] = setupPC + cfg.Model = req.Provider + "/" + resolvedModel if err := config.SaveConfig(cfg); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) @@ -3006,12 +3066,12 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no active task to configure"}) return } - ag, err := eng.createAgent(req.Provider, req.Model) + ag, err := eng.createAgent(req.Provider, resolvedModel) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create agent: " + err.Error()}) return } - eng.applyModelSwitch(ag, req.Provider, req.Model) + eng.applyModelSwitch(ag, req.Provider, resolvedModel) // Publish the new config + registry to the live server so endpoints // (/api/models, context-limit, etc.) reflect the just-configured provider // without a restart. @@ -3026,16 +3086,29 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { // Notify clients that setup is complete. s.wsBroker.Broadcast(WSEvent{Type: "model_changed", TaskID: eng.taskID, Data: map[string]string{ "provider": req.Provider, - "model": req.Model, + "model": resolvedModel, }}) writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", "provider": req.Provider, - "model": req.Model, + "model": resolvedModel, }) } +// maskSecret hides a secret for display: first 4 and last 4 chars for longer +// values, "****" for short ones. Used for API keys and header values so the +// list endpoint never returns plaintext credentials. +func maskSecret(s string) string { + if s == "" { + return "" + } + if len(s) > 8 { + return s[:4] + "..." + s[len(s)-4:] + } + return "****" +} + // handleListProviders returns all configured providers (key masked). func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig() @@ -3045,27 +3118,46 @@ func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { } type providerDetail struct { - ID string `json:"id"` - APIKeySet bool `json:"api_key_set"` - APIKey string `json:"api_key,omitempty"` // masked - BaseURL string `json:"base_url,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` // display name for custom providers + Custom bool `json:"custom,omitempty"` + APIKeySet bool `json:"api_key_set"` + APIKey string `json:"api_key,omitempty"` // masked + BaseURL string `json:"base_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` // values masked + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` } result := make([]providerDetail, 0) for id, pc := range cfg.GetProviders() { detail := providerDetail{ - ID: id, - APIKeySet: pc.APIKey != "", - BaseURL: pc.BaseURL, + ID: id, + Name: pc.Name, + APIKeySet: pc.APIKey != "", + BaseURL: pc.BaseURL, + Vision: pc.Vision, + Thinking: pc.Thinking, + ReasoningEffort: pc.ReasoningEffort, + } + // A provider is "custom" if it isn't in the registry. The registry may + // be nil in setup mode; treat that as custom only when it also has a + // non-registry-style id (name set) to avoid mislabeling during setup. + if s.registry != nil { + detail.Custom = !s.registry.HasProvider(id) + } else if pc.Name != "" { + detail.Custom = true } if pc.APIKey != "" { - // Mask API key: show first 4 and last 4 chars. - key := pc.APIKey - if len(key) > 8 { - detail.APIKey = key[:4] + "..." + key[len(key)-4:] - } else { - detail.APIKey = "****" + detail.APIKey = maskSecret(pc.APIKey) + } + if len(pc.Headers) > 0 { + masked := make(map[string]string, len(pc.Headers)) + for k, v := range pc.Headers { + masked[k] = maskSecret(v) } + detail.Headers = masked } result = append(result, detail) } @@ -3074,12 +3166,21 @@ func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } -// handleAddProvider adds a new provider to the config. +// handleAddProvider adds a new provider to the config. For custom +// (non-registry) providers the caller should also send a name and at least one +// model so the provider is usable as an active model. func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { var req struct { - ID string `json:"id"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url,omitempty"` + ID string `json:"id"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` + ModelReasoning bool `json:"model_reasoning,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) @@ -3089,6 +3190,10 @@ func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id and api_key are required"}) return } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } cfg, err := config.LoadConfig() if err != nil { @@ -3097,10 +3202,159 @@ func (s *Server) handleAddProvider(w http.ResponseWriter, r *http.Request) { if cfg.Providers == nil { cfg.Providers = make(map[string]*config.ProviderConfig) } - cfg.Providers[req.ID] = &config.ProviderConfig{ - APIKey: req.APIKey, - BaseURL: req.BaseURL, + + // A custom provider (not in the registry) needs a base URL so requests can + // be routed, and at least one model id to be usable. + isCustom := s.registry == nil || !s.registry.HasProvider(req.ID) + if isCustom && req.BaseURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "base_url is required for custom providers"}) + return + } + + pc := &config.ProviderConfig{ + APIKey: req.APIKey, + BaseURL: req.BaseURL, + Name: req.Name, + Headers: cleanHeaders(req.Headers), + Vision: req.Vision, + Thinking: req.Thinking, + ReasoningEffort: req.ReasoningEffort, + } + if isCustom && req.Model != "" { + pc.CustomModels = []config.CustomModelConfig{{ + ID: req.Model, + Name: req.Model, + ToolCall: true, + Reasoning: req.ModelReasoning, + }} } + cfg.Providers[req.ID] = pc + + // If there is no active model yet and this is a custom provider with an + // explicit model, adopt it as the active model so the app can boot. + if cfg.Model == "" && isCustom && req.Model != "" { + cfg.Model = req.ID + "/" + req.Model + } + + if err := config.SaveConfig(cfg); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// validReasoningEffort whitelists the thinking-depth values accepted from +// clients. The set mirrors the effort levels models.dev publishes under +// reasoning_options (see internal/model registry). Empty means "unset / omit +// the parameter". +func validReasoningEffort(v string) bool { + switch v { + case "", "none", "minimal", "low", "medium", "high", "xhigh", "max": + return true + } + return false +} + +// cleanHeaders drops rows with an empty key and trims whitespace, so blank +// editor rows from the UI never reach the saved config. +func cleanHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + k = strings.TrimSpace(k) + if k == "" { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + +// handleUpdateProvider edits an existing provider, merging secret fields so the +// client may omit unchanged credentials. An empty api_key keeps the stored key; +// a header value left empty keeps the stored value for that key (the list +// endpoint returns masked secrets, so the UI sends blanks for untouched ones). +func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider id is required"}) + return + } + var req struct { + APIKey string `json:"api_key,omitempty"` + BaseURL string `json:"base_url,omitempty"` + Name string `json:"name,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Vision *bool `json:"vision,omitempty"` + Thinking *bool `json:"thinking,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if !validReasoningEffort(req.ReasoningEffort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid reasoning_effort"}) + return + } + + cfg, err := config.LoadConfig() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + pc := cfg.GetProviders()[id] + if pc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"}) + return + } + + // Mutate in place so fields not exposed by this endpoint (display name, + // custom models, deprecated lists) are preserved untouched. + prevHeaders := pc.Headers + // base_url uses keep-on-empty semantics (like api_key): the list endpoint + // masks secrets but returns base_url verbatim, yet a client that doesn't + // touch the endpoint may still submit an empty value. Overwriting + // unconditionally would wipe a stored custom endpoint, so only adopt a + // non-empty incoming value. + if req.BaseURL != "" { + pc.BaseURL = req.BaseURL + } + pc.Vision = req.Vision + pc.Thinking = req.Thinking + pc.ReasoningEffort = req.ReasoningEffort + if req.Name != "" { + pc.Name = req.Name + } + if req.APIKey != "" { + pc.APIKey = req.APIKey + } + // Merge headers: empty incoming value ⇒ keep the stored secret for that key. + pc.Headers = nil + if cleaned := cleanHeaders(req.Headers); len(cleaned) > 0 { + merged := make(map[string]string, len(cleaned)) + for k, v := range cleaned { + if v == "" { + if ov, ok := prevHeaders[k]; ok { + merged[k] = ov + continue + } + } + merged[k] = v + } + pc.Headers = merged + } + + if cfg.Providers == nil { + cfg.Providers = make(map[string]*config.ProviderConfig) + } + cfg.Providers[id] = pc if err := config.SaveConfig(cfg); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save config: " + err.Error()}) return @@ -3182,10 +3436,11 @@ func (s *Server) handleGetModelState(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ - "recent": recent, - "favorite": favorites, - "enabled_models": enabledModels, - "disabled_models": disabledModels, + "recent": recent, + "favorite": favorites, + "enabled_models": enabledModels, + "disabled_models": disabledModels, + "effort_overrides": state.EffortOverrides, }) } @@ -3250,6 +3505,44 @@ func (s *Server) handleToggleModelEnabled(w http.ResponseWriter, r *http.Request }) } +// handleSetModelEffort records the user's reasoning-effort choice for a single +// model (set from the chat model picker). An empty effort clears the override, +// restoring the provider-level default. The agent is rebuilt so the change +// takes effect on the next turn. +func (s *Server) handleSetModelEffort(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + Effort string `json:"effort"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if req.Provider == "" || req.Model == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"}) + return + } + if req.Effort != "" && !validReasoningEffort(req.Effort) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid effort"}) + return + } + + state, err := config.LoadModelState() + if err != nil { + state = &config.ModelState{} + } + state.SetEffortOverride(config.ModelRef{Provider: req.Provider, Model: req.Model}, req.Effort) + if err := config.SaveModelState(state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "effort": req.Effort, + }) +} + // --- Helpers --- func writeJSON(w http.ResponseWriter, status int, data any) { diff --git a/script/generate_models.go b/script/generate_models.go index 0bca1507..5c2a68e0 100644 --- a/script/generate_models.go +++ b/script/generate_models.go @@ -71,11 +71,20 @@ type RegistryModel struct { Knowledge string `json:"knowledge,omitempty"` ReleaseDate string `json:"release_date,omitempty"` LastUpdated string `json:"last_updated,omitempty"` - Modalities *ModelModalities `json:"modalities,omitempty"` - OpenWeights bool `json:"open_weights,omitempty"` - Cost *ModelCost `json:"cost,omitempty"` - Limit *ModelLimit `json:"limit,omitempty"` - Status string `json:"status,omitempty"` + Modalities *ModelModalities `json:"modalities,omitempty"` + OpenWeights bool `json:"open_weights,omitempty"` + Cost *ModelCost `json:"cost,omitempty"` + Limit *ModelLimit `json:"limit,omitempty"` + Status string `json:"status,omitempty"` + ReasoningOptions []ReasoningOption `json:"reasoning_options,omitempty"` +} + +// ReasoningOption mirrors models.dev's reasoning_options entries. +type ReasoningOption struct { + Type string `json:"type"` + Values []string `json:"values,omitempty"` + Min *int `json:"min,omitempty"` + Max *int `json:"max,omitempty"` } // ModelModalities describes input/output modalities. @@ -305,6 +314,10 @@ func run() error { b.WriteString("\t\t\t\t},\n") } + if opts := emitReasoningOptions(model.ReasoningOptions); opts != "" { + b.WriteString(opts) + } + b.WriteString("\t\t\t},\n") } @@ -325,6 +338,50 @@ func run() error { return nil } +// emitReasoningOptions renders a model's reasoning_options as a Go literal. It +// returns "" when there are no usable options. JSON nulls in effort value lists +// (which decode to "") are dropped, and Min/Max are emitted via the intPtr +// helper so nil bounds stay nil. +func emitReasoningOptions(opts []ReasoningOption) string { + if len(opts) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\t\t\t\tReasoningOptions: []ReasoningOption{\n") + for _, ro := range opts { + if ro.Type == "" { + continue + } + b.WriteString("\t\t\t\t\t{") + fmt.Fprintf(&b, "Type: %q", ro.Type) + vals := make([]string, 0, len(ro.Values)) + for _, v := range ro.Values { + if v != "" { + vals = append(vals, v) + } + } + if len(vals) > 0 { + b.WriteString(", Values: []string{") + for i, v := range vals { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q", v) + } + b.WriteString("}") + } + if ro.Min != nil { + fmt.Fprintf(&b, ", Min: intPtr(%d)", *ro.Min) + } + if ro.Max != nil { + fmt.Fprintf(&b, ", Max: intPtr(%d)", *ro.Max) + } + b.WriteString("},\n") + } + b.WriteString("\t\t\t\t},\n") + return b.String() +} + func escapeString(s string) string { // Basic escaping for Go string literals s = strings.ReplaceAll(s, "\\", "\\\\") diff --git a/web/src/components/ChatInput.vue b/web/src/components/ChatInput.vue index 4bf61657..071997fb 100644 --- a/web/src/components/ChatInput.vue +++ b/web/src/components/ChatInput.vue @@ -28,6 +28,7 @@ const { t } = useI18n() const input = ref('') const textarea = ref(null) const showModelPicker = ref(false) +const showEffortPicker = ref(false) const showModePicker = ref(false) const showAddMenu = ref(false) const showContextPopup = ref(false) @@ -133,6 +134,31 @@ function modelInfoFor(provider: string, model: string) { // + capability dots. const currentModelInfo = computed(() => modelInfoFor(store.providerName, store.modelName)) +// The reasoning-effort levels the current model accepts, taken from its +// models.dev reasoning_options (type === 'effort'). Empty when the model has +// no effort control — in which case the effort control is hidden entirely. +const currentEffortOptions = computed(() => { + const info = currentModelInfo.value + if (!info?.reasoning_options) return [] + for (const o of info.reasoning_options) { + if (o.type === 'effort' && o.values?.length) return o.values + } + return [] +}) + +// Whether to show the per-model effort control: only when the active model +// advertises effort levels. A "" (off) option is always prepended so the user +// can clear the override. +const showEffortControl = computed(() => currentEffortOptions.value.length > 0) + +// The user's saved effort choice for the current model ('' = unset/default). +const currentEffort = computed(() => store.getEffortOverride(store.providerName, store.modelName)) + +async function pickEffort(effort: string) { + // Empty means "clear override" → send '' so the provider default is restored. + await store.setModelEffort(store.providerName, store.modelName, effort) +} + // Favorite recent models (recent keeps the recency order), filtered by the // search box and excluding the current model (it's pinned above). const favoriteModelRefs = computed(() => { @@ -290,6 +316,7 @@ async function send() { function selectModel(provider: string, model: string) { showModelPicker.value = false + showEffortPicker.value = false store.switchModel(provider, model) } @@ -360,6 +387,7 @@ function handleClickOutside(e: MouseEvent) { showAddMenu.value = false showSlashMenu.value = false showContextPopup.value = false + showEffortPicker.value = false if (showManageModels.value) { showManageModels.value = false modelFilter.value = '' @@ -629,11 +657,11 @@ watch(() => store.imageSupport, (supported) => { -
+
+ + + +
+ + +
+
store.imageSupport, (supported) => { } .mm-trigger[aria-expanded='true'] .mm-trigger-chev { transform: rotate(180deg); } +/* Per-model reasoning-effort control — visually matches the model picker + trigger (transparent border, no background; muted bg only on hover). */ +.effort-trigger { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: var(--radius-lg); + background: transparent; + color: var(--color-foreground); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background var(--duration-fast); +} +.effort-trigger:hover { background: var(--color-muted); } +/* When an effort is selected, tint only the text (no chip fill/border), so the + trigger stays visually consistent with the adjacent model picker. */ +.effort-trigger.on { color: var(--color-primary); } +.effort-chev { opacity: 0.55; transition: transform var(--duration-normal); } +.effort-trigger[aria-expanded='true'] .effort-chev { transform: rotate(180deg); } +.effort-panel { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 4px; + z-index: var(--z-dropdown); + min-width: 140px; + padding: 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + gap: 1px; +} +.effort-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-foreground); + font-size: 12px; + cursor: pointer; + transition: background var(--duration-fast); +} +.effort-row:hover { background: var(--color-muted); } +.effort-row.active { color: var(--color-primary); font-weight: 600; } +.effort-row .heroicon { color: var(--color-primary); } + .mm-panel { position: absolute; bottom: 100%; diff --git a/web/src/components/SettingsDialog.vue b/web/src/components/SettingsDialog.vue index dca10d21..a3271441 100644 --- a/web/src/components/SettingsDialog.vue +++ b/web/src/components/SettingsDialog.vue @@ -3,7 +3,7 @@ import { ref, reactive, computed, watch, nextTick, onUnmounted, inject, type Com import { useChatStore } from '@/stores/chat' import { useTheme } from '@/composables/useTheme' import { api } from '@/composables/api' -import type { MCPServerInfo, MCPServerRequest, SkillInfo, SSHAlias, SetupProvider, SetupModel, ProviderDetail, RemoteMeta } from '@/types/api' +import type { MCPServerInfo, MCPServerRequest, SkillInfo, SSHAlias, SetupProvider, ProviderDetail, RemoteMeta } from '@/types/api' import QRCode from 'qrcode' import { Dialog, @@ -23,6 +23,7 @@ import { ComputerDesktopIcon, ServerIcon, ChevronRightIcon, + PencilSquareIcon, PlusIcon, SignalIcon, ShieldCheckIcon, @@ -121,17 +122,33 @@ const bleSaving = ref(false) // Provider management state const configuredProviders = ref([]) const showAddProvider = ref(false) -const addProviderStep = ref<'select' | 'model' | 'apikey'>('select') +const addProviderStep = ref<'select' | 'apikey'>('select') const addProviderList = ref([]) -const addProviderModels = ref([]) const addSelectedProvider = ref('') -const addSelectedModel = ref('') const addApiKey = ref('') const addBaseURL = ref('') const addLoading = ref(false) const addError = ref('') const deleteConfirmId = ref('') +// Custom provider fields (when adding a non-registry provider). +const addIsCustom = ref(false) +const addCustomId = ref('') +const addCustomName = ref('') +const addCustomModelId = ref('') +const addCustomReasoning = ref(false) + +// Provider advanced settings (shared by add + edit flows). +interface ProviderHeaderRow { key: string; value: string; ph?: string } +const editingProviderId = ref('') // '' = add mode, otherwise editing this provider +const addAdvancedOpen = ref(false) +const addHeaders = ref([]) +const addValidating = ref(false) +const addValidationResult = ref<{ valid: boolean; error?: string } | null>(null) + +// Any edit to the credentials invalidates a prior test-connection result. +watch([addApiKey, addBaseURL, addHeaders], () => { addValidationResult.value = null }, { deep: true }) + watch(() => props.open, async (isOpen) => { if (isOpen) { mcpEditing.value = null @@ -593,14 +610,27 @@ const iconFor: Record = { -async function startAddProvider() { - showAddProvider.value = true - addProviderStep.value = 'select' +function resetProviderForm() { + editingProviderId.value = '' addSelectedProvider.value = '' - addSelectedModel.value = '' addApiKey.value = '' addBaseURL.value = '' addError.value = '' + addAdvancedOpen.value = false + addHeaders.value = [] + addValidating.value = false + addValidationResult.value = null + addIsCustom.value = false + addCustomId.value = '' + addCustomName.value = '' + addCustomModelId.value = '' + addCustomReasoning.value = false +} + +async function startAddProvider() { + showAddProvider.value = true + resetProviderForm() + addProviderStep.value = 'select' addLoading.value = true try { addProviderList.value = await api.setupProviders() @@ -608,33 +638,128 @@ async function startAddProvider() { addLoading.value = false } -async function selectAddProvider(id: string) { - addSelectedProvider.value = id - addLoading.value = true - addError.value = '' +// Open the form pre-filled to edit an already configured provider. Secrets are +// returned masked by the API, so api_key is left blank (placeholder shows the +// mask) and each header value starts empty — blanks mean "keep existing". +function startEditProvider(p: ProviderDetail) { + showAddProvider.value = true + resetProviderForm() + editingProviderId.value = p.id + addSelectedProvider.value = p.id + addProviderStep.value = 'apikey' + addBaseURL.value = p.base_url || '' + addHeaders.value = Object.entries(p.headers ?? {}).map(([key, value]) => ({ key, value: '', ph: value })) + addIsCustom.value = !!p.custom + addCustomName.value = p.name || '' + if (addHeaders.value.length || p.base_url) { + addAdvancedOpen.value = true + } +} + +function addProviderHeaderRow() { + addHeaders.value.push({ key: '', value: '' }) +} + +function removeProviderHeaderRow(i: number) { + addHeaders.value.splice(i, 1) +} + +// Build the advanced payload shared by add + update. Header rows with a blank +// key are dropped; blank values are kept so the server can preserve the stored +// secret on edit. +function collectProviderAdvanced() { + const headers: Record = {} + for (const h of addHeaders.value) { + const k = h.key.trim() + if (k) headers[k] = h.value + } + return { + base_url: addBaseURL.value || undefined, + headers: Object.keys(headers).length ? headers : undefined, + } +} + +async function validateProviderConnection() { + addValidating.value = true + addValidationResult.value = null + const headers: Record = {} + for (const h of addHeaders.value) { + const k = h.key.trim() + if (k && h.value) headers[k] = h.value + } try { - addProviderModels.value = await api.setupProviderModels(id) - addProviderStep.value = 'model' - } catch { - addError.value = 'Failed to load models' + addValidationResult.value = await api.setupValidate({ + provider: editingProviderId.value || addSelectedProvider.value || 'openai-compatible', + api_key: addApiKey.value, + base_url: addBaseURL.value || undefined, + headers: Object.keys(headers).length ? headers : undefined, + }) + } catch (err: unknown) { + addValidationResult.value = { valid: false, error: err instanceof Error ? err.message : 'failed' } } - addLoading.value = false + addValidating.value = false } -function selectAddModel(id: string) { - addSelectedModel.value = id +// Select a registry provider from the list → go straight to the api-key step +// (no model step: the server picks a default). +function selectAddProvider(id: string) { + addSelectedProvider.value = id + addIsCustom.value = false + addProviderStep.value = 'apikey' +} + +// Choose the custom-provider entry from the list. +function selectAddCustomProvider() { + addSelectedProvider.value = '' + addIsCustom.value = true + addBaseURL.value = '' + addCustomReasoning.value = false + addAdvancedOpen.value = true addProviderStep.value = 'apikey' } async function submitAddProvider() { - addLoading.value = true addError.value = '' + // Validate custom-provider required fields before hitting the API. + if (!editingProviderId.value && addIsCustom.value) { + if (!addCustomId.value.trim()) { + addError.value = t('settings.providers.customIdRequired') + return + } + if (!addBaseURL.value.trim()) { + addError.value = t('settings.providers.customUrlRequired') + return + } + if (!addCustomModelId.value.trim()) { + addError.value = t('settings.providers.customModelRequired') + return + } + } + addLoading.value = true try { - await api.addProvider({ - id: addSelectedProvider.value, - api_key: addApiKey.value, - base_url: addBaseURL.value || undefined, - }) + const advanced = collectProviderAdvanced() + if (editingProviderId.value) { + await api.updateProvider(editingProviderId.value, { + api_key: addApiKey.value || undefined, + name: addIsCustom.value ? (addCustomName.value || undefined) : undefined, + ...advanced, + }) + } else if (addIsCustom.value) { + await api.addProvider({ + id: addCustomId.value.trim(), + api_key: addApiKey.value, + name: addCustomName.value.trim() || undefined, + model: addCustomModelId.value.trim(), + model_reasoning: addCustomReasoning.value, + ...advanced, + }) + } else { + await api.addProvider({ + id: addSelectedProvider.value, + api_key: addApiKey.value, + ...advanced, + }) + } // Refresh provider list configuredProviders.value = await api.listProviders() showAddProvider.value = false @@ -658,6 +783,7 @@ async function deleteProvider(id: string) { } const addProviderInfo = () => addProviderList.value.find(p => p.id === addSelectedProvider.value) +