Restore configurable Vision Model call limits

Expose timeout and max-token controls only in the visible Vision sidecar advanced settings, defaulting to 300 seconds and 2000 tokens, and pass them through the model builder. Point prompt customization to Agent Editor and promote legacy kwargs into the dedicated preset fields.

GreifMax committed Aug 26, 2026 at 00:00 UTC 906eee4da715768dc56d02a6a7772c878c0d6bb4
6 files changed +188 -11
plugins/_model_config/AGENTS.md
+1
@@ -26,6 +26,7 @@
26 - The optional `vision` slot is strictly per preset and never inherited from `Default`; an empty slot disables the separate Vision Model for that preset.
27 - Main native vision wins by default. A configured Vision Model handles `vision_load` when Main lacks vision, or when that preset explicitly enables `override_main`.
28 - Keep the optional Vision provider/model selector inside the Main Model card and flush with Main's field alignment, without a nested left inset. Show it only while Main vision is disabled or `override_main` is enabled; do not render a standalone Vision Model card.
29 +- Keep Vision timeout and maximum-output-token controls, plus the Agent Editor prompt-customization note, inside the visible Vision sidecar's Advanced Settings only. The Vision call limits belong to the preset/model builder, not to `vision_load` call-site constants.
30 - Show `Use separate Vision Model` immediately below `Supports Vision` while Main vision is enabled, not inside Advanced Settings; describe the disabled state as using Main's native vision.
31 - In model overviews, render the effective Vision Model as a text-only `Vision override / Provider / Model` child aligned with Main's provider column, not as an icon-bearing peer row.
32 - Changing a model provider in the settings UI must clear `api_base` and `kwargs` because both may be provider-specific.
plugins/_model_config/helpers/model_config.py
+15 -1
@@ -11,6 +11,8 @@ PRESETS_FILE = "presets.yaml"
11 FALLBACK_PRESETS_FILE = "mode_presets_fallback.yaml"
12 PROVIDER_METADATA_FILE = "provider_metadata.yaml"
13 DEFAULT_PRESET_NAME = "Default"
14 +DEFAULT_VISION_TIMEOUT_SECONDS = 300
15 +DEFAULT_VISION_MAX_TOKENS = 2000
16 MODEL_PRESET_CONFIG_KEY = "model_preset"
17 PRESET_SCOPE_GLOBAL = "global"
18 PRESET_SCOPE_PROJECT = "project"
@@ -25,6 +27,8 @@ IMPLICIT_PRESET_SLOT_DEFAULTS = {
27 "vision": {
28 "vision": True,
29 "max_embeds": 10,
30 + "timeout": DEFAULT_VISION_TIMEOUT_SECONDS,
31 + "max_tokens": DEFAULT_VISION_MAX_TOKENS,
32 "override_main": False,
33 "rl_requests": 0,
34 "rl_input": 0,
@@ -873,8 +877,18 @@ def build_vision_model(agent=None):
877 cfg = get_vision_model_config(agent)
878 mc = build_model_config(cfg, models.ModelType.CHAT)
879 mc.vision = True
880 + kwargs = mc.build_kwargs()
881 + for key, default in (
882 + ("timeout", DEFAULT_VISION_TIMEOUT_SECONDS),
883 + ("max_tokens", DEFAULT_VISION_MAX_TOKENS),
884 + ):
885 + value = cfg.get(key)
886 + if value not in (None, ""):
887 + kwargs[key] = _normalize_kwargs({key: value})[key]
888 + else:
889 + kwargs.setdefault(key, default)
890 return models.get_chat_model(
877 - mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
891 + mc.provider, mc.name, model_config=mc, **kwargs
892 )
893
894
plugins/_model_config/webui/model-config-store.js
+22 -3
@@ -63,6 +63,8 @@ const IMPLICIT_PRESET_SLOT_DEFAULTS = {
63 vision: {
64 vision: true,
65 max_embeds: 10,
66 + timeout: 300,
67 + max_tokens: 2000,
68 override_main: false,
69 rl_requests: 0,
70 rl_input: 0,
@@ -222,7 +224,23 @@ export const store = createStore("modelConfig", {
224 const source = (rawPresets || []).filter(p => p && typeof p === 'object');
225 const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {};
226 const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) });
225 - const visionSlot = value => ({ ...slot(value), vision: true, max_embeds: Number(value?.max_embeds ?? 10), override_main: !!value?.override_main });
227 + const visionSlot = value => {
228 + const normalized = slot(value);
229 + const kwargs = { ...(normalized.kwargs || {}) };
230 + const timeout = Number(value?.timeout ?? kwargs.timeout ?? 300);
231 + const maxTokens = Number(value?.max_tokens ?? kwargs.max_tokens ?? 2000);
232 + delete kwargs.timeout;
233 + delete kwargs.max_tokens;
234 + return {
235 + ...normalized,
236 + vision: true,
237 + max_embeds: Number(value?.max_embeds ?? 10),
238 + timeout,
239 + max_tokens: maxTokens,
240 + override_main: !!value?.override_main,
241 + kwargs,
242 + };
243 + };
244 const defaultConfig = {
245 chat_model: slot(rawDefault.chat),
246 vision_model: hasModelIdentity(rawDefault.vision) ? visionSlot(rawDefault.vision) : {},
@@ -234,10 +252,11 @@ export const store = createStore("modelConfig", {
252 const effective = String(p.name || '').toLowerCase() === 'default'
253 ? defaultConfig
254 : configFromPreset(p, defaultConfig, true);
255 + const vision = visionSlot(effective.vision_model);
256 return {
257 name: p.name || '',
258 chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) },
240 - vision: { ...visionSlot(effective.vision_model), _kwargs_text: kwargsToText(effective.vision_model?.kwargs) },
259 + vision: { ...vision, _kwargs_text: kwargsToText(vision.kwargs) },
260 utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) },
261 embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) },
262 };
@@ -360,7 +379,7 @@ export const store = createStore("modelConfig", {
379 || this.presets[0]
380 || {
381 chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
363 - vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, override_main: false, kwargs: {}, _kwargs_text: '' },
382 + vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, timeout: 300, max_tokens: 2000, override_main: false, kwargs: {}, _kwargs_text: '' },
383 utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
384 embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
385 }
plugins/_model_config/webui/model-field.html
+31 -7
@@ -9,7 +9,7 @@
9 <!--
10 Reusable model configuration field set.
11 Parent x-data scope must provide:
12 - model — reactive object with provider, name, api_key, api_base, ctx_length, ctx_history, ctx_input, vision, max_embeds, rl_requests, rl_input, rl_output, kwargs, _kwargs_text
12 + model — reactive object with provider, name, api_key, api_base, ctx_length, ctx_history, ctx_input, vision, max_embeds, timeout, max_tokens, rl_requests, rl_input, rl_output, kwargs, _kwargs_text
13 modelType — 'chat' | 'vision' | 'utility' | 'embedding'
14 providers — array of { value, label }
15 searchType — 'chat' | 'embedding'
@@ -224,14 +224,38 @@
224 </template>
225
226 <template x-if="modelType === 'vision'">
227 - <div class="field">
228 - <div class="field-label">
229 - <div class="field-title">Max embeds</div>
230 - <div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
227 + <div>
228 + <div class="field">
229 + <div class="field-label">
230 + <div class="field-title">Max embeds</div>
231 + <div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
232 + </div>
233 + <div class="field-control">
234 + <input type="number" min="0" x-model.number="model.max_embeds" />
235 + </div>
236 </div>
232 - <div class="field-control">
233 - <input type="number" min="0" x-model.number="model.max_embeds" />
237 +
238 + <div class="field">
239 + <div class="field-label">
240 + <div class="field-title">Timeout (seconds)</div>
241 + <div class="field-description">How long to wait for the Vision Model.</div>
242 + </div>
243 + <div class="field-control">
244 + <input type="number" min="1" x-model.number="model.timeout" />
245 + </div>
246 </div>
247 +
248 + <div class="field">
249 + <div class="field-label">
250 + <div class="field-title">Max tokens</div>
251 + <div class="field-description">Maximum output tokens for each delegated vision call.</div>
252 + </div>
253 + <div class="field-control">
254 + <input type="number" min="1" x-model.number="model.max_tokens" />
255 + </div>
256 + </div>
257 +
258 + <p class="field-description">To customize the vision prompt, use Agent Editor &gt; Advanced &gt; Prompt files and edit <code>fw.vision_load.md</code>.</p>
259 </div>
260 </template>
261
tests/test_model_config_project_presets.py
+98
@@ -1130,6 +1130,104 @@ def test_preset_vision_slot_is_optional_and_never_inherited(monkeypatch, tmp_pat
1130 assert "default-vision" not in str(with_sidecar["vision_model"])
1131
1132
1133 +def test_vision_call_limits_are_preset_owned_and_default_clean(monkeypatch, tmp_path):
1134 + _prepare_a0_tree(monkeypatch, tmp_path)
1135 +
1136 + from plugins._model_config.helpers import model_config
1137 +
1138 + cleaned = model_config.clean_presets_for_file(
1139 + [
1140 + {
1141 + "name": "Default limits",
1142 + "vision": {
1143 + "provider": "openrouter",
1144 + "name": "vision-default",
1145 + "timeout": 300,
1146 + "max_tokens": 2000,
1147 + },
1148 + },
1149 + {
1150 + "name": "Tuned limits",
1151 + "vision": {
1152 + "provider": "openrouter",
1153 + "name": "vision-tuned",
1154 + "timeout": 45,
1155 + "max_tokens": 512,
1156 + },
1157 + },
1158 + ]
1159 + )
1160 +
1161 + assert cleaned[0]["vision"] == {
1162 + "provider": "openrouter",
1163 + "name": "vision-default",
1164 + }
1165 + assert cleaned[1]["vision"]["timeout"] == 45
1166 + assert cleaned[1]["vision"]["max_tokens"] == 512
1167 +
1168 +
1169 +def test_vision_model_build_applies_only_its_preset_call_limits(monkeypatch):
1170 + from plugins._model_config.helpers import model_config
1171 +
1172 + calls = []
1173 +
1174 + def fake_get_chat_model(provider, name, **kwargs):
1175 + calls.append((provider, name, kwargs))
1176 + return kwargs
1177 +
1178 + monkeypatch.setattr(model_config.models, "get_chat_model", fake_get_chat_model)
1179 +
1180 + cases = [
1181 + (
1182 + {"provider": "openrouter", "name": "vision-default"},
1183 + {"timeout": 300, "max_tokens": 2000},
1184 + ),
1185 + (
1186 + {
1187 + "provider": "openrouter",
1188 + "name": "vision-legacy-kwargs",
1189 + "kwargs": {"timeout": 90, "max_tokens": 1024},
1190 + },
1191 + {"timeout": 90, "max_tokens": 1024},
1192 + ),
1193 + (
1194 + {
1195 + "provider": "openrouter",
1196 + "name": "vision-tuned",
1197 + "timeout": "45",
1198 + "max_tokens": "512",
1199 + "kwargs": {
1200 + "timeout": 90,
1201 + "max_tokens": 1024,
1202 + "temperature": 0.1,
1203 + },
1204 + },
1205 + {"timeout": 45, "max_tokens": 512},
1206 + ),
1207 + ]
1208 +
1209 + for config, expected in cases:
1210 + monkeypatch.setattr(
1211 + model_config,
1212 + "get_vision_model_config",
1213 + lambda _agent=None, config=config: config,
1214 + )
1215 + built = model_config.build_vision_model()
1216 + assert built["timeout"] == expected["timeout"]
1217 + assert built["max_tokens"] == expected["max_tokens"]
1218 +
1219 + assert calls[-1][2]["temperature"] == 0.1
1220 +
1221 + monkeypatch.setattr(
1222 + model_config,
1223 + "get_chat_model_config",
1224 + lambda _agent=None: {"provider": "openrouter", "name": "main"},
1225 + )
1226 + main = model_config.build_chat_model()
1227 + assert "timeout" not in main
1228 + assert "max_tokens" not in main
1229 +
1230 +
1231 def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
1232 monkeypatch,
1233 tmp_path,
tests/test_model_config_ui.py
+21
@@ -98,6 +98,9 @@ def test_preset_editor_nests_one_conditional_vision_selector_in_main() -> None:
98 override_start = model_field.index('<div class="field-title">Use separate Vision Model</div>')
99 context_start = model_field.index('<div class="field-title">Context window size</div>')
100 advanced_start = model_field.index('<!-- Advanced Settings (collapsed by default) -->')
101 + vision_advanced_start = model_field.index('<template x-if="modelType === \'vision\'">')
102 + vision_advanced_end = model_field.index('<!-- Utility-specific: ctx_input slider -->')
103 + vision_advanced = model_field[vision_advanced_start:vision_advanced_end]
104
105 assert '<div class="section-title">Vision Model</div>' not in preset_modal
106 assert preset_modal.count('class="vision-sidecar-selector"') == 1
@@ -115,6 +118,24 @@ def test_preset_editor_nests_one_conditional_vision_selector_in_main() -> None:
118 assert "When enabled, vision_load uses the preset's Vision Model" not in model_field
119 assert 'x-model="visionModel.override_main"' in model_field
120 assert '<div class="advanced-section" x-data="{ advOpen: false }">' in model_field
121 + assert advanced_start < vision_advanced_start
122 + assert model_field.count('<div class="field-title">Timeout (seconds)</div>') == 1
123 + assert model_field.count('<div class="field-title">Max tokens</div>') == 1
124 + assert 'x-model.number="model.timeout"' in vision_advanced
125 + assert 'x-model.number="model.max_tokens"' in vision_advanced
126 + assert "How long to wait for the Vision Model." in vision_advanced
127 + assert "Maximum output tokens for each delegated vision call." in vision_advanced
128 + assert "Agent Editor &gt; Advanced &gt; Prompt files" in vision_advanced
129 + assert "fw.vision_load.md" in vision_advanced
130 + assert "delegated_system" not in model_field
131 + assert "vision prompt" not in model_field[:vision_advanced_start].lower()
132 + assert "timeout: 300" in preset_store
133 + assert "max_tokens: 2000" in preset_store
134 + assert "value?.timeout ?? kwargs.timeout ?? 300" in preset_store
135 + assert "value?.max_tokens ?? kwargs.max_tokens ?? 2000" in preset_store
136 + assert "delete kwargs.timeout;" in preset_store
137 + assert "delete kwargs.max_tokens;" in preset_store
138 + assert "_kwargs_text: kwargsToText(vision.kwargs)" in preset_store
139 assert "model-preset-row-nested" in preset_overview
140 assert "model.title === 'Vision'" in preset_overview
141 assert preset_overview.count("model.title !== 'Vision'") == 2