feat(config): support per-model context windows (#6677)

Problem: Shared provider endpoints can expose models with different context limits, but Reasonix only allowed one provider-wide context_window value.

Approach: Add an optional model_overrides.<model>.context_window, preserve existing capability overrides, expose the setting in desktop UI with token guidance and a low-value warning, and document the behavior.

Compatibility: Missing or zero model overrides inherit the provider value. The desktop JSON field is optional, and older TOML readers ignore the additive nested key.

Cache impact: No provider-visible prompt or schema bytes change. Explicitly smaller values may compact earlier; the UI warns below 16384 tokens and unset values preserve existing behavior.

Verification: ./scripts/cache-guard.sh; go vet ./...; go test ./...; cd desktop && go test ./...; frontend typecheck, CSS checks, and provider model tests.

Co-authored-by: HaoyueQin <[email protected]>
This commit is contained in:
SivanColaandHaoyueQin authored and GitHub committed 2026-07-19 16:15:15 +08:00
1 parent dd929bf2df
commit c77af74c96
19 files changed
+321 -25

No files matched your search

@@ -6,10 +6,13 @@ import {
isLikelyChatModel,
isLikelyVisionModel,
mergedFetchedProviderModels,
mergeProviderModelContextWindows,
providerApiKeyEnvForSave,
providerDefaultModel,
providerIsConfigured,
providerModelCandidates,
providerModelContextWindowDrafts,
providerModelContextWindowIsSmall,
providerRequiresKey,
} from "../lib/providerModels";
@@ -122,6 +125,67 @@ eq(
"falls back to first saved model when default is unavailable",
);
const contextOverrides = [
{
model: "short-model",
reasoningProtocol: "none",
supportedEfforts: [],
defaultEffort: "",
vision: null,
contextWindow: 32768,
},
{
model: "long-model",
reasoningProtocol: "",
supportedEfforts: [],
defaultEffort: "",
vision: null,
contextWindow: 1000000,
},
];
eq(
providerModelContextWindowDrafts(contextOverrides),
{ "short-model": "32768", "long-model": "1000000" },
"loads positive per-model context windows into editable drafts",
);
eq(
mergeProviderModelContextWindows(contextOverrides, ["short-model", "new-model"], {
"short-model": "65536",
"new-model": "131072",
}),
[
{ ...contextOverrides[0], contextWindow: 65536 },
{
model: "new-model",
reasoningProtocol: "",
supportedEfforts: [],
defaultEffort: "",
vision: null,
contextWindow: 131072,
},
],
"merges model context edits while preserving other overrides and dropping removed models",
);
eq(
mergeProviderModelContextWindows([], ["invalid-model"], { "invalid-model": "Infinity" }),
[],
"drops non-finite model context values before crossing the desktop bridge",
);
eq(
[
providerModelContextWindowIsSmall("8192"),
providerModelContextWindowIsSmall("16384"),
providerModelContextWindowIsSmall(""),
providerModelContextWindowIsSmall("Infinity"),
],
[true, false, false, false],
"warns only for positive context windows below 16384 tokens",
);
eq(
providerApiKeyEnvForSave("Local Gateway", "", ""),
"",
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
import { useDeferredClose } from "../lib/useMountTransition";
import { app, openExternal } from "../lib/bridge";
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerRequiresKey } from "../lib/providerModels";
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerModelContextWindowIsSmall, providerRequiresKey } from "../lib/providerModels";
import { useUpdater } from "../lib/useUpdater";
import {
applyTheme,
@@ -5461,18 +5461,22 @@ const ProviderEditorModelPicker = memo(function ProviderEditorModelPicker({
candidates,
selectedModels,
visionModels,
contextWindows,
disabled,
onToggleModel,
onToggleVision,
onContextWindowChange,
onSelectAll,
onClear,
}: {
candidates: string[];
selectedModels: string[];
visionModels: string[];
contextWindows: Record<string, string>;
disabled: boolean;
onToggleModel: (model: string) => void;
onToggleVision: (model: string) => void;
onContextWindowChange: (model: string, value: string) => void;
onSelectAll: () => void;
onClear: () => void;
}) {
@@ -5506,6 +5510,7 @@ const ProviderEditorModelPicker = memo(function ProviderEditorModelPicker({
</button>
</div>
</div>
<div className="provider-model-draft__context-guide">{t("settings.modelContextWindowGuide")}</div>
{candidates.length > 8 && (
<input
className="mem-input provider-model-draft__search"
@@ -5538,6 +5543,28 @@ const ProviderEditorModelPicker = memo(function ProviderEditorModelPicker({
/>
<span>{t("settings.visionModel")}</span>
</label>
<div className="provider-model-draft__context-field">
<label className="provider-model-draft__context">
<span>{t("settings.modelContextWindow")}</span>
<input
className="mem-input provider-model-draft__context-input"
type="number"
inputMode="numeric"
min={1}
disabled={disabled || !enabled}
placeholder={t("settings.modelContextWindowPlaceholder")}
title={t("settings.modelContextWindowHint")}
aria-label={t("settings.modelContextWindowAria", { model })}
value={contextWindows[model] ?? ""}
onChange={(event) => onContextWindowChange(model, event.target.value)}
/>
</label>
{enabled && providerModelContextWindowIsSmall(contextWindows[model]) && (
<div className="provider-model-draft__context-warning" role="status">
{t("settings.modelContextWindowSmallWarning")}
</div>
)}
</div>
</div>
);
}) : (
@@ -5584,9 +5611,12 @@ function ProviderEditor({
const [authHeader, setAuthHeader] = useState(Boolean(initial?.authHeader));
const [keyDraft, setKeyDraft] = useState("");
const [balanceUrl, setBalanceUrl] = useState(initial?.balanceUrl ?? "");
// Empty when unset so the placeholder (and its "0 = default" hint) reads instead
// Empty when unset so the placeholder (and its "0 = disabled" hint) reads instead
// of a bare "0"; saved back as 0.
const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
const [modelContextWindows, setModelContextWindows] = useState<Record<string, string>>(
() => providerModelContextWindowDrafts(initial?.modelOverrides),
);
const [reasoningProtocol, setReasoningProtocol] = useState(normalizeReasoningProtocol(initial?.reasoningProtocol));
const [thinking, setThinking] = useState(normalizeThinkingMode(initial?.thinking));
const [supportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
@@ -5662,7 +5692,7 @@ function ProviderEditor({
thinking,
supportedEfforts: cleanedSupportedEfforts,
defaultEffort: cleanDefaultEffort,
modelOverrides: initial?.modelOverrides ?? [],
modelOverrides: mergeProviderModelContextWindows(initial?.modelOverrides, parseProviderListInput(models), modelContextWindows),
});
if (fetched.length === 0) {
setFetchFallback(t("settings.fetchModelsManualFallbackEmpty"));
@@ -5716,7 +5746,7 @@ function ProviderEditor({
// Clear the stored default if no levels are selected; the backend's
// NormalizeEffort would otherwise silently ignore an unsupported value.
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
modelOverrides: initial?.modelOverrides ?? [],
modelOverrides: mergeProviderModelContextWindows(initial?.modelOverrides, ms, modelContextWindows),
};
try {
await onSave(provider, keyDraft.trim() || undefined);
@@ -5806,6 +5836,10 @@ function ProviderEditor({
setVisionModelsConfigured(true);
};
const updateEditorModelContextWindow = (model: string, value: string) => {
setModelContextWindows((current) => ({ ...current, [model]: value }));
};
const selectAllEditorModels = () => {
setModelsFromList(modelCandidateNames);
setVisionModels(visionModelNames.filter((model) => modelCandidateNames.includes(model)).join(", "));
@@ -6015,9 +6049,11 @@ function ProviderEditor({
candidates={modelCandidateNames}
selectedModels={modelNames}
visionModels={visionModelNames}
contextWindows={modelContextWindows}
disabled={busy || fetchingModels}
onToggleModel={toggleEditorModel}
onToggleVision={toggleEditorVisionModel}
onContextWindowChange={updateEditorModelContextWindow}
onSelectAll={selectAllEditorModels}
onClear={clearEditorModels}
/>
@@ -1,3 +1,5 @@
import type { ProviderModelOverrideView } from "./types";
export function mergedFetchedProviderModels(current: string[], fetched: string[], options: { preserveCurated?: boolean } = {}): string[] {
const saved = uniqueStrings(current);
if (options.preserveCurated && saved.length > 0) return saved;
@@ -16,6 +18,58 @@ export function providerDefaultModel(currentDefault: string, models: string[]):
return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? "";
}
export function providerModelContextWindowDrafts(overrides: ProviderModelOverrideView[] | null | undefined): Record<string, string> {
const drafts: Record<string, string> = {};
for (const override of overrides ?? []) {
const model = override.model.trim();
const contextWindow = normalizedContextWindow(override.contextWindow);
if (model && contextWindow > 0) drafts[model] = String(contextWindow);
}
return drafts;
}
export function providerModelContextWindowIsSmall(value: unknown): boolean {
const contextWindow = normalizedContextWindow(value);
return contextWindow > 0 && contextWindow < 16_384;
}
export function mergeProviderModelContextWindows(
overrides: ProviderModelOverrideView[] | null | undefined,
models: string[],
drafts: Record<string, string>,
): ProviderModelOverrideView[] {
const existing = new Map((overrides ?? []).map((override) => [override.model.trim(), override]));
const merged: ProviderModelOverrideView[] = [];
for (const model of uniqueStrings(models)) {
const previous = existing.get(model);
const parsedContextWindow = normalizedContextWindow(drafts[model]);
const override: ProviderModelOverrideView = {
model,
reasoningProtocol: previous?.reasoningProtocol ?? "",
supportedEfforts: previous?.supportedEfforts ?? [],
defaultEffort: previous?.defaultEffort ?? "",
vision: previous?.vision ?? null,
contextWindow: Math.max(parsedContextWindow, 0),
};
if (
override.reasoningProtocol.trim()
|| override.supportedEfforts.length > 0
|| override.defaultEffort.trim()
|| override.vision != null
|| (override.contextWindow ?? 0) > 0
) {
merged.push(override);
}
}
return merged;
}
function normalizedContextWindow(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.min(Math.trunc(parsed), Number.MAX_SAFE_INTEGER);
}
export function providerRequiresKey(provider: { requiresKey?: boolean; apiKeyEnv?: string }): boolean {
if (typeof provider.requiresKey === "boolean") return provider.requiresKey;
return Boolean((provider.apiKeyEnv ?? "").trim());
+1
View File
@@ -1183,6 +1183,7 @@ export interface ProviderModelOverrideView {
supportedEfforts: string[];
defaultEffort: string;
vision?: boolean | null;
contextWindow?: number;
}
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
+9 -3
View File
@@ -1405,9 +1405,15 @@ export const en = {
"settings.providerBalanceUrl": "Balance URL (optional)",
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
"settings.providerContextWindow": "Context window",
"settings.contextWindowPlaceholder": "tokens (0 = provider default)",
"settings.contextWindowHint": "Max tokens to keep in context for this provider. Leave 0 to use the provider's default.",
"settings.providerContextWindow": "Context window (tokens)",
"settings.contextWindowPlaceholder": "tokens (0 = disable auto-compaction)",
"settings.contextWindowHint": "Use the context-window token limit from the model documentation, not the maximum output tokens. For example, enter 128000 for 128K unless the provider specifies 131072. Set 0 to disable automatic compaction for this provider.",
"settings.modelContextWindow": "Context (tokens)",
"settings.modelContextWindowPlaceholder": "Inherit / e.g. 128000",
"settings.modelContextWindowHint": "Leave blank to inherit the provider-level context window. This is not the maximum output-token setting.",
"settings.modelContextWindowAria": "Context window in tokens for {model}",
"settings.modelContextWindowGuide": "Use the context-window limit from the model documentation, not maximum output tokens. 128K usually means 128000; use 131072 when the provider documents that exact value.",
"settings.modelContextWindowSmallWarning": "This value is unusually small and may cause frequent compaction and lower cache hit rates. Confirm the model limit.",
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
+9 -3
View File
@@ -1053,9 +1053,15 @@ export const zhTW: Record<DictKey, string> = {
"settings.providerBalanceUrl": "餘額查詢 URL(可選)",
"settings.balanceUrlPlaceholder": "餘額查詢介面(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可選。填了就會查詢並在狀態欄顯示錢包餘額。",
"settings.providerContextWindow": "上下文視窗",
"settings.contextWindowPlaceholder": "token 數(0 = 模型服務預設值)",
"settings.contextWindowHint": "該模型服務在上下文中保留的最大 token 數。填 0 表示使用模型服務預設值。",
"settings.providerContextWindow": "上下文視窗(tokens)",
"settings.contextWindowPlaceholder": "token 數(0 = 停用自動壓縮)",
"settings.contextWindowHint": "填寫模型文件標示的上下文視窗 token 上限,不是最大輸出 token。文件寫 128K 時通常填 128000;若服務商明確標示 131072,則按 131072 填寫。填 0 會停用該模型服務的自動上下文壓縮。",
"settings.modelContextWindow": "上下文(tokens)",
"settings.modelContextWindowPlaceholder": "繼承 / 例 128000",
"settings.modelContextWindowHint": "留空時繼承模型服務級上下文視窗;這不是最大輸出 token 設定。",
"settings.modelContextWindowAria": "{model} 的上下文視窗 token 數",
"settings.modelContextWindowGuide": "填寫模型文件標示的上下文視窗,不是最大輸出 token。128K 通常填 128000;若服務商明確標示 131072,請按官方值填寫。",
"settings.modelContextWindowSmallWarning": "該值明顯偏小,可能導致頻繁壓縮並降低快取命中率,請確認模型規格。",
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端點自動判斷)",
"settings.providerReasoningProtocolHint": "用於 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 參數,openai 使用普通 reasoning_effort,none 表示停用。",
+9 -3
View File
@@ -1407,9 +1407,15 @@ export const zh: Record<DictKey, string> = {
"settings.providerBalanceUrl": "余额查询 URL(可选)",
"settings.balanceUrlPlaceholder": "余额查询接口(如 https://api.deepseek.com/user/balance)",
"settings.balanceUrlHint": "可选。填了就会查询并在状态栏显示钱包余额。",
"settings.providerContextWindow": "上下文窗口",
"settings.contextWindowPlaceholder": "token 数(0 = 模型服务默认值)",
"settings.contextWindowHint": "该模型服务在上下文中保留的最大 token 数。填 0 表示使用模型服务默认值。",
"settings.providerContextWindow": "上下文窗口(tokens)",
"settings.contextWindowPlaceholder": "token 数(0 = 禁用自动压缩)",
"settings.contextWindowHint": "填写模型文档标注的上下文窗口 token 上限,不是最大输出 token。文档写 128K 时通常填 128000;若服务商明确标注 131072,则按 131072 填写。填 0 会禁用该模型服务的自动上下文压缩。",
"settings.modelContextWindow": "上下文(tokens)",
"settings.modelContextWindowPlaceholder": "继承 / 例 128000",
"settings.modelContextWindowHint": "留空时继承模型服务级上下文窗口;这不是最大输出 token 设置。",
"settings.modelContextWindowAria": "{model} 的上下文窗口 token 数",
"settings.modelContextWindowGuide": "填写模型文档标注的上下文窗口,不是最大输出 token。128K 通常填 128000;若服务商明确标注 131072,请按官方值填写。",
"settings.modelContextWindowSmallWarning": "该值明显偏小,可能导致频繁压缩并降低缓存命中率,请确认模型规格。",
"settings.providerReasoningProtocol": "reasoning protocol",
"settings.providerReasoningProtocolAuto": "auto(按模型和端点自动判断)",
"settings.providerReasoningProtocolHint": "用于 OpenAI-compatible 推理模型。deepseek 使用 DeepSeek reasoning 参数,openai 使用普通 reasoning_effort,none 表示禁用。",
+34
View File
@@ -15187,6 +15187,11 @@ body > .mermaid-diagram--fullscreen {
.provider-model-draft__search {
min-height: 34px;
}
.provider-model-draft__context-guide {
color: var(--fg-muted);
font-size: var(--font-control-small);
line-height: 1.45;
}
.provider-model-draft__list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
@@ -15217,6 +15222,35 @@ body > .mermaid-diagram--fullscreen {
align-items: center;
gap: 7px;
}
.provider-model-draft__context {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(88px, 1fr);
align-items: center;
gap: 7px;
color: var(--fg-muted);
font-family: var(--font-sans);
font-size: var(--font-control-small);
}
.provider-model-draft__context-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.provider-model-draft__context-input {
width: 100%;
min-height: 28px;
padding: 3px 7px;
font-family: var(--mono);
font-size: var(--text-2xs);
}
.provider-model-draft__context-warning {
color: var(--warn);
font-family: var(--font-sans);
font-size: var(--text-2xs);
line-height: 1.35;
}
.provider-model-draft__vision {
color: var(--fg-muted);
font-family: var(--font-sans);
+4 -1
View File
@@ -92,6 +92,7 @@ type ProviderModelOverrideView struct {
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
Vision *bool `json:"vision"`
ContextWindow int `json:"contextWindow,omitempty"`
}
type PermissionsView struct {
@@ -356,6 +357,7 @@ func providerModelOverridesForView(overrides map[string]config.ProviderModelOver
SupportedEfforts: nonNil(ov.SupportedEfforts),
DefaultEffort: ov.DefaultEffort,
Vision: ov.Vision,
ContextWindow: ov.ContextWindow,
})
}
return out
@@ -380,8 +382,9 @@ func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models
SupportedEfforts: nonNil(item.SupportedEfforts),
DefaultEffort: strings.TrimSpace(item.DefaultEffort),
Vision: item.Vision,
ContextWindow: max(item.ContextWindow, 0),
}
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil {
if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil && ov.ContextWindow == 0 {
continue
}
out[model] = ov
+23
View File
@@ -84,6 +84,29 @@ func TestProviderViewFromEntry_FiltersNonChatModels(t *testing.T) {
}
}
func TestProviderModelOverridesPreservePerModelContextWindow(t *testing.T) {
overrides := map[string]config.ProviderModelOverride{
"short-model": {ContextWindow: 32_768},
"long-model": {ContextWindow: 1_000_000},
"removed": {ContextWindow: 8_192},
}
models := []string{"short-model", "long-model"}
view := providerModelOverridesForView(overrides, models)
if len(view) != 2 || view[0].Model != "long-model" || view[0].ContextWindow != 1_000_000 || view[1].Model != "short-model" || view[1].ContextWindow != 32_768 {
t.Fatalf("provider model override view = %+v", view)
}
view[0].ContextWindow = -1
saved := providerModelOverridesForSave(view, models)
if _, ok := saved["long-model"]; ok {
t.Fatalf("non-positive context-only override should be removed: %+v", saved)
}
if got := saved["short-model"].ContextWindow; got != 32_768 {
t.Fatalf("saved short-model context window = %d, want 32768", got)
}
}
func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
p := config.ProviderEntry{
Name: "custom",
+10 -1
View File
@@ -271,7 +271,16 @@ For Anthropic-compatible services, such as some coding-plan endpoints, choose
| Model capability mode | Which reasoning request protocol Reasonix should use for this provider. | Keep **Auto-detect** unless the gateway is misdetected or the model docs require a specific reasoning format. |
| Thinking override | Provider-specific override for `thinking.type`. | Keep **Auto** unless the backend documents `enabled`, `disabled`, or `adaptive`. Unsupported values can make some OpenAI-compatible gateways reject the request. |
| Balance URL | Optional endpoint for wallet/balance lookup. | Set it when the provider exposes a balance endpoint and you want the desktop status bar to show it. |
| Context window | The maximum number of tokens this provider keeps in context. `0` means provider default. | Set it when the model's real context size differs from Reasonix's default or built-in metadata. |
| Context window | The provider-wide token budget Reasonix uses for automatic context cleanup. `0` disables automatic compaction. | Set it to the provider's model context limit; use a per-model override below when selected models differ. |
Each selected model also has an optional **Context window** input. Leave it blank
to inherit the provider-wide value, or enter a positive token count to override
that value for this model. This avoids premature compaction for long-context
models and provider errors for shorter-context models sharing the same endpoint.
Use the context-window limit from the model documentation, not the maximum output
tokens. For example, 128K commonly means `128000`; if the provider documents
`131072`, use that exact value. Values below 16384 show a non-blocking warning
because they can trigger frequent compaction and reduce cache hit rates.
Model capability mode options:
+8 -1
View File
@@ -238,7 +238,14 @@ Anthropic-compatible 网关需要的 Bearer 认证、Ollama Cloud max-effort 支
| 模型能力模式 | 指定 Reasonix 对该 provider 使用哪种 reasoning 请求协议。 | 默认用“自动识别”。只有网关被误判,或模型文档要求特定 reasoning 格式时再切换。 |
| Thinking 覆盖 | provider 专用的 `thinking.type` 覆盖项。 | 默认用 Auto。只有后端文档明确支持 `enabled`、`disabled` 或 `adaptive` 时再手动指定;不支持的值可能让中转站拒绝请求。 |
| 余额查询 URL | 可选的钱包余额查询接口。 | 服务商提供余额接口,且希望桌面端状态栏显示余额时填写。 |
| 上下文窗口 | 该 provider 可保留的最大上下文 token 数。`0` 表示使用模型服务默认值。 | 模型实际上下文大小和 Reasonix 默认值或内置元数据不一致时填写。 |
| 上下文窗口 | Reasonix 用于自动清理上下文的 provider 级 token 预算。`0` 表示禁用自动 compaction。 | 按该 provider 的模型上下文上限填写;所选模型规格不同时使用下方的逐模型覆盖。 |
每个已选模型还提供一个可选的 **上下文窗口** 输入框。留空时继承 provider
级设置;填写正整数时只覆盖该模型。这样,同一端点下的长上下文模型不会过早
compaction,短上下文模型也不会在 Reasonix 清理前被服务端拒绝。
这里应填写模型文档标注的上下文窗口,而不是最大输出 token。例如 128K 通常填
`128000`;如果服务商明确标注 `131072`,则按该精确值填写。小于 16384 时界面会
显示非阻断警告,因为过小的窗口可能导致频繁 compaction 并降低缓存命中率。
模型能力模式选项:
+7 -3
View File
@@ -91,9 +91,9 @@ type Config struct {
one vendor expose several models without re-declaring the endpoint/key. A
**model reference** (`default_model`, the `--model` flag, the desktop switcher)
resolves via `Config.ResolveModel`, which accepts a provider name (→ its default
model), a bare model name, or an explicit `provider/model`. `context_window` /
`price` are per-provider, so models that need distinct values stay separate
single-`model` entries.
model), a bare model name, or an explicit `provider/model`. `context_window` is
the provider-wide fallback; `model_overrides.<model>.context_window` can replace
it for one model. Per-model `prices` use model IDs as keys.
- Streaming tool-call deltas are accumulated by index inside the provider; only
complete `ToolCall`s are emitted.
@@ -208,6 +208,9 @@ Long tasks eventually fill the model's context window. Reasonix manages this wit
pruning still leaves the prompt above the threshold does summary compaction
run. At `agent.compact_force_ratio` (default `0.9`), the existing forced fold
may proceed even when the fold economics would normally skip it.
- A positive `model_overrides.<model>.context_window` replaces the provider-wide
value after model resolution. Missing or zero model overrides inherit the
provider value; provider-level `context_window = 0` disables compaction.
- Tool-result snip/prune never removes messages, so assistant `tool_calls` and
tool results stay paired. `KeepErrors` preserves error/blocked tool outputs,
and the recent tail is not rewritten. Snipped results can later be upgraded to
@@ -604,6 +607,7 @@ models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash" # optional; defaults to models[0]
api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000 # tokens; harness compacts older history near this limit (0 disables)
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } }
# A single-model entry still works for custom OpenAI-compatible endpoints.
+7
View File
@@ -1168,6 +1168,10 @@ type ProviderModelOverride struct {
SupportedEfforts []string `toml:"supported_efforts"`
DefaultEffort string `toml:"default_effort"`
Vision *bool `toml:"vision"`
// ContextWindow overrides the provider-wide context budget for this model.
// Zero inherits ProviderEntry.ContextWindow so existing configurations keep
// their current compaction behavior.
ContextWindow int `toml:"context_window"`
}
// ModelList returns the models this provider exposes: the explicit `models` list,
@@ -1313,6 +1317,9 @@ func (e *ProviderEntry) applyModelOverride() {
if ov.Vision != nil {
e.visionOverride = ov.Vision
}
if ov.ContextWindow > 0 {
e.ContextWindow = ov.ContextWindow
}
}
func (e *ProviderEntry) modelOverrideForModel(model string) (ProviderModelOverride, bool) {
+8
View File
@@ -692,6 +692,7 @@ func TestResolveModelAppliesModelOverrides(t *testing.T) {
BaseURL: "https://proxy.example.com/v1",
Models: []string{"deepseek-v4-flash", "plain-chat"},
Default: "plain-chat",
ContextWindow: 131_072,
ReasoningProtocol: ReasoningProtocolOpenAI,
SupportedEfforts: []string{"low", "medium", "high"},
ModelOverrides: map[string]ProviderModelOverride{
@@ -700,6 +701,7 @@ func TestResolveModelAppliesModelOverrides(t *testing.T) {
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
Vision: &visionOff,
ContextWindow: 1_000_000,
},
},
}}}
@@ -718,6 +720,9 @@ func TestResolveModelAppliesModelOverrides(t *testing.T) {
if EffectiveVision(deepseek) {
t.Fatalf("vision override false should disable image input")
}
if deepseek.ContextWindow != 1_000_000 {
t.Fatalf("deepseek context window = %d, want per-model override", deepseek.ContextWindow)
}
plain, ok := c.ResolveModel("gateway/plain-chat")
if !ok {
@@ -726,6 +731,9 @@ func TestResolveModelAppliesModelOverrides(t *testing.T) {
if protocol := ReasoningProtocolForEntry(plain); protocol != ReasoningProtocolOpenAI {
t.Fatalf("plain protocol = %q, want provider-level openai", protocol)
}
if plain.ContextWindow != 131_072 {
t.Fatalf("plain context window = %d, want inherited provider value", plain.ContextWindow)
}
}
func TestRemoveProvider(t *testing.T) {
+4 -1
View File
@@ -451,10 +451,13 @@ func normalizedModelOverrides(overrides map[string]ProviderModelOverride) map[st
ov.ReasoningProtocol = normalizeReasoningProtocol(ov.ReasoningProtocol)
ov.SupportedEfforts = normalizedEffortLevels(ov.SupportedEfforts)
ov.DefaultEffort = normalizeEffortLevel(ov.DefaultEffort)
if ov.ContextWindow < 0 {
ov.ContextWindow = 0
}
if ov.DefaultEffort != "" && !containsString(ov.SupportedEfforts, ov.DefaultEffort) {
ov.DefaultEffort = ""
}
if ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil {
if ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil && ov.ContextWindow == 0 {
continue
}
out[model] = ov
+5 -2
View File
@@ -367,7 +367,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
fmt.Fprintf(&b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort)
}
if len(p.ModelOverrides) > 0 {
fmt.Fprintf(&b, "model_overrides = %s # per-model reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides))
fmt.Fprintf(&b, "model_overrides = %s # per-model context/reasoning/vision overrides for mixed gateways\n", renderModelOverrides(p.ModelOverrides))
}
if p.NoProxy {
b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
@@ -1522,11 +1522,14 @@ func renderModelOverride(ov ProviderModelOverride) string {
if ov.Vision != nil {
parts = append(parts, fmt.Sprintf("vision = %t", *ov.Vision))
}
if ov.ContextWindow > 0 {
parts = append(parts, fmt.Sprintf("context_window = %d", ov.ContextWindow))
}
return "{ " + strings.Join(parts, ", ") + " }"
}
func modelOverrideEmpty(ov ProviderModelOverride) bool {
return ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil
return ov.ReasoningProtocol == "" && len(ov.SupportedEfforts) == 0 && ov.DefaultEffort == "" && ov.Vision == nil && ov.ContextWindow <= 0
}
func hasPositiveIntMap(m map[string]int) bool {
+21 -2
View File
@@ -997,6 +997,7 @@ func TestRenderTOMLRoundTripsProviderHeadersAndModelOverrides(t *testing.T) {
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "high",
Vision: boolPtr(false),
ContextWindow: 262_144,
},
},
}}
@@ -1011,7 +1012,7 @@ func TestRenderTOMLRoundTripsProviderHeadersAndModelOverrides(t *testing.T) {
if !strings.Contains(rendered, `auth_header = true`) {
t.Fatalf("rendered TOML missing auth_header:\n%s", rendered)
}
if !strings.Contains(rendered, `model_overrides`) || !strings.Contains(rendered, `reasoning_protocol = "deepseek"`) {
if !strings.Contains(rendered, `model_overrides`) || !strings.Contains(rendered, `reasoning_protocol = "deepseek"`) || !strings.Contains(rendered, `context_window = 262144`) {
t.Fatalf("rendered TOML missing model overrides:\n%s", rendered)
}
@@ -1037,9 +1038,27 @@ func TestRenderTOMLRoundTripsProviderHeadersAndModelOverrides(t *testing.T) {
t.Fatalf("extra_body metadata after round trip = %+v", p.ExtraBody["metadata"])
}
ov := p.ModelOverrides["deepseek-v4-flash"]
if ov.ReasoningProtocol != ReasoningProtocolDeepSeek || !reflect.DeepEqual(ov.SupportedEfforts, []string{"high", "max"}) || ov.DefaultEffort != "high" || ov.Vision == nil || *ov.Vision {
if ov.ReasoningProtocol != ReasoningProtocolDeepSeek || !reflect.DeepEqual(ov.SupportedEfforts, []string{"high", "max"}) || ov.DefaultEffort != "high" || ov.Vision == nil || *ov.Vision || ov.ContextWindow != 262_144 {
t.Fatalf("model override after round trip = %+v", ov)
}
// Older releases do not know context_window inside model_overrides, but their
// TOML decoder must still accept a config written by this release.
type legacyModelOverride struct {
ReasoningProtocol string `toml:"reasoning_protocol"`
SupportedEfforts []string `toml:"supported_efforts"`
DefaultEffort string `toml:"default_effort"`
Vision *bool `toml:"vision"`
}
type legacyProvider struct {
ModelOverrides map[string]legacyModelOverride `toml:"model_overrides"`
}
var legacy struct {
Providers []legacyProvider `toml:"providers"`
}
if _, err := toml.Decode(rendered, &legacy); err != nil {
t.Fatalf("legacy config shape cannot read per-model context window: %v", err)
}
}
func TestRenderStringMapQuotesNonBareTOMLKeys(t *testing.T) {
+4 -1
View File
@@ -62,6 +62,8 @@ models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash" # optional; defaults to the first of `models`
api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000
# Optional per-model context budgets. Omitted models inherit context_window.
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } }
# Official DeepSeek providers seed RMB prices by default.
# Set prices explicitly only when you want to pin a currency/rate.
# prices = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } } # per 1M tokens
@@ -69,7 +71,8 @@ context_window = 1000000
effort = "high"
# A single-model provider still works (use `model = "..."`) — pick this form when
# a model needs its own base_url / context_window / price.
# a model needs its own base_url. Use model_overrides when models share an endpoint
# but need distinct context windows or capability settings.
# Custom /effort levels for a provider. When supported_efforts is set, the
# /effort command exposes these levels; default_effort is what "/effort auto"