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 > Advanced > 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 > Advanced > 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