mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 08:04:50 -10:00
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:
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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 表示停用。",
|
||||
|
||||
@@ -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 表示禁用。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user