Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions internal/model/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
Expand Down
14 changes: 14 additions & 0 deletions internal/web/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions internal/web/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
cnjack marked this conversation as resolved.
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")
Expand Down
22 changes: 22 additions & 0 deletions web/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, string> } }) {
s.recentModels = a.payload.recent
Expand Down
60 changes: 39 additions & 21 deletions web/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)),
Expand All @@ -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()
Expand All @@ -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<typeof api.updateProvider>[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()
Expand All @@ -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,
Expand Down Expand Up @@ -1193,20 +1207,24 @@ function ProviderForm({

<Field label={t('settings.providers.advanced')}>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div>
<div className="text-[12px] font-medium text-[var(--color-foreground)]">{t('settings.providers.supportImage')}</div>
<div className="text-[10px] text-[var(--color-muted-foreground)]">{t('settings.providers.supportImageDesc')}</div>
</div>
<Switch on={vision} onClick={() => setVision((v) => !v)} />
</div>
<div className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div>
<div className="text-[12px] font-medium text-[var(--color-foreground)]">{t('settings.providers.customReasoning')}</div>
<div className="text-[10px] text-[var(--color-muted-foreground)]">{t('settings.providers.customReasoningDesc')}</div>
{isCustomProvider && (
<div className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div>
<div className="text-[12px] font-medium text-[var(--color-foreground)]">{t('settings.providers.customReasoning')}</div>
<div className="text-[10px] text-[var(--color-muted-foreground)]">{t('settings.providers.customReasoningDesc')}</div>
</div>
<select
value={thinking}
onChange={(e) => setThinking(e.target.value as '' | 'on' | 'off')}
className={INPUT_SM}
style={{ width: '8rem' }}
>
<option value="">{t('common.default')}</option>
<option value="on">{t('common.on')}</option>
<option value="off">{t('common.off')}</option>
</select>
</div>
<Switch on={thinking} onClick={() => setThinking((v) => !v)} />
</div>
)}
<div className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div className="text-[12px] font-medium text-[var(--color-foreground)]">{t('settings.providers.supportReasoning')}</div>
<select
Expand Down
3 changes: 3 additions & 0 deletions web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
export default {
common: {
ok: 'OK',
default: 'Default',
on: 'On',
off: 'Off',
add: 'Add',
cancel: 'Cancel',
close: 'Close',
Expand Down
3 changes: 3 additions & 0 deletions web/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
export default {
common: {
ok: 'OK',
default: 'デフォルト',
on: 'オン',
off: 'オフ',
add: '追加',
cancel: 'キャンセル',
close: '閉じる',
Expand Down
3 changes: 3 additions & 0 deletions web/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
export default {
common: {
ok: '확인',
default: '기본값',
on: '켬',
off: '끔',
add: '추가',
cancel: '취소',
close: '닫기',
Expand Down
3 changes: 3 additions & 0 deletions web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
export default {
common: {
ok: '确定',
default: '默认',
on: '开启',
off: '关闭',
add: '添加',
cancel: '取消',
close: '关闭',
Expand Down
3 changes: 3 additions & 0 deletions web/src/i18n/locales/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
export default {
common: {
ok: '確定',
default: '預設',
on: '開啟',
off: '關閉',
add: '添加',
cancel: '取消',
close: '關閉',
Expand Down
6 changes: 4 additions & 2 deletions web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,12 +296,14 @@ export const api = {
// Provider management
listProviders: () =>
request<ProviderDetail[]>('/api/providers'),
addProvider: (data: { id: string; api_key: string; name?: string; model?: string; model_reasoning?: boolean; vision?: boolean; thinking?: boolean; reasoning_effort?: string } & ProviderAdvanced) =>
// vision is deliberately absent: image support is model metadata, and the
// backend treats an omitted field as "clear the stored override".
addProvider: (data: { id: string; api_key: string; name?: string; model?: string; model_reasoning?: boolean; thinking?: boolean; reasoning_effort?: string } & ProviderAdvanced) =>
request<{ status: string }>('/api/providers', {
method: 'POST',
body: JSON.stringify(data),
}),
updateProvider: (id: string, data: { api_key?: string; name?: string; custom_models?: CustomModelDetail[]; vision?: boolean; thinking?: boolean; reasoning_effort?: string } & ProviderAdvanced) =>
updateProvider: (id: string, data: { api_key?: string; name?: string; custom_models?: CustomModelDetail[]; thinking?: boolean; reasoning_effort?: string } & ProviderAdvanced) =>
request<{ status: string }>(`/api/providers/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(data),
Expand Down
Loading