mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): resolve per-model context windows instead of the global 128k default
This commit is contained in:
parent
bfd84fd5c3
commit
5ba92f3b87
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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. */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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) 里。
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '高级设置',
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user