diff --git a/internal/model/registry.go b/internal/model/registry.go index 65b2d80d..d082f50f 100644 --- a/internal/model/registry.go +++ b/internal/model/registry.go @@ -514,7 +514,7 @@ var staticProviders = map[string]*RegistryProvider{ // "kimi-for-coding" record, but its model ids (k2p5/k2p6/k2p7/kimi-k2-thinking) // are undocumented aliases that the vendor's own /models endpoint does not // advertise, so the provider is hand-written here instead of pulled through - // generate_models.go. The two ids below are the only ones the official + // generate_models.go. The three ids below are the ones the official // OpenAI-compatible setup documents. Verified against the live endpoint // 2026-07-15: tool calls work, reasoning_effort is honored, and every model // rejects temperature != 1 (hence Temperature stays false — note models.dev @@ -526,10 +526,26 @@ var staticProviders = map[string]*RegistryProvider{ API: "https://api.kimi.com/coding/v1", Doc: "https://www.kimi.com/code/docs/third-party-tools/other-coding-agents.html", Models: map[string]*RegistryModel{ + // K3 is Moonshot's newest flagship: a 1,048,576-token context (4x the + // kimi-for-coding window) with deep reasoning on by default. Per the docs + // its thinking depth is the only one configurable via reasoning_effort, + // and only "max" is currently accepted (low/high are documented as + // planned but not yet live), so ReasoningOptions offers "max" alone. + // Requires a Moderato-or-above subscription (kimi-for-coding also works + // on the lower Andante tier). Output limit is unstated in the docs; it + // carries the family's 32768 default pending live confirmation. + "k3": { + ID: "k3", Name: "Kimi K3", Family: "kimi", + Attachment: true, Reasoning: true, ToolCall: true, + DefaultEnabled: true, Recommended: true, + Modalities: &ModelModalities{Input: []string{"text", "image", "video"}, Output: []string{"text"}}, + Limit: &ModelLimit{Context: 1048576, Output: 32768}, + ReasoningOptions: []ReasoningOption{{Type: "effort", Values: []string{"max"}}}, + }, "kimi-for-coding": { ID: "kimi-for-coding", Name: "Kimi For Coding", Family: "kimi", Attachment: true, Reasoning: true, ToolCall: true, - DefaultEnabled: true, Recommended: true, + DefaultEnabled: true, Modalities: &ModelModalities{Input: []string{"text", "image", "video"}, Output: []string{"text"}}, Limit: &ModelLimit{Context: 262144, Output: 32768}, ReasoningOptions: standardEffortOptions(), diff --git a/internal/web/engine.go b/internal/web/engine.go index 6ed6628d..87b55f12 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -280,6 +280,20 @@ func (e *Engine) setAgent(ag *adk.ChatModelAgent) { e.agent = ag } +// setAgentIfModel installs ag only if the engine is still on provider/model. +// Rebuild paths construct agents outside emu; if a model switch lands in that +// window, its (newer) agent must win over the rebuild's stale one. Returns +// whether the agent was installed. +func (e *Engine) setAgentIfModel(ag *adk.ChatModelAgent, provider, model string) bool { + e.emu.Lock() + defer e.emu.Unlock() + if e.providerName != provider || e.modelName != model { + return false + } + e.agent = ag + return true +} + // resolveEngine returns the engine for taskID, or the active engine when taskID // is empty (legacy / no-task_id callers). Returns nil when taskID is unknown. func (s *Server) resolveEngine(taskID string) *Engine { diff --git a/internal/web/providers.go b/internal/web/providers.go index c381dde5..cc5c0708 100644 --- a/internal/web/providers.go +++ b/internal/web/providers.go @@ -641,9 +641,63 @@ func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { s.cfg = cfg s.registry = model.NewModelRegistryWithConfig(cfg) + // Rebuild the agents of live engines currently running on this provider so + // connection-level changes (api_key, base_url, headers, vision, thinking, + // reasoning_effort) take effect immediately. The old agent captured a chat + // model built from the previous ProviderConfig — without a rebuild, e.g. a + // cleared vision override would keep silently stripping images until the + // next model/mode switch. createAgent re-reads the config from disk (already + // saved above), mirroring the MCP-reload rebuild path. + s.rebuildEnginesForProvider(id) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +// rebuildEnginesForProvider rebuilds the agent of every live engine whose +// current model belongs to the given provider. Rebuild failures are logged and +// skipped: the engine keeps its previous (stale but working) agent rather than +// being left without one. +func (s *Server) rebuildEnginesForProvider(providerID string) { + s.tasksMu.RLock() + engines := make([]*Engine, 0, len(s.tasks)) + for _, e := range s.tasks { + engines = append(engines, e) + } + s.tasksMu.RUnlock() + if a := s.activeEngine(); a != nil { + found := false + for _, e := range engines { + if e == a { + found = true + break + } + } + if !found { + engines = append(engines, a) + } + } + for _, eng := range engines { + if eng.createAgent == nil { + continue + } + prov, mdl, _ := eng.modelSnapshot() + if prov != providerID { + continue + } + ag, err := eng.createAgent(prov, mdl) + if err != nil { + config.Logger().Printf("[web] provider %s update: agent rebuild failed for task %s: %v", providerID, eng.taskID, err) + continue + } + // Conditional install: a model switch that lands while createAgent runs + // outside emu built a newer agent from the already-updated config — it + // must not be clobbered with this now-stale one. + if !eng.setAgentIfModel(ag, prov, mdl) { + config.Logger().Printf("[web] provider %s update: task %s switched models mid-rebuild; skipping stale agent", providerID, eng.taskID) + } + } +} + // handleDeleteProvider removes a provider from the config. func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { providerID := r.PathValue("id") diff --git a/web/src/app/store.ts b/web/src/app/store.ts index dcd116ae..f465f260 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -471,15 +471,36 @@ const initialModel: ModelState = { maxIterations: 0, } +// Recompute imageSupport from the providers list for the currently selected +// model. Runs on every provider/model/providers change so the flag can never +// go stale when the user switches models (picker selectModel, WS +// model_changed). When the current model isn't in the list yet (providers not +// loaded), the last value — seeded from /health at startup — is kept. +function syncImageSupport(s: ModelState) { + const cur = s.providers + .find((p) => p.id === s.providerName) + ?.models.find((m) => m.id === s.modelName) + if (cur) { + s.imageSupport = !!cur.image_support + } else if (s.providers.length > 0) { + // Providers are loaded but the current model isn't among them (e.g. set + // via automation to an unlisted model) — don't carry the previous model's + // capability forward. + s.imageSupport = false + } +} + const modelSlice = createSlice({ name: 'model', initialState: initialModel, reducers: { setProvider(s, a: { payload: string }) { s.providerName = a.payload + syncImageSupport(s) }, setModel(s, a: { payload: string }) { s.modelName = a.payload + syncImageSupport(s) }, setSmallModel(s, a: { payload: string }) { s.smallModel = a.payload @@ -489,6 +510,7 @@ const modelSlice = createSlice({ }, setProviders(s, a: { payload: ProviderInfo[] }) { s.providers = a.payload + syncImageSupport(s) }, setModelState(s, a: { payload: { recent: ModelRef[]; favorite: ModelRef[]; effortOverrides?: Record } }) { s.recentModels = a.payload.recent diff --git a/web/src/components/SettingsDialog.tsx b/web/src/components/SettingsDialog.tsx index b442462d..39baa771 100644 --- a/web/src/components/SettingsDialog.tsx +++ b/web/src/components/SettingsDialog.tsx @@ -10,7 +10,7 @@ * permissions), Remote (SSH aliases), Usage (stats). * * The Providers tab is the most complete port: list of provider cards, inline - * add/edit form with advanced fields (base_url, headers, vision, thinking, + * add/edit form with advanced fields (base_url, headers, thinking, * reasoning_effort), browsable model catalog with add/remove/toggle, and an * inline custom-model authoring form. Other tabs are functional CRUD ports of * the Vue logic. @@ -967,7 +967,7 @@ function ProviderCard({ ) } -/** Add/edit provider form with advanced config (base_url, headers, vision, thinking, reasoning_effort). */ +/** Add/edit provider form with advanced config (base_url, headers, thinking, reasoning_effort). */ function ProviderForm({ editing, setupList, @@ -992,8 +992,17 @@ function ProviderForm({ const [headers, setHeaders] = useState<{ key: string; value: string }[]>( Object.entries(editing?.headers ?? {}).map(([key, value]) => ({ key, value })), ) - const [vision, setVision] = useState(!!editing?.vision) - const [thinking, setThinking] = useState(!!editing?.thinking) + // Thinking is a tri-state override for the qwen3-style enable_thinking + // request kwarg — only meaningful for custom (self-hosted) endpoints, so it + // is only shown and sent for custom providers. '' (Default) omits the field + // so the backend keeps nil and never sends the kwarg. Vision has no form + // control at all: image support is per-model metadata (registry modalities / + // custom-model attachment), and a provider-level override only served to + // silently strip images. Both fields use update-by-replacement on the + // backend, so simply not sending them clears any stale stored override. + const [thinking, setThinking] = useState<'' | 'on' | 'off'>( + editing?.thinking === true ? 'on' : editing?.thinking === false ? 'off' : '', + ) const [reasoningEffort, setReasoningEffort] = useState(editing?.reasoning_effort ?? '') const [advancedOpen, setAdvancedOpen] = useState( !!(editing?.base_url || (editing?.headers && Object.keys(editing.headers).length)), @@ -1005,6 +1014,9 @@ function ProviderForm({ const availableSetup = setupList.filter((s) => !configuredIds.includes(s.id)) const providerId = isEdit ? editing!.id : mode === 'custom' ? customId.trim() : selId + // Custom (non-registry) providers get the enable_thinking knob; registry + // providers derive everything from models.dev metadata. + const isCustomProvider = isEdit ? !!editing?.custom : mode === 'custom' async function save(e: React.FormEvent) { e.preventDefault() @@ -1020,13 +1032,16 @@ function ProviderForm({ setSaving(true) try { const builtHeaders = buildHeaders(headers) + // '' (Default) → undefined so the JSON omits the override entirely. + // Vision is never sent: image support comes from model metadata, and + // omitting the field clears any stale stored override on save. + const thinkingOverride = !isCustomProvider || thinking === '' ? undefined : thinking === 'on' if (isEdit) { const data: Parameters[1] = { name: name || undefined, base_url: baseUrl || undefined, headers: Object.keys(builtHeaders).length ? builtHeaders : undefined, - vision, - thinking, + thinking: thinkingOverride, reasoning_effort: reasoningEffort || undefined, } if (apiKey.trim()) data.api_key = apiKey.trim() @@ -1036,8 +1051,7 @@ function ProviderForm({ id: providerId, api_key: apiKey.trim(), name: name || undefined, - vision, - thinking, + thinking: thinkingOverride, reasoning_effort: reasoningEffort || undefined, base_url: baseUrl || undefined, headers: Object.keys(builtHeaders).length ? builtHeaders : undefined, @@ -1193,20 +1207,24 @@ function ProviderForm({
-
-
-
{t('settings.providers.supportImage')}
-
{t('settings.providers.supportImageDesc')}
-
- setVision((v) => !v)} /> -
-
-
-
{t('settings.providers.customReasoning')}
-
{t('settings.providers.customReasoningDesc')}
+ {isCustomProvider && ( +
+
+
{t('settings.providers.customReasoning')}
+
{t('settings.providers.customReasoningDesc')}
+
+
- setThinking((v) => !v)} /> -
+ )}
{t('settings.providers.supportReasoning')}