feat(llm): resolve per-model context windows instead of the global 128k default

This commit is contained in:
matevip 2026-08-02 23:39:31 -04:00
parent bfd84fd5c3
commit 5ba92f3b87
26 changed files with 714 additions and 42 deletions

View File

@ -176,6 +176,15 @@ public class ModelConfigController {
return R.ok(modelProviderService.removeModel(providerId, modelId));
}
@Operation(summary = "设置模型上下文窗口")
@PutMapping("/{providerId}/models/context-window")
@RequireGlobalAdmin
public R<ProviderInfoDTO> updateModelContextWindow(@PathVariable String providerId,
@RequestBody UpdateModelContextWindowRequest request) {
return R.ok(modelProviderService.updateModelContextWindow(
providerId, request.getModelId(), request.getMaxInputTokens()));
}
@Operation(summary = "获取模型详情")
@GetMapping("/{id}")
@RequireGlobalAdmin

View File

@ -41,6 +41,27 @@ public class ModelInfoDTO {
*/
private boolean supportsThinking;
/**
* Explicit per-model input window from {@code mate_model_config}; null when
* the operator has not set one (stored as 0). This is what the management
* UI's input binds to an empty field means "let the server decide".
*/
private Integer maxInputTokens;
/**
* The window context budgeting would use if a turn ran right now, computed
* without any probe traffic: explicit config, else the built-in window
* table, else the global default. Display only.
*/
private Integer effectiveMaxInputTokens;
/**
* Where {@link #effectiveMaxInputTokens} came from {@code configured},
* {@code catalog} or {@code default} so the UI can say why a number is
* what it is instead of presenting a guess as configuration.
*/
private String maxInputTokensSource;
public ModelInfoDTO(String id, String name) {
this.id = id;
this.name = name;

View File

@ -0,0 +1,19 @@
package vip.mate.llm.model;
import lombok.Data;
/**
* Body of {@code PUT /api/v1/models/{providerId}/models/context-window}.
*/
@Data
public class UpdateModelContextWindowRequest {
/** Model identifier within the provider, i.e. {@code mate_model_config.model_name}. */
private String modelId;
/**
* Input window in tokens. Null or non-positive clears the override and
* hands budgeting back to the built-in window table / global default.
*/
private Integer maxInputTokens;
}

View File

@ -10,7 +10,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "mateclaw.context.probe")
public class ContextProbeProperties {
/** Master switch. When false, {@code resolveMaxInputTokens} only honors explicit config. */
/**
* Master switch for probe traffic and error-text reconciliation. When
* false, {@code resolveMaxInputTokens} honors explicit config and the
* built-in window table only no request ever leaves the process.
*/
private boolean enabled = true;
/** Per-request read timeout. Probing must never hold up chat startup. */

View File

@ -0,0 +1,119 @@
package vip.mate.llm.probe;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Built-in context-window table for hosted models, keyed by lowercase
* model-name prefix; longest match wins.
*
* <p>Why this exists: {@code mate_model_config.max_input_tokens} ships as 0 for
* every catalog row and cloud endpoints are deliberately never probed, so
* without this table every hosted model a 1M-window one included budgets
* and reports against the 128k global default. That both compacts history far
* too early on large-window models and hides the real window in the chat
* context-usage chip.
*
* <p>Values are <b>input</b> windows in tokens (not max output). Entries are
* limited to models whose window is documented by the vendor or by this
* repository's own model catalog; a family that is not listed simply falls
* through to the caller's global default, which is the pre-existing behavior.
* Per-model {@code maxInputTokens} in the database always overrides this table,
* so an operator can correct any entry without a code change.
*
* <p>Names carrying a vendor segment ({@code google/gemini-2.5-pro},
* {@code Pro/deepseek-ai/DeepSeek-V3}) are retried against the segment after
* the last slash, so aggregator providers reuse the same entries.
*/
final class ModelContextWindowCatalog {
private static final Map<String, Integer> WINDOWS;
static {
Map<String, Integer> m = new LinkedHashMap<>();
// ===== DeepSeek =====
// V4 ships a 1M window; the V3 line and the chat/reasoner aliases are 128k.
m.put("deepseek-v4", 1_000_000);
m.put("deepseek-v3", 128_000);
m.put("deepseek-r1", 128_000);
m.put("deepseek-chat", 128_000);
m.put("deepseek-reasoner", 128_000);
// ===== Anthropic Claude =====
// 200k across the line; the 1M variants are opt-in per request, so the
// conservative default is the one that always holds.
m.put("claude-", 200_000);
// ===== Google Gemini =====
m.put("gemini-2.0", 1_048_576);
m.put("gemini-2.5", 1_048_576);
m.put("gemini-3", 1_048_576);
// ===== OpenAI =====
// gpt-5's 400k total budget splits into 272k input + 128k output.
m.put("gpt-5", 272_000);
m.put("gpt-4.1", 1_047_576);
m.put("gpt-4o", 128_000);
m.put("o3", 200_000);
m.put("o4-mini", 200_000);
// ===== Alibaba Qwen =====
m.put("qwen3-max", 262_144);
m.put("qwen-long", 10_000_000);
// ===== Moonshot Kimi =====
m.put("kimi-k2", 262_144);
// ===== Zhipu GLM =====
m.put("glm-4.7", 204_800);
m.put("glm-4-7", 204_800);
m.put("glm-5.2", 1_000_000);
// ===== Volcengine Doubao / Ark =====
m.put("doubao-seed-1-8", 262_144);
m.put("doubao-seed-code", 262_144);
m.put("ark-code-latest", 262_144);
// ===== xAI Grok =====
m.put("grok-3", 131_072);
m.put("grok-4", 256_000);
// ===== Meta Llama =====
m.put("llama-4-maverick", 1_048_576);
WINDOWS = Map.copyOf(m);
}
private ModelContextWindowCatalog() {
}
/**
* @return the known input window for {@code modelName}, or {@code null}
* when the model is not in the table
*/
static Integer lookup(String modelName) {
if (modelName == null || modelName.isBlank()) {
return null;
}
String lowered = modelName.trim().toLowerCase();
Integer direct = matchPrefix(lowered);
if (direct != null) {
return direct;
}
int slash = lowered.lastIndexOf('/');
if (slash >= 0 && slash + 1 < lowered.length()) {
return matchPrefix(lowered.substring(slash + 1));
}
return null;
}
private static Integer matchPrefix(String loweredName) {
return WINDOWS.entrySet().stream()
.filter(e -> loweredName.startsWith(e.getKey()))
.max(Comparator.comparingInt(e -> e.getKey().length()))
.map(Map.Entry::getValue)
.orElse(null);
}
}

View File

@ -22,7 +22,10 @@ import java.util.concurrent.ConcurrentHashMap;
* <li>explicit {@code ModelConfigEntity.maxInputTokens} user configuration
* always wins;</li>
* <li>a probed value from a {@link LocalContextProbe} (runtime-cached with a
* short TTL, never persisted local servers hot-swap models);</li>
* short TTL, never persisted local servers hot-swap models), or a limit
* previously parsed out of a provider error;</li>
* <li>{@link ModelContextWindowCatalog} the built-in window table for
* hosted models, which are never probed;</li>
* <li>{@code null} caller falls back to the global default, exactly the
* pre-probe behavior.</li>
* </ol>
@ -48,9 +51,9 @@ public class ModelContextWindowResolver {
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
/**
* @return the effective max input tokens, or {@code null} when neither
* explicit config nor probing yields a value (caller keeps its
* existing global-default fallback).
* @return the effective max input tokens, or {@code null} when explicit
* config, probing and the built-in catalog all come up empty
* (caller keeps its existing global-default fallback).
*/
public Integer resolveMaxInputTokens(ModelProviderEntity provider, ModelConfigEntity model) {
if (model == null) {
@ -59,36 +62,77 @@ public class ModelContextWindowResolver {
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
return model.getMaxInputTokens();
}
if (!properties.isEnabled()) {
return null;
}
String key = cacheKey(provider != null ? provider.getProviderId() : null, model.getModelName());
CacheEntry cached = cache.get(key);
long now = System.currentTimeMillis();
if (cached != null && cached.expiresAtMs() > now) {
return cached.value();
}
Integer probed = null;
for (LocalContextProbe probe : probes) {
try {
if (!probe.supports(provider, model)) {
continue;
if (properties.isEnabled()) {
long now = System.currentTimeMillis();
CacheEntry cached = cache.get(key);
if (cached != null && cached.expiresAtMs() > now) {
// A cached value outranks the catalog: it came from the live
// endpoint or from the provider's own over-limit rejection.
if (cached.value() != null) {
return cached.value();
}
probed = probe.probeContextLength(provider, model).orElse(null);
} else {
Integer probed = null;
for (LocalContextProbe probe : probes) {
try {
if (!probe.supports(provider, model)) {
continue;
}
probed = probe.probeContextLength(provider, model).orElse(null);
if (probed != null) {
break;
}
} catch (Exception e) {
log.debug("[ContextProbe] probe {} threw for {}: {}",
probe.getClass().getSimpleName(), key, e.getMessage());
}
}
cache.put(key, new CacheEntry(probed, now + ttlMs()));
if (probed != null) {
break;
log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)",
key, probed);
return probed;
}
} catch (Exception e) {
log.debug("[ContextProbe] probe {} threw for {}: {}",
probe.getClass().getSimpleName(), key, e.getMessage());
}
}
cache.put(key, new CacheEntry(probed, now + ttlMs()));
if (probed != null) {
log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)",
key, probed);
Integer known = catalogWindow(provider, model);
if (known != null) {
log.debug("[ContextProbe] 模型 {} 未配置 maxInputTokens,按内置窗口表使用 {} tokens", key, known);
}
return probed;
return known;
}
/**
* Same priority as {@link #resolveMaxInputTokens} minus probing, so it
* performs no I/O and is safe to call while rendering a model list.
*
* @return the window this model would budget against, or {@code null} when
* only the caller's global default applies
*/
public Integer resolveWithoutProbing(ModelProviderEntity provider, ModelConfigEntity model) {
if (model == null) {
return null;
}
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
return model.getMaxInputTokens();
}
return catalogWindow(provider, model);
}
/**
* Built-in table lookup, excluding self-hosted endpoints: their real window
* is whatever the server was started with (num_ctx / max_model_len), which
* a vendor table cannot know a wrong guess there is worse than the
* default.
*/
private Integer catalogWindow(ModelProviderEntity provider, ModelConfigEntity model) {
if (provider != null
&& ("ollama".equalsIgnoreCase(provider.getProviderId())
|| LocalEndpoints.isLocal(provider.getBaseUrl()))) {
return null;
}
return ModelContextWindowCatalog.lookup(model.getModelName());
}
/**

View File

@ -22,6 +22,11 @@ import org.springframework.context.ApplicationEventPublisher;
@RequiredArgsConstructor
public class ModelConfigService {
/** Below this a "window" is a typo, not a model — even 4k-era models exceed it. */
private static final int MIN_CONTEXT_WINDOW = 1024;
/** Above this the value is a typo too; the largest published windows are ~10M. */
private static final int MAX_CONTEXT_WINDOW = 20_000_000;
private final ModelConfigMapper modelConfigMapper;
private final ApplicationEventPublisher eventPublisher;
private final ModelCapabilityService modelCapabilityService;
@ -297,6 +302,33 @@ public class ModelConfigService {
return entity;
}
/**
* Persist an explicit input-context window for one model. {@code null} or a
* non-positive value clears the override (stored as 0), handing budgeting
* back to the built-in window table / global default.
*
* @throws MateClawException when the model is unknown or the value is
* outside the range a real model could have
*/
public ModelConfigEntity updateModelContextWindow(String providerId, String modelId, Integer maxInputTokens) {
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getProvider, providerId)
.eq(ModelConfigEntity::getModelName, modelId)
.last("LIMIT 1"));
if (entity == null) {
throw new MateClawException("err.llm.model_not_found", "模型不存在: " + modelId);
}
int value = (maxInputTokens == null || maxInputTokens <= 0) ? 0 : maxInputTokens;
if (value > 0 && (value < MIN_CONTEXT_WINDOW || value > MAX_CONTEXT_WINDOW)) {
throw new MateClawException("err.llm.context_window_out_of_range",
"上下文窗口需要在 " + MIN_CONTEXT_WINDOW + " ~ " + MAX_CONTEXT_WINDOW + " tokens 之间");
}
entity.setMaxInputTokens(value);
modelConfigMapper.updateById(entity);
publishConfigChanged("model-context-window-updated");
return entity;
}
public void removeModelFromProvider(String providerId, String modelId) {
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getProvider, providerId)

