From f1564a99b59c6066a49f4bb1e77362a06922d03d Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 17 Jul 2026 01:28:36 +0800 Subject: [PATCH 1/4] feat(model): add Kimi K3 to the Kimi For Coding provider Adds the k3 model documented for the coding plan's third-party-agent setup: 1,048,576-token context (4x the kimi-for-coding window), image input, and deep reasoning on by default. Per the official docs only "max" is currently accepted for thinking depth (low/high are documented as planned but not yet live), so ReasoningOptions offers "max" alone. k3 takes over the provider's Recommended star from kimi-for-coding as the flagship; kimi-for-coding stays DefaultEnabled for the lower Andante tier that cannot call k3 (k3 needs Moderato or above). Output limit is unstated in the docs, so it carries the family's 32768 default pending live confirmation. Verified against the live endpoint with a real key: k3 answers image questions through the OpenAI-compatible path with the exact multipart format jcode sends, and reasoning_effort=max round-trips. Co-Authored-By: Claude Fable 5 --- internal/model/registry.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/model/registry.go b/internal/model/registry.go index 65b2d80d..d89ee1f6 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(), From 715f5f77c10745083f28130a05e64a0ab91fecfa Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 17 Jul 2026 01:28:54 +0800 Subject: [PATCH 2/4] fix(web): stop provider form silently blinding vision models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider add/edit form defaulted its vision/thinking switches to false and always submitted them, so saving any provider (even without touching the switches) wrote "vision": false into config. That override makes chatmodel collapse multimodal messages to text-only — the UI shows attached image thumbnails while the model receives none of them. Reproduced end to end against a logging mock endpoint: with the stored override the user turn arrives text-only; without it the same turn arrives as [text, image_url]. Fixes, verified in the browser against a live dev instance: - Remove the vision control from the provider form entirely. Image support is per-model metadata (registry modalities / custom-model attachment), not a provider connection setting. The update API uses replacement semantics, so simply not sending the field means one save of an affected provider clears the stale override from config. Hand-edited config values are still honored as an escape hatch. - Keep the thinking (enable_thinking kwarg) control for custom providers only, as a tri-state Default/On/Off that omits the field on Default instead of forcing an explicit false. Registry providers derive everything from models.dev metadata. - Rebuild the agents of live engines running on a provider after its config is updated (rebuildEnginesForProvider), so connection-level changes — api_key, base_url, headers, vision, thinking, effort — take effect on the next turn instead of after a restart or model switch. Mirrors the MCP-reload rebuild path. - Recompute the chat input's imageSupport whenever the selected model or the provider list changes. It was only set from /health at page load and from loadModels, so switching from a text-only model to a vision model in the picker left the attach button disabled (and the reverse allowed attaching to models that cannot see images) until a reload. Co-Authored-By: Claude Fable 5 --- internal/web/providers.go | 49 ++++++++++++++++++++++ web/src/app/store.ts | 15 +++++++ web/src/components/SettingsDialog.tsx | 60 +++++++++++++++++---------- web/src/lib/api.ts | 6 ++- 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/internal/web/providers.go b/internal/web/providers.go index c381dde5..9f774a0a 100644 --- a/internal/web/providers.go +++ b/internal/web/providers.go @@ -641,9 +641,58 @@ 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 + } + eng.setAgent(ag) + } +} + // 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..06ebe9cc 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -471,15 +471,29 @@ 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 +} + 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 +503,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..1b7ae03b 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')}
)} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index b17cbbb3..4441b94b 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -9,6 +9,9 @@ export default { common: { ok: 'OK', + default: 'Default', + on: 'On', + off: 'Off', add: 'Add', cancel: 'Cancel', close: 'Close', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 6873fdb7..7354d710 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -4,6 +4,9 @@ export default { common: { ok: 'OK', + default: 'デフォルト', + on: 'オン', + off: 'オフ', add: '追加', cancel: 'キャンセル', close: '閉じる', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 7fbfcd86..73bba41d 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -4,6 +4,9 @@ export default { common: { ok: '확인', + default: '기본값', + on: '켬', + off: '끔', add: '추가', cancel: '취소', close: '닫기', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index b3e0cc52..d1f24533 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -4,6 +4,9 @@ export default { common: { ok: '确定', + default: '默认', + on: '开启', + off: '关闭', add: '添加', cancel: '取消', close: '关闭', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 98bd3277..1c7f5a49 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -5,6 +5,9 @@ export default { common: { ok: '確定', + default: '預設', + on: '開啟', + off: '關閉', add: '添加', cancel: '取消', close: '關閉',