From 5ba92f3b877dae958d742949a28f393283dd9bb6 Mon Sep 17 00:00:00 2001 From: matevip Date: Sun, 2 Aug 2026 23:39:31 -0400 Subject: [PATCH] feat(llm): resolve per-model context windows instead of the global 128k default --- .../llm/controller/ModelConfigController.java | 9 ++ .../java/vip/mate/llm/model/ModelInfoDTO.java | 21 ++++ .../UpdateModelContextWindowRequest.java | 19 +++ .../llm/probe/ContextProbeProperties.java | 6 +- .../llm/probe/ModelContextWindowCatalog.java | 119 ++++++++++++++++++ .../llm/probe/ModelContextWindowResolver.java | 98 +++++++++++---- .../mate/llm/service/ModelConfigService.java | 32 +++++ .../llm/service/ModelProviderService.java | 42 +++++++ .../migration/h2/V45__deepseek_v4_models.sql | 8 +- .../src/main/resources/docs/en/chat.md | 4 +- .../src/main/resources/docs/zh/chat.md | 4 +- .../probe/ModelContextWindowCatalogTest.java | 41 ++++++ .../probe/ModelContextWindowResolverTest.java | 58 +++++++++ .../ModelConfigServiceContextWindowTest.java | 97 ++++++++++++++ .../ModelProviderServiceConfiguredTest.java | 7 +- ...odelProviderServiceCustomProviderTest.java | 9 +- .../ModelProviderServiceEnableTest.java | 7 +- .../ModelProviderServiceLivenessTest.java | 7 +- .../ModelProviderServiceOptionsTest.java | 7 +- mateclaw-ui/src/api/index.ts | 3 + mateclaw-ui/src/i18n/locales/en-US.ts | 12 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 12 ++ mateclaw-ui/src/types/index.ts | 6 + .../composables/useProviderDiscovery.ts | 8 ++ .../src/views/Settings/Models/index.vue | 11 ++ .../Models/modals/ManageModelsModal.vue | 109 +++++++++++++++- 26 files changed, 714 insertions(+), 42 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/model/UpdateModelContextWindowRequest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowCatalog.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowCatalogTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceContextWindowTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index dfed1400..bc075bdd 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -176,6 +176,15 @@ public class ModelConfigController { return R.ok(modelProviderService.removeModel(providerId, modelId)); } + @Operation(summary = "设置模型上下文窗口") + @PutMapping("/{providerId}/models/context-window") + @RequireGlobalAdmin + public R updateModelContextWindow(@PathVariable String providerId, + @RequestBody UpdateModelContextWindowRequest request) { + return R.ok(modelProviderService.updateModelContextWindow( + providerId, request.getModelId(), request.getMaxInputTokens())); + } + @Operation(summary = "获取模型详情") @GetMapping("/{id}") @RequireGlobalAdmin diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java index 18013199..57dc1544 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelInfoDTO.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/UpdateModelContextWindowRequest.java b/mateclaw-server/src/main/java/vip/mate/llm/model/UpdateModelContextWindowRequest.java new file mode 100644 index 00000000..625cb44c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/UpdateModelContextWindowRequest.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java index ed32d81f..64cba931 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ContextProbeProperties.java @@ -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. */ diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowCatalog.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowCatalog.java new file mode 100644 index 00000000..eed10f77 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowCatalog.java @@ -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. + * + *

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. + * + *

Values are input 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. + * + *

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 WINDOWS; + + static { + Map 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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java index 70549909..e703c811 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/probe/ModelContextWindowResolver.java @@ -22,7 +22,10 @@ import java.util.concurrent.ConcurrentHashMap; *

  • explicit {@code ModelConfigEntity.maxInputTokens} — user configuration * always wins;
  • *
  • a probed value from a {@link LocalContextProbe} (runtime-cached with a - * short TTL, never persisted — local servers hot-swap models);
  • + * short TTL, never persisted — local servers hot-swap models), or a limit + * previously parsed out of a provider error; + *
  • {@link ModelContextWindowCatalog} — the built-in window table for + * hosted models, which are never probed;
  • *
  • {@code null} — caller falls back to the global default, exactly the * pre-probe behavior.
  • * @@ -48,9 +51,9 @@ public class ModelContextWindowResolver { private final Map 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()); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index 24be318b..a552149e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -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() + .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() .eq(ModelConfigEntity::getProvider, providerId) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 7cb8e142..9718ec38 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -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 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 { diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql index b38908a6..ab147a9e 100644 --- a/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql +++ b/mateclaw-server/src/main/resources/db/migration/h2/V45__deepseek_v4_models.sql @@ -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) diff --git a/mateclaw-server/src/main/resources/docs/en/chat.md b/mateclaw-server/src/main/resources/docs/en/chat.md index 5ed1c1b8..8022ef87 100644 --- a/mateclaw-server/src/main/resources/docs/en/chat.md +++ b/mateclaw-server/src/main/resources/docs/en/chat.md @@ -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). diff --git a/mateclaw-server/src/main/resources/docs/zh/chat.md b/mateclaw-server/src/main/resources/docs/zh/chat.md index bf15f8c2..1058fb89 100644 --- a/mateclaw-server/src/main/resources/docs/zh/chat.md +++ b/mateclaw-server/src/main/resources/docs/zh/chat.md @@ -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) 里。 diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowCatalogTest.java new file mode 100644 index 00000000..f8fc987d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowCatalogTest.java @@ -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)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java index 561119fc..5aeb57aa 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/probe/ModelContextWindowResolverTest.java @@ -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))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceContextWindowTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceContextWindowTest.java new file mode 100644 index 00000000..176bda44 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelConfigServiceContextWindowTest.java @@ -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)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java index ce3faab3..da87cc05 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceConfiguredTest.java @@ -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 diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java index 2562724c..995f3509 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java @@ -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 ==================== diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java index afa6f791..f6de6abf 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceEnableTest.java @@ -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 diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java index 7dc467df..427da7eb 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceLivenessTest.java @@ -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 diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java index 1d5531c5..94f7befc 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceOptionsTest.java @@ -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 diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index ae7d0991..cedafcc1 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index f3561919..9aa4bc1e 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index f3ca3b0d..ea845750 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '高级设置', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index d0866451..f7e70de5 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -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' } /** diff --git a/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts b/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts index 695953b2..5c897899 100644 --- a/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts +++ b/mateclaw-ui/src/views/Settings/Models/composables/useProviderDiscovery.ts @@ -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, diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index 967610ce..f7064b85 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -164,6 +164,7 @@ @set-active="onSetActiveModel" @remove-model="onRemoveProviderModel" @add-model="onAddProviderModel" + @update-context-window="onUpdateModelContextWindow" /> @@ -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) diff --git a/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue b/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue index eff1c3ff..43dd7e42 100644 --- a/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue +++ b/mateclaw-ui/src/views/Settings/Models/modals/ManageModelsModal.vue @@ -97,9 +97,35 @@ :key="model.id" class="model-list-item" > -
    +
    {{ model.name }}
    {{ model.id }}
    + +
    + {{ t('settings.model.contextWindow.label') }} + {{ formatWindow(model.effectiveMaxInputTokens) }} + {{ windowSourceLabel(model.maxInputTokensSource) }} + +
    +
    + + + + +
    {{ t('settings.model.contextWindow.hint') }}
    +
    {{ t('settings.model.discovery.modelOk') }} · {{ t('settings.model.discovery.latency', { ms: modelTestResults[model.id].latencyMs }) }} @@ -160,12 +186,17 @@