View File

@ -16,7 +16,9 @@ import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.failover.ProviderRequirements;
import vip.mate.llm.model.*;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import vip.mate.config.ConversationWindowProperties;
import org.springframework.ai.chat.model.ChatModel;
@ -60,6 +62,10 @@ public class ModelProviderService {
* defers Spring's wiring decision past construction.
*/
private final ObjectProvider<ProviderInitProbe> providerInitProbeProvider;
/** Supplies the window a model would budget against when none is configured. */
private final ModelContextWindowResolver contextWindowResolver;
/** Global fallback window, shown in the UI when nothing more specific applies. */
private final ConversationWindowProperties conversationWindowProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
@ -235,6 +241,41 @@ public class ModelProviderService {
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
}
/**
* Set (or clear, with a null / non-positive value) the per-model input
* window. Applies to built-in models too the shipped catalog cannot know
* every vendor's window, so operators need to correct it without editing
* the database by hand.
*/
public ProviderInfoDTO updateModelContextWindow(String providerId, String modelId, Integer maxInputTokens) {
getProvider(providerId);
modelConfigService.updateModelContextWindow(providerId, modelId, maxInputTokens);
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
}
/**
* Fill the three window fields the model-management UI reads. Uses the
* probe-free resolution path so listing providers never issues a request.
*/
private void applyContextWindow(ModelInfoDTO info, ModelProviderEntity provider, ModelConfigEntity model) {
Integer configured = (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0)
? model.getMaxInputTokens() : null;
info.setMaxInputTokens(configured);
if (configured != null) {
info.setEffectiveMaxInputTokens(configured);
info.setMaxInputTokensSource("configured");
return;
}
Integer resolved = contextWindowResolver.resolveWithoutProbing(provider, model);
if (resolved != null) {
info.setEffectiveMaxInputTokens(resolved);
info.setMaxInputTokensSource("catalog");
return;
}
info.setEffectiveMaxInputTokens(conversationWindowProperties.getDefaultMaxInputTokens());
info.setMaxInputTokensSource("default");
}
public ModelProviderEntity getProviderConfig(String providerId) {
return getProvider(providerId);
}
@ -455,6 +496,7 @@ public class ModelProviderService {
// RFC-049 PR-1-UI: ModelInfoDTO(id, name) derives supportsReasoningEffort
// from id via ModelFamily no extra wiring needed here.
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
applyContextWindow(info, provider, model);
if (Boolean.TRUE.equals(model.getBuiltin())) {
builtinModels.add(info);
} else {

View File

@ -3,10 +3,10 @@
-- from data-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators
-- already on V44.
--
-- Reference: openclaw extensions/deepseek/models.ts:28-81 — V4 supports
-- reasoning_effort + thinking control. NULL temperature/top_p marks the model
-- as thinking-managed (DeepSeekV4ThinkingDecorator handles the per-request
-- thinking field injection).
-- V4 supports reasoning_effort together with thinking control. NULL
-- temperature/top_p marks the model as thinking-managed
-- (DeepSeekV4ThinkingDecorator handles the per-request thinking field
-- injection).
MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, create_time, update_time, deleted)
KEY (id)

View File

@ -276,7 +276,9 @@ Every turn, MateClaw builds the prompt that actually goes to the LLM. Roughly:
4. **Recent turns** — as many as fit in the token budget
5. **Current user message** — always last
When the total exceeds `defaultMaxInputTokens × compactTriggerRatio` (default 128000 × 0.75 = 96000), the system calls the LLM to summarize earlier turns, caches the result for 30 minutes, and sends a compact version. If the LLM still returns a `context_length_exceeded` error, emergency trimming kicks in: discard older messages without calling the LLM, keep the last two turns.
The window comes from the model itself: the model config's `maxInputTokens` first, then a window probed from a local inference server, then the built-in table of known model windows (DeepSeek V4, Gemini, Claude, Kimi K2, …). Only when all three come up empty does it fall back to the global `defaultMaxInputTokens`.
When the total exceeds `window × compactTriggerRatio` (global default 128000 × 0.75 = 96000), the system calls the LLM to summarize earlier turns, caches the result for 30 minutes, and sends a compact version. If the LLM still returns a `context_length_exceeded` error, emergency trimming kicks in: discard older messages without calling the LLM, keep the last two turns.
More detail, plus the security rationale for injecting summaries as `UserMessage` rather than `SystemMessage`, is in [Memory](./memory).

View File

@ -276,7 +276,9 @@ Segment 的结构是渐进展示的底层。它也让**数据库成为单一事
4. **最近的若干轮**——尽可能装进 token 预算
5. **当前用户消息**——永远在最后
当总量超过 `defaultMaxInputTokens × compactTriggerRatio`(默认 128000 × 0.75 = 96000系统会让 LLM 把早期轮次总结一下,把结果缓存 30 分钟,送出去的是压缩版。如果 LLM 依然报 `context_length_exceeded`,会触发紧急截断:不调 LLM直接丢掉更早的消息保留最近两轮。
这里的窗口取自模型自身的上下文长度:优先用模型配置里的 `maxInputTokens`其次是本地推理服务探测到的窗口再次是内置的常见模型窗口表DeepSeek V4、Gemini、Claude、Kimi K2 等),都拿不到才回落全局默认 `defaultMaxInputTokens`
当总量超过 `窗口 × compactTriggerRatio`(全局默认 128000 × 0.75 = 96000系统会让 LLM 把早期轮次总结一下,把结果缓存 30 分钟,送出去的是压缩版。如果 LLM 依然报 `context_length_exceeded`,会触发紧急截断:不调 LLM直接丢掉更早的消息保留最近两轮。
更多细节,以及"为什么把摘要注入成 `UserMessage` 而不是 `SystemMessage`"的安全设计理由,在 [记忆系统](./memory) 里。

View File

@ -0,0 +1,41 @@
package vip.mate.llm.probe;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Unit tests for {@link ModelContextWindowCatalog} prefix matching, vendor
* segments, and the "unknown stays unknown" contract.
*/
class ModelContextWindowCatalogTest {
@Test
@DisplayName("longest prefix wins — v4 does not inherit the v3 window")
void longestPrefixWins() {
assertEquals(1_000_000, ModelContextWindowCatalog.lookup("deepseek-v4-flash"));
assertEquals(1_000_000, ModelContextWindowCatalog.lookup("deepseek-v4-pro"));
assertEquals(128_000, ModelContextWindowCatalog.lookup("deepseek-v3-2-251201"));
assertEquals(128_000, ModelContextWindowCatalog.lookup("deepseek-chat"));
}
@Test
@DisplayName("matching is case-insensitive and ignores the vendor segment")
void vendorSegmentIsStripped() {
assertEquals(200_000, ModelContextWindowCatalog.lookup("anthropic/claude-opus-4-8"));
assertEquals(1_048_576, ModelContextWindowCatalog.lookup("google/gemini-2.5-flash:free"));
assertEquals(128_000, ModelContextWindowCatalog.lookup("Pro/deepseek-ai/DeepSeek-V3"));
assertEquals(1_048_576, ModelContextWindowCatalog.lookup("meta-llama/llama-4-maverick"));
}
@Test
@DisplayName("models outside the table return null so the caller keeps its default")
void unknownModelsReturnNull() {
assertNull(ModelContextWindowCatalog.lookup("acme-llm-1"));
assertNull(ModelContextWindowCatalog.lookup("vendor/unknown-model"));
assertNull(ModelContextWindowCatalog.lookup(""));
assertNull(ModelContextWindowCatalog.lookup(null));
}
}

View File

@ -48,6 +48,12 @@ class ModelContextWindowResolverTest {
return provider;
}
private static ModelProviderEntity cloudProvider(String id) {
ModelProviderEntity provider = provider(id);
provider.setBaseUrl("https://api.deepseek.com");
return provider;
}
private static ModelConfigEntity model(String name, Integer maxInputTokens) {
ModelConfigEntity model = new ModelConfigEntity();
model.setModelName(name);
@ -132,4 +138,56 @@ class ModelContextWindowResolverTest {
resolver.noteContextLimitError("p", "m", "connection refused");
assertNull(resolver.resolveMaxInputTokens(provider("p"), model("m", null)));
}
@Test
@DisplayName("cloud model with no config falls back to the built-in window table")
void catalogFillsCloudModels() {
ModelContextWindowResolver resolver =
new ModelContextWindowResolver(List.of(), properties);
assertEquals(1_000_000,
resolver.resolveMaxInputTokens(cloudProvider("deepseek"), model("deepseek-v4-pro", null)));
assertEquals(128_000,
resolver.resolveMaxInputTokens(cloudProvider("deepseek"), model("deepseek-chat", 0)));
}
@Test
@DisplayName("catalog applies with probing disabled — it costs no request")
void catalogWorksWhenProbingDisabled() {
properties.setEnabled(false);
ModelContextWindowResolver resolver =
new ModelContextWindowResolver(List.of(fixedProbe(16384)), properties);
assertEquals(1_000_000,
resolver.resolveMaxInputTokens(cloudProvider("deepseek"), model("deepseek-v4-flash", null)));
assertEquals(0, probeCalls.get());
}
@Test
@DisplayName("self-hosted endpoints skip the table — only the real server knows its window")
void catalogSkippedForLocalEndpoints() {
ModelProviderEntity local = provider("lmstudio");
local.setBaseUrl("http://127.0.0.1:1234");
ModelContextWindowResolver resolver =
new ModelContextWindowResolver(List.of(), properties);
assertNull(resolver.resolveMaxInputTokens(local, model("deepseek-v4-pro", null)));
assertNull(resolver.resolveMaxInputTokens(provider("ollama"), model("deepseek-r1:latest", null)));
}
@Test
@DisplayName("a limit parsed from a provider error outranks the table")
void errorTextOutranksCatalog() {
ModelContextWindowResolver resolver =
new ModelContextWindowResolver(List.of(), properties);
resolver.noteContextLimitError("deepseek", "deepseek-v4-pro",
"This model's maximum context length is 65536 tokens");
assertEquals(65536,
resolver.resolveMaxInputTokens(cloudProvider("deepseek"), model("deepseek-v4-pro", null)));
}
@Test
@DisplayName("an unknown model stays on the caller's global default")
void unknownModelStaysNull() {
ModelContextWindowResolver resolver =
new ModelContextWindowResolver(List.of(), properties);
assertNull(resolver.resolveMaxInputTokens(cloudProvider("acme"), model("acme-llm-1", null)));
}
}

View File

@ -0,0 +1,97 @@
package vip.mate.llm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.repository.ModelConfigMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests for {@link ModelConfigService#updateModelContextWindow} the operator
* override behind the model-management UI's context-window field.
*/
@ExtendWith(MockitoExtension.class)
class ModelConfigServiceContextWindowTest {
@Mock
private ModelConfigMapper modelConfigMapper;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private ModelConfigService service;
private ModelConfigEntity existingModel() {
ModelConfigEntity m = new ModelConfigEntity();
m.setId(1L);
m.setProvider("deepseek");
m.setModelName("deepseek-v4-pro");
m.setMaxInputTokens(0);
return m;
}
@SuppressWarnings("unchecked")
private void mapperReturns(ModelConfigEntity entity) {
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity);
}
@Test
@DisplayName("a positive value is persisted on the model row")
void setsWindow() {
ModelConfigEntity model = existingModel();
mapperReturns(model);
service.updateModelContextWindow("deepseek", "deepseek-v4-pro", 262_144);
assertEquals(262_144, model.getMaxInputTokens());
verify(modelConfigMapper).updateById(model);
}
@Test
@DisplayName("null clears the override back to 'let the server decide'")
void clearsWindow() {
ModelConfigEntity model = existingModel();
model.setMaxInputTokens(262_144);
mapperReturns(model);
service.updateModelContextWindow("deepseek", "deepseek-v4-pro", null);
assertEquals(0, model.getMaxInputTokens());
verify(modelConfigMapper).updateById(model);
}
@Test
@DisplayName("out-of-range values are rejected instead of persisted")
void rejectsOutOfRange() {
ModelConfigEntity model = existingModel();
mapperReturns(model);
assertThrows(MateClawException.class,
() -> service.updateModelContextWindow("deepseek", "deepseek-v4-pro", 12));
verify(modelConfigMapper, never()).updateById(any(ModelConfigEntity.class));
}
@Test
@DisplayName("an unknown model is an error, not a silent no-op")
void rejectsUnknownModel() {
mapperReturns(null);
assertThrows(MateClawException.class,
() -> service.updateModelContextWindow("deepseek", "nope", 128_000));
verify(modelConfigMapper, never()).updateById(any(ModelConfigEntity.class));
}
}

View File

@ -15,6 +15,9 @@ import vip.mate.llm.model.Liveness;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.probe.ContextProbeProperties;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import java.util.List;
@ -62,7 +65,9 @@ class ModelProviderServiceConfiguredTest {
when(initProbe.hasBeenProbed(any())).thenReturn(true);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
new ConversationWindowProperties());
}
@Test

View File

@ -14,6 +14,11 @@ import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.CreateCustomProviderRequest;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderConfigRequest;
import java.util.List;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.probe.ContextProbeProperties;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import static org.junit.jupiter.api.Assertions.*;
@ -67,7 +72,9 @@ class ModelProviderServiceCustomProviderTest {
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
new ConversationWindowProperties());
}
// ==================== create-side guard ====================

View File

@ -17,6 +17,9 @@ import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.EnableResult;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.probe.ContextProbeProperties;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import java.util.ArrayList;
@ -75,7 +78,9 @@ class ModelProviderServiceEnableTest {
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
new ConversationWindowProperties());
}
@Test

