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.
This commit is contained in:
GreifMax 2026-08-26 00:00:54 +02:00 committed by Alessandro
parent 519f2c2a62
commit 906eee4da7
6 changed files with 188 additions and 11 deletions

View file

@ -26,6 +26,7 @@
- The optional `vision` slot is strictly per preset and never inherited from `Default`; an empty slot disables the separate Vision Model for that preset.
- 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`.
- 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.
- 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.
- 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.
- 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.
- Changing a model provider in the settings UI must clear `api_base` and `kwargs` because both may be provider-specific.

View file

@ -11,6 +11,8 @@ PRESETS_FILE = "presets.yaml"
FALLBACK_PRESETS_FILE = "mode_presets_fallback.yaml"
PROVIDER_METADATA_FILE = "provider_metadata.yaml"
DEFAULT_PRESET_NAME = "Default"
DEFAULT_VISION_TIMEOUT_SECONDS = 300
DEFAULT_VISION_MAX_TOKENS = 2000
MODEL_PRESET_CONFIG_KEY = "model_preset"
PRESET_SCOPE_GLOBAL = "global"
PRESET_SCOPE_PROJECT = "project"
@ -25,6 +27,8 @@ IMPLICIT_PRESET_SLOT_DEFAULTS = {
"vision": {
"vision": True,
"max_embeds": 10,
"timeout": DEFAULT_VISION_TIMEOUT_SECONDS,
"max_tokens": DEFAULT_VISION_MAX_TOKENS,
"override_main": False,
"rl_requests": 0,
"rl_input": 0,
@ -873,8 +877,18 @@ def build_vision_model(agent=None):
cfg = get_vision_model_config(agent)
mc = build_model_config(cfg, models.ModelType.CHAT)
mc.vision = True
kwargs = mc.build_kwargs()
for key, default in (
("timeout", DEFAULT_VISION_TIMEOUT_SECONDS),
("max_tokens", DEFAULT_VISION_MAX_TOKENS),
):
value = cfg.get(key)
if value not in (None, ""):
kwargs[key] = _normalize_kwargs({key: value})[key]
else:
kwargs.setdefault(key, default)
return models.get_chat_model(
mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
mc.provider, mc.name, model_config=mc, **kwargs
)

View file

@ -63,6 +63,8 @@ const IMPLICIT_PRESET_SLOT_DEFAULTS = {
vision: {
vision: true,
max_embeds: 10,
timeout: 300,
max_tokens: 2000,
override_main: false,
rl_requests: 0,
rl_input: 0,
@ -222,7 +224,23 @@ export const store = createStore("modelConfig", {
const source = (rawPresets || []).filter(p => p && typeof p === 'object');
const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {};
const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) });
const visionSlot = value => ({ ...slot(value), vision: true, max_embeds: Number(value?.max_embeds ?? 10), override_main: !!value?.override_main });
const visionSlot = value => {
const normalized = slot(value);
const kwargs = { ...(normalized.kwargs || {}) };
const timeout = Number(value?.timeout ?? kwargs.timeout ?? 300);
const maxTokens = Number(value?.max_tokens ?? kwargs.max_tokens ?? 2000);
delete kwargs.timeout;
delete kwargs.max_tokens;
return {
...normalized,
vision: true,
max_embeds: Number(value?.max_embeds ?? 10),
timeout,
max_tokens: maxTokens,
override_main: !!value?.override_main,
kwargs,
};
};
const defaultConfig = {
chat_model: slot(rawDefault.chat),
vision_model: hasModelIdentity(rawDefault.vision) ? visionSlot(rawDefault.vision) : {},
@ -234,10 +252,11 @@ export const store = createStore("modelConfig", {
const effective = String(p.name || '').toLowerCase() === 'default'
? defaultConfig
: configFromPreset(p, defaultConfig, true);
const vision = visionSlot(effective.vision_model);
return {
name: p.name || '',
chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) },
vision: { ...visionSlot(effective.vision_model), _kwargs_text: kwargsToText(effective.vision_model?.kwargs) },
vision: { ...vision, _kwargs_text: kwargsToText(vision.kwargs) },
utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) },
embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) },
};
@ -360,7 +379,7 @@ export const store = createStore("modelConfig", {
|| this.presets[0]
|| {
chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, override_main: false, kwargs: {}, _kwargs_text: '' },
vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, timeout: 300, max_tokens: 2000, override_main: false, kwargs: {}, _kwargs_text: '' },
utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
}

View file

@ -9,7 +9,7 @@
<!--
Reusable model configuration field set.
Parent x-data scope must provide:
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
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
modelType — 'chat' | 'vision' | 'utility' | 'embedding'
providers — array of { value, label }
searchType — 'chat' | 'embedding'
@ -224,14 +224,38 @@
</template>
<template x-if="modelType === 'vision'">
<div class="field">
<div class="field-label">
<div class="field-title">Max embeds</div>
<div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
<div>
<div class="field">
<div class="field-label">
<div class="field-title">Max embeds</div>
<div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
</div>
<div class="field-control">
<input type="number" min="0" x-model.number="model.max_embeds" />
</div>
</div>
<div class="field-control">
<input type="number" min="0" x-model.number="model.max_embeds" />
<div class="field">
<div class="field-label">
<div class="field-title">Timeout (seconds)</div>
<div class="field-description">How long to wait for the Vision Model.</div>
</div>
<div class="field-control">
<input type="number" min="1" x-model.number="model.timeout" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Max tokens</div>
<div class="field-description">Maximum output tokens for each delegated vision call.</div>
</div>
<div class="field-control">
<input type="number" min="1" x-model.number="model.max_tokens" />
</div>
</div>
<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>
</div>
</template>

View file

@ -1130,6 +1130,104 @@ def test_preset_vision_slot_is_optional_and_never_inherited(monkeypatch, tmp_pat
assert "default-vision" not in str(with_sidecar["vision_model"])
def test_vision_call_limits_are_preset_owned_and_default_clean(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
cleaned = model_config.clean_presets_for_file(
[
{
"name": "Default limits",
"vision": {
"provider": "openrouter",
"name": "vision-default",
"timeout": 300,
"max_tokens": 2000,
},
},
{
"name": "Tuned limits",
"vision": {
"provider": "openrouter",
"name": "vision-tuned",
"timeout": 45,
"max_tokens": 512,
},
},
]
)
assert cleaned[0]["vision"] == {
"provider": "openrouter",
"name": "vision-default",
}
assert cleaned[1]["vision"]["timeout"] == 45
assert cleaned[1]["vision"]["max_tokens"] == 512
def test_vision_model_build_applies_only_its_preset_call_limits(monkeypatch):
from plugins._model_config.helpers import model_config
calls = []
def fake_get_chat_model(provider, name, **kwargs):
calls.append((provider, name, kwargs))
return kwargs
monkeypatch.setattr(model_config.models, "get_chat_model", fake_get_chat_model)
cases = [
(
{"provider": "openrouter", "name": "vision-default"},
{"timeout": 300, "max_tokens": 2000},
),
(
{
"provider": "openrouter",
"name": "vision-legacy-kwargs",
"kwargs": {"timeout": 90, "max_tokens": 1024},
},
{"timeout": 90, "max_tokens": 1024},
),
(
{
"provider": "openrouter",
"name": "vision-tuned",
"timeout": "45",
"max_tokens": "512",
"kwargs": {
"timeout": 90,
"max_tokens": 1024,
"temperature": 0.1,
},
},
{"timeout": 45, "max_tokens": 512},
),
]
for config, expected in cases:
monkeypatch.setattr(
model_config,
"get_vision_model_config",
lambda _agent=None, config=config: config,
)
built = model_config.build_vision_model()
assert built["timeout"] == expected["timeout"]
assert built["max_tokens"] == expected["max_tokens"]
assert calls[-1][2]["temperature"] == 0.1
monkeypatch.setattr(
model_config,
"get_chat_model_config",
lambda _agent=None: {"provider": "openrouter", "name": "main"},
)
main = model_config.build_chat_model()
assert "timeout" not in main
assert "max_tokens" not in main
def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
monkeypatch,
tmp_path,

View file

@ -98,6 +98,9 @@ def test_preset_editor_nests_one_conditional_vision_selector_in_main() -> None:
override_start = model_field.index('<div class="field-title">Use separate Vision Model</div>')
context_start = model_field.index('<div class="field-title">Context window size</div>')
advanced_start = model_field.index('<!-- Advanced Settings (collapsed by default) -->')
vision_advanced_start = model_field.index('<template x-if="modelType === \'vision\'">')
vision_advanced_end = model_field.index('<!-- Utility-specific: ctx_input slider -->')
vision_advanced = model_field[vision_advanced_start:vision_advanced_end]
assert '<div class="section-title">Vision Model</div>' not in preset_modal
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:
assert "When enabled, vision_load uses the preset's Vision Model" not in model_field
assert 'x-model="visionModel.override_main"' in model_field
assert '<div class="advanced-section" x-data="{ advOpen: false }">' in model_field
assert advanced_start < vision_advanced_start
assert model_field.count('<div class="field-title">Timeout (seconds)</div>') == 1
assert model_field.count('<div class="field-title">Max tokens</div>') == 1
assert 'x-model.number="model.timeout"' in vision_advanced
assert 'x-model.number="model.max_tokens"' in vision_advanced
assert "How long to wait for the Vision Model." in vision_advanced
assert "Maximum output tokens for each delegated vision call." in vision_advanced
assert "Agent Editor &gt; Advanced &gt; Prompt files" in vision_advanced
assert "fw.vision_load.md" in vision_advanced
assert "delegated_system" not in model_field
assert "vision prompt" not in model_field[:vision_advanced_start].lower()
assert "timeout: 300" in preset_store
assert "max_tokens: 2000" in preset_store
assert "value?.timeout ?? kwargs.timeout ?? 300" in preset_store
assert "value?.max_tokens ?? kwargs.max_tokens ?? 2000" in preset_store
assert "delete kwargs.timeout;" in preset_store
assert "delete kwargs.max_tokens;" in preset_store
assert "_kwargs_text: kwargsToText(vision.kwargs)" in preset_store
assert "model-preset-row-nested" in preset_overview
assert "model.title === 'Vision'" in preset_overview
assert preset_overview.count("model.title !== 'Vision'") == 2