mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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));
|
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 = "获取模型详情")
|
@Operation(summary = "获取模型详情")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@RequireGlobalAdmin
|
@RequireGlobalAdmin
|
||||||
|
|||||||
@ -41,6 +41,27 @@ public class ModelInfoDTO {
|
|||||||
*/
|
*/
|
||||||
private boolean supportsThinking;
|
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) {
|
public ModelInfoDTO(String id, String name) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.name = name;
|
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")
|
@ConfigurationProperties(prefix = "mateclaw.context.probe")
|
||||||
public class ContextProbeProperties {
|
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;
|
private boolean enabled = true;
|
||||||
|
|
||||||
/** Per-request read timeout. Probing must never hold up chat startup. */
|
/** 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
|
* <li>explicit {@code ModelConfigEntity.maxInputTokens} — user configuration
|
||||||
* always wins;</li>
|
* always wins;</li>
|
||||||
* <li>a probed value from a {@link LocalContextProbe} (runtime-cached with a
|
* <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
|
* <li>{@code null} — caller falls back to the global default, exactly the
|
||||||
* pre-probe behavior.</li>
|
* pre-probe behavior.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
@ -48,9 +51,9 @@ public class ModelContextWindowResolver {
|
|||||||
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the effective max input tokens, or {@code null} when neither
|
* @return the effective max input tokens, or {@code null} when explicit
|
||||||
* explicit config nor probing yields a value (caller keeps its
|
* config, probing and the built-in catalog all come up empty
|
||||||
* existing global-default fallback).
|
* (caller keeps its existing global-default fallback).
|
||||||
*/
|
*/
|
||||||
public Integer resolveMaxInputTokens(ModelProviderEntity provider, ModelConfigEntity model) {
|
public Integer resolveMaxInputTokens(ModelProviderEntity provider, ModelConfigEntity model) {
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
@ -59,36 +62,77 @@ public class ModelContextWindowResolver {
|
|||||||
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
|
if (model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0) {
|
||||||
return model.getMaxInputTokens();
|
return model.getMaxInputTokens();
|
||||||
}
|
}
|
||||||
if (!properties.isEnabled()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String key = cacheKey(provider != null ? provider.getProviderId() : null, model.getModelName());
|
String key = cacheKey(provider != null ? provider.getProviderId() : null, model.getModelName());
|
||||||
CacheEntry cached = cache.get(key);
|
if (properties.isEnabled()) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
if (cached != null && cached.expiresAtMs() > now) {
|
CacheEntry cached = cache.get(key);
|
||||||
return cached.value();
|
if (cached != null && cached.expiresAtMs() > now) {
|
||||||
}
|
// A cached value outranks the catalog: it came from the live
|
||||||
Integer probed = null;
|
// endpoint or from the provider's own over-limit rejection.
|
||||||
for (LocalContextProbe probe : probes) {
|
if (cached.value() != null) {
|
||||||
try {
|
return cached.value();
|
||||||
if (!probe.supports(provider, model)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
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) {
|
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()));
|
Integer known = catalogWindow(provider, model);
|
||||||
if (probed != null) {
|
if (known != null) {
|
||||||
log.info("[ContextProbe] 探测到模型 {} 的上下文窗口为 {} tokens(未配置 maxInputTokens,窗口预算将使用探测值)",
|
log.debug("[ContextProbe] 模型 {} 未配置 maxInputTokens,按内置窗口表使用 {} tokens", key, known);
|
||||||
key, probed);
|
|
||||||
}
|
}
|
||||||
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
|
@RequiredArgsConstructor
|
||||||
public class ModelConfigService {
|
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 ModelConfigMapper modelConfigMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final ModelCapabilityService modelCapabilityService;
|
private final ModelCapabilityService modelCapabilityService;
|
||||||
@ -297,6 +302,33 @@ public class ModelConfigService {
|
|||||||
return entity;
|
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) {
|
public void removeModelFromProvider(String providerId, String modelId) {
|
||||||
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
|
||||||
.eq(ModelConfigEntity::getProvider, providerId)
|
.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.ProviderInitProbe;
|
||||||
import vip.mate.llm.failover.ProviderRequirements;
|
import vip.mate.llm.failover.ProviderRequirements;
|
||||||
import vip.mate.llm.model.*;
|
import vip.mate.llm.model.*;
|
||||||
|
import vip.mate.llm.probe.ModelContextWindowResolver;
|
||||||
import vip.mate.llm.repository.ModelProviderMapper;
|
import vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
|
||||||
import org.springframework.ai.chat.model.ChatModel;
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
|
||||||
@ -60,6 +62,10 @@ public class ModelProviderService {
|
|||||||
* defers Spring's wiring decision past construction.
|
* defers Spring's wiring decision past construction.
|
||||||
*/
|
*/
|
||||||
private final ObjectProvider<ProviderInitProbe> providerInitProbeProvider;
|
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();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
|
/** Plugin-registered ChatModel instances: providerId -> ChatModel */
|
||||||
@ -235,6 +241,41 @@ public class ModelProviderService {
|
|||||||
return toProviderInfo(getProvider(providerId), modelConfigService.listModelsByProvider(providerId));
|
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) {
|
public ModelProviderEntity getProviderConfig(String providerId) {
|
||||||
return getProvider(providerId);
|
return getProvider(providerId);
|
||||||
}
|
}
|
||||||
@ -455,6 +496,7 @@ public class ModelProviderService {
|
|||||||
// RFC-049 PR-1-UI: ModelInfoDTO(id, name) derives supportsReasoningEffort
|
// RFC-049 PR-1-UI: ModelInfoDTO(id, name) derives supportsReasoningEffort
|
||||||
// from id via ModelFamily — no extra wiring needed here.
|
// from id via ModelFamily — no extra wiring needed here.
|
||||||
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
|
ModelInfoDTO info = new ModelInfoDTO(model.getModelName(), model.getName());
|
||||||
|
applyContextWindow(info, provider, model);
|
||||||
if (Boolean.TRUE.equals(model.getBuiltin())) {
|
if (Boolean.TRUE.equals(model.getBuiltin())) {
|
||||||
builtinModels.add(info);
|
builtinModels.add(info);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
-- from data-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators
|
-- from data-{en,zh,mysql-en,mysql-zh}.sql; this migration covers operators
|
||||||
-- already on V44.
|
-- already on V44.
|
||||||
--
|
--
|
||||||
-- Reference: openclaw extensions/deepseek/models.ts:28-81 — V4 supports
|
-- V4 supports reasoning_effort together with thinking control. NULL
|
||||||
-- reasoning_effort + thinking control. NULL temperature/top_p marks the model
|
-- temperature/top_p marks the model as thinking-managed
|
||||||
-- as thinking-managed (DeepSeekV4ThinkingDecorator handles the per-request
|
-- (DeepSeekV4ThinkingDecorator handles the per-request thinking field
|
||||||
-- thinking field injection).
|
-- 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)
|
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)
|
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
|
4. **Recent turns** — as many as fit in the token budget
|
||||||
5. **Current user message** — always last
|
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).
|
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 预算
|
4. **最近的若干轮**——尽可能装进 token 预算
|
||||||
5. **当前用户消息**——永远在最后
|
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) 里。
|
更多细节,以及"为什么把摘要注入成 `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;
|
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) {
|
private static ModelConfigEntity model(String name, Integer maxInputTokens) {
|
||||||
ModelConfigEntity model = new ModelConfigEntity();
|
ModelConfigEntity model = new ModelConfigEntity();
|
||||||
model.setModelName(name);
|
model.setModelName(name);
|
||||||
@ -132,4 +138,56 @@ class ModelContextWindowResolverTest {
|
|||||||
resolver.noteContextLimitError("p", "m", "connection refused");
|
resolver.noteContextLimitError("p", "m", "connection refused");
|
||||||
assertNull(resolver.resolveMaxInputTokens(provider("p"), model("m", null)));
|
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.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.model.ProviderInfoDTO;
|
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 vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -62,7 +65,9 @@ class ModelProviderServiceConfiguredTest {
|
|||||||
when(initProbe.hasBeenProbed(any())).thenReturn(true);
|
when(initProbe.hasBeenProbed(any())).thenReturn(true);
|
||||||
|
|
||||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
|
||||||
|
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
|
||||||
|
new ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -14,6 +14,11 @@ import vip.mate.llm.failover.ProviderInitProbe;
|
|||||||
import vip.mate.llm.model.CreateCustomProviderRequest;
|
import vip.mate.llm.model.CreateCustomProviderRequest;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.model.ProviderConfigRequest;
|
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 vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@ -67,7 +72,9 @@ class ModelProviderServiceCustomProviderTest {
|
|||||||
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
||||||
|
|
||||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
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 ====================
|
// ==================== create-side guard ====================
|
||||||
|
|||||||
@ -17,6 +17,9 @@ import vip.mate.llm.failover.ProviderInitProbe;
|
|||||||
import vip.mate.llm.model.EnableResult;
|
import vip.mate.llm.model.EnableResult;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
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 vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -75,7 +78,9 @@ class ModelProviderServiceEnableTest {
|
|||||||
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
||||||
|
|
||||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
|
||||||
|
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
|
||||||
|
new ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -15,6 +15,9 @@ import vip.mate.llm.model.Liveness;
|
|||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.model.ProviderInfoDTO;
|
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 vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -63,7 +66,9 @@ class ModelProviderServiceLivenessTest {
|
|||||||
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
||||||
|
|
||||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
|
||||||
|
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
|
||||||
|
new ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import vip.mate.llm.failover.ProviderInitProbe;
|
|||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.model.ProviderOptionDTO;
|
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 vip.mate.llm.repository.ModelProviderMapper;
|
||||||
|
|
||||||
import java.lang.reflect.RecordComponent;
|
import java.lang.reflect.RecordComponent;
|
||||||
@ -59,7 +62,9 @@ class ModelProviderServiceOptionsTest {
|
|||||||
when(initProbe.hasBeenProbed(any())).thenReturn(true);
|
when(initProbe.hasBeenProbed(any())).thenReturn(true);
|
||||||
|
|
||||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider,
|
||||||
|
new ModelContextWindowResolver(List.of(), new ContextProbeProperties()),
|
||||||
|
new ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -598,6 +598,9 @@ export const modelApi = {
|
|||||||
http.post(`/models/${providerId}/models`, data),
|
http.post(`/models/${providerId}/models`, data),
|
||||||
removeProviderModel: (providerId: string, modelId: string) =>
|
removeProviderModel: (providerId: string, modelId: string) =>
|
||||||
http.delete(`/models/${providerId}/models`, { params: { modelId } }),
|
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'),
|
getActive: () => http.get('/models/active'),
|
||||||
setActive: (data: { providerId: string; model: string }) =>
|
setActive: (data: { providerId: string; model: string }) =>
|
||||||
http.put('/models/active', data),
|
http.put('/models/active', data),
|
||||||
|
|||||||
@ -984,6 +984,18 @@ export default {
|
|||||||
activeChangeFailed: 'Failed to change active model',
|
activeChangeFailed: 'Failed to change active model',
|
||||||
deleteConfirm: 'Delete provider "{name}"?',
|
deleteConfirm: 'Delete provider "{name}"?',
|
||||||
removeConfirm: 'Remove model "{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',
|
generateConfigInvalidJson: 'Generate kwargs is not valid JSON',
|
||||||
generateConfigMustBeObject: 'Generate kwargs must be a JSON object',
|
generateConfigMustBeObject: 'Generate kwargs must be a JSON object',
|
||||||
advancedSettings: 'Advanced Settings',
|
advancedSettings: 'Advanced Settings',
|
||||||
|
|||||||
@ -846,6 +846,18 @@ export default {
|
|||||||
activeChangeFailed: '激活模型切换失败',
|
activeChangeFailed: '激活模型切换失败',
|
||||||
deleteConfirm: '确认删除提供商“{name}”?',
|
deleteConfirm: '确认删除提供商“{name}”?',
|
||||||
removeConfirm: '确认移除模型 “{name}”?',
|
removeConfirm: '确认移除模型 “{name}”?',
|
||||||
|
contextWindow: {
|
||||||
|
label: '上下文窗口',
|
||||||
|
edit: '设置窗口',
|
||||||
|
placeholder: '留空使用默认',
|
||||||
|
hint: '模型能接收的最大输入 token 数,决定历史压缩的触发点和聊天里显示的上下文占用。留空则用内置窗口表或全局默认。',
|
||||||
|
sourceConfigured: '已配置',
|
||||||
|
sourceCatalog: '内置窗口表',
|
||||||
|
sourceDefault: '全局默认',
|
||||||
|
invalid: '请输入 1024 ~ 20000000 之间的整数',
|
||||||
|
updated: '上下文窗口已更新',
|
||||||
|
updateFailed: '上下文窗口更新失败',
|
||||||
|
},
|
||||||
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
||||||
generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象',
|
generateConfigMustBeObject: 'Generate Kwargs 必须是 JSON 对象',
|
||||||
advancedSettings: '高级设置',
|
advancedSettings: '高级设置',
|
||||||
|
|||||||
@ -879,6 +879,12 @@ export interface ProviderModelInfo {
|
|||||||
* toggle should gate on.
|
* toggle should gate on.
|
||||||
*/
|
*/
|
||||||
supportsThinking?: boolean
|
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)
|
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() {
|
function toggleSelectAll() {
|
||||||
if (!discoverResult.value) return
|
if (!discoverResult.value) return
|
||||||
if (allNewSelected.value) {
|
if (allNewSelected.value) {
|
||||||
@ -175,6 +182,7 @@ export function useProviderDiscovery(deps: ListDeps) {
|
|||||||
isExtraModel,
|
isExtraModel,
|
||||||
addProviderModel,
|
addProviderModel,
|
||||||
removeProviderModel,
|
removeProviderModel,
|
||||||
|
updateModelContextWindow,
|
||||||
toggleSelectAll,
|
toggleSelectAll,
|
||||||
handleDiscoverModels,
|
handleDiscoverModels,
|
||||||
handleApplyModels,
|
handleApplyModels,
|
||||||
|
|||||||
@ -164,6 +164,7 @@
|
|||||||
@set-active="onSetActiveModel"
|
@set-active="onSetActiveModel"
|
||||||
@remove-model="onRemoveProviderModel"
|
@remove-model="onRemoveProviderModel"
|
||||||
@add-model="onAddProviderModel"
|
@add-model="onAddProviderModel"
|
||||||
|
@update-context-window="onUpdateModelContextWindow"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- RFC-074 PR-2: Add Provider Drawer (catalog of opt-in built-ins). -->
|
<!-- RFC-074 PR-2: Add Provider Drawer (catalog of opt-in built-ins). -->
|
||||||
@ -253,6 +254,7 @@ const {
|
|||||||
isExtraModel,
|
isExtraModel,
|
||||||
addProviderModel,
|
addProviderModel,
|
||||||
removeProviderModel,
|
removeProviderModel,
|
||||||
|
updateModelContextWindow,
|
||||||
isProviderActive,
|
isProviderActive,
|
||||||
isActiveModel,
|
isActiveModel,
|
||||||
setActiveModel,
|
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) {
|
async function onSetActiveModel(model: ProviderModelInfo) {
|
||||||
try {
|
try {
|
||||||
await setActiveModel(model)
|
await setActiveModel(model)
|
||||||
|
|||||||
@ -97,9 +97,35 @@
|
|||||||
:key="model.id"
|
:key="model.id"
|
||||||
class="model-list-item"
|
class="model-list-item"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="model-list-main">
|
||||||
<div class="model-list-name">{{ model.name }}</div>
|
<div class="model-list-name">{{ model.name }}</div>
|
||||||
<div class="model-list-id">{{ model.id }}</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'">
|
<div v-if="modelTestResults[model.id]" class="model-test-result" :class="modelTestResults[model.id].success ? 'success' : 'error'">
|
||||||
<span v-if="modelTestResults[model.id].success">
|
<span v-if="modelTestResults[model.id].success">
|
||||||
{{ t('settings.model.discovery.modelOk') }} · {{ t('settings.model.discovery.latency', { ms: modelTestResults[model.id].latencyMs }) }}
|
{{ t('settings.model.discovery.modelOk') }} · {{ t('settings.model.discovery.latency', { ms: modelTestResults[model.id].latencyMs }) }}
|
||||||
@ -160,12 +186,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
import type { DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
import type { DiscoverResult, ProviderInfo, ProviderModelInfo, TestResult } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/** Matches the server-side bounds in ModelConfigService. */
|
||||||
|
const MIN_WINDOW = 1024
|
||||||
|
const MAX_WINDOW = 20_000_000
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
provider: ProviderInfo | null
|
provider: ProviderInfo | null
|
||||||
@ -190,7 +221,7 @@ const discoveredUnavailable = computed(() => {
|
|||||||
return all.filter(m => m && m.probeOk === false)
|
return all.filter(m => m && m.probeOk === false)
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
discover: []
|
discover: []
|
||||||
toggleSelectAll: []
|
toggleSelectAll: []
|
||||||
@ -200,7 +231,68 @@ defineEmits<{
|
|||||||
setActive: [model: ProviderModelInfo]
|
setActive: [model: ProviderModelInfo]
|
||||||
removeModel: [model: ProviderModelInfo]
|
removeModel: [model: ProviderModelInfo]
|
||||||
addModel: []
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -292,6 +384,17 @@ defineEmits<{
|
|||||||
.model-list-name { font-weight: 600; color: var(--mc-text-primary); }
|
.model-list-name { font-weight: 600; color: var(--mc-text-primary); }
|
||||||
.model-list-id { font-size: 12px; color: var(--mc-text-secondary); }
|
.model-list-id { font-size: 12px; color: var(--mc-text-secondary); }
|
||||||
.model-list-actions { display: flex; align-items: center; gap: 8px; }
|
.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); }
|
.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-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; }
|
.form-label { display: block; font-size: 13px; color: var(--mc-text-secondary); margin-bottom: 6px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user