View File

@ -15,6 +15,9 @@ import vip.mate.llm.model.Liveness;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.probe.ContextProbeProperties;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import java.util.List;
@ -63,7 +66,9 @@ class ModelProviderServiceLivenessTest {
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
new ConversationWindowProperties());
}
@Test

View File

@ -14,6 +14,9 @@ import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderOptionDTO;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.llm.probe.ContextProbeProperties;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.repository.ModelProviderMapper;
import java.lang.reflect.RecordComponent;
@ -59,7 +62,9 @@ class ModelProviderServiceOptionsTest {
when(initProbe.hasBeenProbed(any())).thenReturn(true);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
new ConversationWindowProperties());
}
@Test

View File

@ -598,6 +598,9 @@ export const modelApi = {
http.post(`/models/${providerId}/models`, data),
removeProviderModel: (providerId: string, modelId: string) =>
http.delete(`/models/${providerId}/models`, { params: { modelId } }),
/** Per-model input context window. Pass null to clear the override. */
updateModelContextWindow: (providerId: string, modelId: string, maxInputTokens: number | null) =>
http.put(`/models/${providerId}/models/context-window`, { modelId, maxInputTokens }),
getActive: () => http.get('/models/active'),
setActive: (data: { providerId: string; model: string }) =>
http.put('/models/active', data),

View File

@ -984,6 +984,18 @@ export default {
activeChangeFailed: 'Failed to change active model',
deleteConfirm: 'Delete provider "{name}"?',
removeConfirm: 'Remove model "{name}"?',
contextWindow: {
label: 'Context window',
edit: 'Set window',
placeholder: 'Empty = use default',
hint: 'Maximum input tokens the model accepts. Drives when history gets compacted and the context usage shown in chat. Leave empty to use the built-in window table or the global default.',
sourceConfigured: 'configured',
sourceCatalog: 'built-in table',
sourceDefault: 'global default',
invalid: 'Enter a whole number between 1024 and 20000000',
updated: 'Context window updated',
updateFailed: 'Failed to update context window',
},
generateConfigInvalidJson: 'Generate kwargs is not valid JSON',
generateConfigMustBeObject: 'Generate kwargs must be a JSON object',
advancedSettings: 'Advanced Settings',

View File

@ -846,6 +846,18 @@ export default {
activeChangeFailed: '激活模型切换失败',
deleteConfirm: '确认删除提供商“{name}”?',
removeConfirm: '确认移除模型 “{name}”?',
contextWindow: {
label: '上下文窗口',
edit: '设置窗口',
placeholder: '留空使用默认',
hint: '模型能接收的最大输入 token 数,决定历史压缩的触发点和聊天里显示的上下文占用。留空则用内置窗口表或全局默认。',
sourceConfigured: '已配置',
sourceCatalog: '内置窗口表',
sourceDefault: '全局默认',
invalid: '请输入 1024 ~ 20000000 之间的整数',
updated: '上下文窗口已更新',
updateFailed: '上下文窗口更新失败',
},
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象',
advancedSettings: '高级设置',

View File

@ -879,6 +879,12 @@ export interface ProviderModelInfo {
* toggle should gate on.
*/
supportsThinking?: boolean
/** Explicit input window in tokens; null/undefined when the operator set none. */
maxInputTokens?: number | null
/** Window budgeting would use right now: configured, built-in table, or global default. */
effectiveMaxInputTokens?: number | null
/** Where `effectiveMaxInputTokens` comes from. */
maxInputTokensSource?: 'configured' | 'catalog' | 'default'
}
/**

View File

@ -77,6 +77,13 @@ export function useProviderDiscovery(deps: ListDeps) {
await deps.refreshCurrentProvider(deps.currentProvider.value.id)
}
/** Set (number) or clear (null) the model's explicit input context window. */
async function updateModelContextWindow(model: ProviderModelInfo, maxInputTokens: number | null) {
if (!deps.currentProvider.value) return
await modelApi.updateModelContextWindow(deps.currentProvider.value.id, model.id, maxInputTokens)
await deps.refreshCurrentProvider(deps.currentProvider.value.id)
}
function toggleSelectAll() {
if (!discoverResult.value) return
if (allNewSelected.value) {
@ -175,6 +182,7 @@ export function useProviderDiscovery(deps: ListDeps) {
isExtraModel,
addProviderModel,
removeProviderModel,
updateModelContextWindow,
toggleSelectAll,
handleDiscoverModels,
handleApplyModels,

View File

@ -164,6 +164,7 @@
@set-active="onSetActiveModel"
@remove-model="onRemoveProviderModel"
@add-model="onAddProviderModel"
@update-context-window="onUpdateModelContextWindow"
/>
<!-- RFC-074 PR-2: Add Provider Drawer (catalog of opt-in built-ins). -->
@ -253,6 +254,7 @@ const {
isExtraModel,
addProviderModel,
removeProviderModel,
updateModelContextWindow,
isProviderActive,
isActiveModel,
setActiveModel,
@ -380,6 +382,15 @@ async function onRemoveProviderModel(model: ProviderModelInfo) {
}
}
async function onUpdateModelContextWindow(model: ProviderModelInfo, maxInputTokens: number | null) {
try {
await updateModelContextWindow(model, maxInputTokens)
showSavedTip(t('settings.model.contextWindow.updated'))
} catch (error) {
mcToast.error(error instanceof Error ? error.message : t('settings.model.contextWindow.updateFailed'))
}
}
async function onSetActiveModel(model: ProviderModelInfo) {
try {
await setActiveModel(model)

View File

@ -97,9 +97,35 @@
:key="model.id"
class="model-list-item"
>
<div>
<div class="model-list-main">
<div class="model-list-name">{{ model.name }}</div>
<div class="model-list-id">{{ model.id }}</div>
<!-- Context window: what history compaction budgets against, and
what the chat context chip reports. Editable because the
shipped window table cannot know every vendor's model. -->
<div v-if="editingWindowId !== model.id" class="model-window">
<span class="model-window-label">{{ t('settings.model.contextWindow.label') }}</span>
<span class="model-window-value">{{ formatWindow(model.effectiveMaxInputTokens) }}</span>
<span class="model-window-source">{{ windowSourceLabel(model.maxInputTokensSource) }}</span>
<button class="model-window-edit" type="button" @click="startEditWindow(model)">
{{ t('settings.model.contextWindow.edit') }}
</button>
</div>
<div v-else class="model-window-form">
<input
v-model.number="windowInput"
class="form-input model-window-input"
type="number"
min="1024"
step="1024"
:placeholder="t('settings.model.contextWindow.placeholder')"
@keyup.enter="saveWindow(model)"
/>
<button class="card-btn test-btn" type="button" @click="saveWindow(model)">{{ t('common.save') }}</button>
<button class="card-btn test-btn" type="button" @click="clearWindow(model)">{{ t('common.clear') }}</button>
<button class="card-btn test-btn" type="button" @click="cancelEditWindow">{{ t('common.cancel') }}</button>
<div class="model-window-hint">{{ t('settings.model.contextWindow.hint') }}</div>
</div>
<div v-if="modelTestResults[model.id]" class="model-test-result" :class="modelTestResults[model.id].success ? 'success' : 'error'">
<span v-if="modelTestResults[model.id].success">
{{ t('settings.model.discovery.modelOk') }} · {{ t('settings.model.discovery.latency', { ms: modelTestResults[model.id].latencyMs }) }}
@ -160,12 +186,17 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import type { DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
const { t } = useI18n()
/** Matches the server-side bounds in ModelConfigService. */
const MIN_WINDOW = 1024
const MAX_WINDOW = 20_000_000
const props = defineProps<{
show: boolean
provider: ProviderInfo | null
@ -190,7 +221,7 @@ const discoveredUnavailable = computed(() => {
return all.filter(m => m && m.probeOk === false)
})
defineEmits<{
const emit = defineEmits<{
close: []
discover: []
toggleSelectAll: []
@ -200,7 +231,68 @@ defineEmits<{
setActive: [model: ProviderModelInfo]
removeModel: [model: ProviderModelInfo]
addModel: []
updateContextWindow: [model: ProviderModelInfo, maxInputTokens: number | null]
}>()
// Inline context-window editor. One row at a time; the value is a plain
// integer, not an id, so v-model.number is safe here.
const editingWindowId = ref<string | null>(null)
const windowInput = ref<number | null>(null)
watch(() => props.show, open => {
if (!open) cancelEditWindow()
})
function startEditWindow(model: ProviderModelInfo) {
editingWindowId.value = model.id
windowInput.value = model.maxInputTokens ?? null
}
function cancelEditWindow() {
editingWindowId.value = null
windowInput.value = null
}
function saveWindow(model: ProviderModelInfo) {
const raw = windowInput.value
// Empty input means "no override" same as pressing Clear.
if (raw === null || raw === undefined || (typeof raw === 'string' && raw === '')) {
clearWindow(model)
return
}
const value = Math.trunc(Number(raw))
if (!Number.isFinite(value) || value < MIN_WINDOW || value > MAX_WINDOW) {
mcToast.error(t('settings.model.contextWindow.invalid'))
return
}
emit('updateContextWindow', model, value)
cancelEditWindow()
}
function clearWindow(model: ProviderModelInfo) {
emit('updateContextWindow', model, null)
cancelEditWindow()
}
/** 128000 → "128K", 1000000 → "1M". */
function formatWindow(tokens?: number | null) {
if (!tokens || tokens <= 0) return '—'
if (tokens >= 1_000_000) {
const m = tokens / 1_000_000
return (Number.isInteger(m) ? m : m.toFixed(1)) + 'M'
}
if (tokens >= 1000) {
const k = tokens / 1000
return (Number.isInteger(k) ? k : k.toFixed(1)) + 'K'
}
return String(tokens)
}
function windowSourceLabel(source?: string) {
if (source === 'configured') return t('settings.model.contextWindow.sourceConfigured')
if (source === 'catalog') return t('settings.model.contextWindow.sourceCatalog')
return t('settings.model.contextWindow.sourceDefault')
}
</script>
<style scoped>
@ -292,6 +384,17 @@ defineEmits<{
.model-list-name { font-weight: 600; color: var(--mc-text-primary); }
.model-list-id { font-size: 12px; color: var(--mc-text-secondary); }
.model-list-actions { display: flex; align-items: center; gap: 8px; }
.model-list-main { min-width: 0; flex: 1; }
.model-window { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: var(--mc-text-secondary); flex-wrap: wrap; }
.model-window-label { color: var(--mc-text-tertiary); }
.model-window-value { font-weight: 600; color: var(--mc-text-primary); }
.model-window-source { color: var(--mc-text-tertiary); }
.model-window-edit { background: none; border: none; padding: 0; font-size: 12px; color: var(--mc-primary); cursor: pointer; }
.model-window-edit:hover { text-decoration: underline; }
.model-window-form { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
/* Specific enough to beat the shared .form-input width:100% below. */
.model-window-form .model-window-input { width: 150px; padding: 5px 8px; font-size: 12px; }
.model-window-hint { flex-basis: 100%; font-size: 11px; color: var(--mc-text-tertiary); line-height: 1.5; }
.model-add-box { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--mc-border-light); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.form-label { display: block; font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 6px; }