From f96b525f5147c7ae28b0b02d6b9c42dd1d7ea6d9 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 10 Oct 2026 19:06:20 +0800 Subject: [PATCH 1/4] feat(core): use each model's real context window Every generation run treated the model as having a 200k-token window: buildPiModel hard-coded contextWindow: 200000, and the context pack read a contextWindow that ModelRef never carries, so its history budget was the same for every model. Core now resolves the window with resolveContextWindow: the provider's configured value, then pi-ai's catalog entry for the provider and model, then the smallest catalog window for the same model id (relays usually serve upstream ids), then the 200k default. The agent model and the context pack both use it. Providers gain an optional contextWindow in config, editable next to Reasoning depth in Settings -> Models, for local runtimes and relays the catalog does not know. --- .changeset/provider-context-window.md | 8 ++ apps/desktop/src/main/ipc/generate.test.ts | 13 --- apps/desktop/src/main/ipc/generate.ts | 18 ++-- .../ipc/generate.workspace-rename.test.ts | 47 ++++++++++ apps/desktop/src/main/onboarding-ipc.test.ts | 47 ++++++++++ .../src/main/onboarding/provider-parsers.ts | 16 ++++ .../src/main/onboarding/providers-crud.ts | 5 ++ .../src/main/provider-settings.test.ts | 18 ++++ apps/desktop/src/main/provider-settings.ts | 5 ++ apps/desktop/src/preload/index.ts | 5 ++ .../src/components/settings/primitives.tsx | 87 +++++++++++++++++++ packages/core/src/agent.test.ts | 15 ++++ packages/core/src/agent.ts | 5 +- packages/core/src/context-window.test.ts | 39 +++++++++ packages/core/src/context-window.ts | 34 ++++++++ packages/core/src/index.ts | 7 ++ packages/i18n/src/locales/en.json | 9 ++ packages/i18n/src/locales/es.json | 9 ++ packages/i18n/src/locales/pt-BR.json | 9 ++ packages/i18n/src/locales/zh-CN.json | 9 ++ packages/shared/src/config.test.ts | 25 ++++++ packages/shared/src/config.ts | 7 ++ 22 files changed, 413 insertions(+), 24 deletions(-) create mode 100644 .changeset/provider-context-window.md create mode 100644 packages/core/src/context-window.test.ts create mode 100644 packages/core/src/context-window.ts diff --git a/.changeset/provider-context-window.md b/.changeset/provider-context-window.md new file mode 100644 index 000000000..f5ebe4bf5 --- /dev/null +++ b/.changeset/provider-context-window.md @@ -0,0 +1,8 @@ +--- +"@open-codesign/shared": patch +"@open-codesign/core": patch +"@open-codesign/desktop": patch +"@open-codesign/i18n": patch +--- + +Use each model's real context window. A generation run takes the window from a new per-provider "Context window" setting in Settings → Models, then from pi-ai's model catalog, and falls back to 200k tokens only when neither knows the model. Set it for local runtimes such as Ollama or LM Studio and for relays the catalog does not know. The chat history sent with each run follows the resolved window. diff --git a/apps/desktop/src/main/ipc/generate.test.ts b/apps/desktop/src/main/ipc/generate.test.ts index d13ff2bf0..80966d6df 100644 --- a/apps/desktop/src/main/ipc/generate.test.ts +++ b/apps/desktop/src/main/ipc/generate.test.ts @@ -7,23 +7,10 @@ vi.mock('../electron-runtime', () => ({ import { buildRunPreferenceAskInput, - contextWindowForContextPack, dropCurrentPromptEchoFromChatRows, shouldRunUserMemoryCandidateCapture, } from './generate'; -describe('generate IPC context budget helpers', () => { - it('uses active model contextWindow when the model object exposes it', () => { - expect( - contextWindowForContextPack({ provider: 'p', modelId: 'm', contextWindow: 64_000 }), - ).toBe(64_000); - }); - - it('falls back to the harness default when model metadata lacks contextWindow', () => { - expect(contextWindowForContextPack({ provider: 'p', modelId: 'm' })).toBe(200_000); - }); -}); - describe('generate IPC memory preference helpers', () => { it('captures user memory candidates only when the memory system and user auto-update are enabled', () => { expect( diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 97922d709..8a8287a78 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -16,6 +16,7 @@ import { inspectWorkspaceFiles, loadDesignSkills, loadFrameTemplates, + resolveContextWindow, routeRunPreferences, updateDesignSessionBrief, } from '@open-codesign/core'; @@ -99,15 +100,6 @@ import { finalAssistantTextForTurn } from './assistant-text'; import { allocateAssetPath, createRuntimeTextEditorFs, resolveLocalAssetRefs } from './runtime-fs'; import { summarizeToolResultForStream, toolExecutionStatusForStream } from './tool-log'; -const DEFAULT_CONTEXT_WINDOW_FOR_CONTEXT_PACK = 200_000; - -export function contextWindowForContextPack(model: unknown): number { - const value = (model as { contextWindow?: unknown } | null)?.contextWindow; - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? value - : DEFAULT_CONTEXT_WINDOW_FOR_CONTEXT_PACK; -} - /** * Whether outbound pi-ai requests for the given provider should bypass TLS * certificate verification. True only for non-built-in providers whose @@ -1324,7 +1316,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe brief: existingBrief, runPreferences, resourceState, - modelContextWindow: contextWindowForContextPack(active.model), + modelContextWindow: resolveContextWindow(active.model, active.contextWindow), workspaceState: { sourcePath: payload.previousSource ? 'App.jsx' : null, hasSource: Boolean(payload.previousSource?.trim()), @@ -1362,6 +1354,9 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe ...(active.reasoningLevel !== undefined ? { reasoningLevel: active.reasoningLevel } : {}), + ...(active.contextWindow !== undefined + ? { contextWindow: active.contextWindow } + : {}), ...(allowKeyless ? { allowKeyless: true } : {}), signal: controller.signal, logger: coreLogger, @@ -1721,6 +1716,9 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe ...(active.reasoningLevel !== undefined ? { reasoningLevel: active.reasoningLevel } : {}), + ...(active.contextWindow !== undefined + ? { contextWindow: active.contextWindow } + : {}), ...(allowKeyless ? { allowKeyless: true } : {}), signal: controller.signal, logger: coreLogger, diff --git a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts index 8da2f2da2..3a14a177c 100644 --- a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts +++ b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts @@ -95,6 +95,7 @@ vi.mock('@open-codesign/core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + buildDesignContextPack: vi.fn(actual.buildDesignContextPack), generateViaAgent: vi.fn(async (input: unknown) => { coreCalls.generateInputs.push(input); generateControl.markStarted(); @@ -195,6 +196,7 @@ vi.mock('../ask-ipc', () => ({ import { type AskInput, type AskResult, + buildDesignContextPack, generateViaAgent, makeAskTool, makeTextEditorTool, @@ -205,6 +207,7 @@ import { requestAsk } from '../ask-ipc'; import { makeRuntimeVerifier } from '../done-verify'; import { runPreview } from '../preview-runtime'; import { preparePromptContext } from '../prompt-context'; +import { resolveActiveModel } from '../provider-settings'; import { appendSessionActiveMessage, appendSessionChatMessage, @@ -842,6 +845,50 @@ describe('generate IPC workspace rename coordination', () => { await generatePromise; }); + it("budgets context with the provider's configured context window", async () => { + vi.mocked(resolveActiveModel).mockReturnValueOnce({ + model: { provider: 'mock-provider', modelId: 'mock-model' }, + baseUrl: null, + wire: 'openai-chat', + httpHeaders: undefined, + queryParams: undefined, + reasoningLevel: undefined, + contextWindow: 32_768, + allowKeyless: false, + overridden: false, + }); + const db = initTestDb(); + const design = createDesign(db, 'Untitled design 1'); + const workspace = path.join(defaultWorkspaceRoot, 'Untitled-design-1'); + await mkdir(workspace, { recursive: true }); + updateDesignWorkspace(db, design.id, workspace); + + registerSnapshotsIpc(db); + registerGenerateIpc({ db, getMainWindow: () => null }); + + const generate = getHandler('codesign:v1:generate'); + const generatePromise = Promise.resolve( + generate(null, { + schemaVersion: 1, + prompt: 'Make a pricing page', + history: [], + model: { provider: 'mock-provider', modelId: 'mock-model' }, + generationId: 'gen-context-window', + designId: design.id, + }), + ); + pendingFixtureGeneration = generatePromise; + + await generateControl.started; + expect(vi.mocked(buildDesignContextPack)).toHaveBeenCalledWith( + expect.objectContaining({ modelContextWindow: 32_768 }), + ); + expect(coreCalls.generateInputs[0]).toMatchObject({ contextWindow: 32_768 }); + + generateControl.release(); + await generatePromise; + }); + it('does not ask for page source when a reference image is attached', async () => { coreCalls.routeResults.push({ preferences: { diff --git a/apps/desktop/src/main/onboarding-ipc.test.ts b/apps/desktop/src/main/onboarding-ipc.test.ts index 2aaae9474..d12168cd0 100644 --- a/apps/desktop/src/main/onboarding-ipc.test.ts +++ b/apps/desktop/src/main/onboarding-ipc.test.ts @@ -570,6 +570,53 @@ describe('config:v1 provider mutations — fail-fast key handling', () => { expect(writeConfig).not.toHaveBeenCalled(); }); + it('stores, clears, and validates a provider context window', async () => { + const { hydrateConfig } = await import('@open-codesign/shared'); + const { readConfig, writeConfig } = await import('./config'); + const { loadConfigOnBoot, registerOnboardingIpc } = await import('./onboarding-ipc'); + vi.mocked(writeConfig).mockClear(); + vi.mocked(readConfig).mockResolvedValueOnce( + hydrateConfig({ + version: 3, + activeProvider: 'local-runtime', + activeModel: 'qwen3:32b', + secrets: { + 'local-runtime': { ciphertext: 'enc:sk-test', mask: 'sk-***test' }, + }, + providers: { + 'local-runtime': { + id: 'local-runtime', + name: 'Local Runtime', + builtin: false, + wire: 'openai-chat', + baseUrl: 'http://localhost:1234/v1', + defaultModel: 'qwen3:32b', + }, + }, + }), + ); + await loadConfigOnBoot(); + registerOnboardingIpc(); + const handler = handlers.get('config:v1:update-provider'); + if (!handler) throw new Error('handler missing'); + + await handler({} as never, { id: 'local-runtime', contextWindow: 32_768 }); + expect( + vi.mocked(writeConfig).mock.calls.at(-1)?.[0].providers['local-runtime']?.contextWindow, + ).toBe(32_768); + + await handler({} as never, { id: 'local-runtime', contextWindow: null }); + expect( + vi.mocked(writeConfig).mock.calls.at(-1)?.[0].providers['local-runtime']?.contextWindow, + ).toBeUndefined(); + + for (const contextWindow of [0, 4096.5, '32768']) { + await expect(handler({} as never, { id: 'local-runtime', contextWindow })).rejects.toThrow( + /contextWindow must be a positive integer or null/, + ); + } + }); + it('rejects invalid provider updates instead of silently keeping old values', async () => { const { registerOnboardingIpc } = await import('./onboarding-ipc'); registerOnboardingIpc(); diff --git a/apps/desktop/src/main/onboarding/provider-parsers.ts b/apps/desktop/src/main/onboarding/provider-parsers.ts index 827cf8020..286790267 100644 --- a/apps/desktop/src/main/onboarding/provider-parsers.ts +++ b/apps/desktop/src/main/onboarding/provider-parsers.ts @@ -54,6 +54,9 @@ export interface UpdateProviderInput { queryParams?: Record; wire?: WireApi; reasoningLevel?: ReasoningLevel | null; + /** Context window in tokens. `null` clears it so core resolves the window + * from pi-ai's catalog; `undefined` leaves the existing value alone. */ + contextWindow?: number | null; /** When present AND non-empty, re-encrypt and replace the stored secret. * Empty string means "clear stored secret" for providers that became * keyless (e.g. switched to local Ollama). `undefined` means "leave alone". */ @@ -88,6 +91,7 @@ const UPDATE_PROVIDER_FIELDS = [ 'queryParams', 'wire', 'reasoningLevel', + 'contextWindow', 'apiKey', 'requiresApiKey', 'tlsRejectUnauthorized', @@ -396,6 +400,18 @@ export function parseUpdateProviderPayload(raw: unknown): UpdateProviderInput { } out.reasoningLevel = parsed.data; } + if (r['contextWindow'] === null) { + out.contextWindow = null; + } else if (r['contextWindow'] !== undefined) { + const value = r['contextWindow']; + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new CodesignError( + 'contextWindow must be a positive integer or null', + ERROR_CODES.IPC_BAD_INPUT, + ); + } + out.contextWindow = value; + } if (r['apiKey'] !== undefined) { if (typeof r['apiKey'] !== 'string') { throw new CodesignError('apiKey must be a string', ERROR_CODES.IPC_BAD_INPUT); diff --git a/apps/desktop/src/main/onboarding/providers-crud.ts b/apps/desktop/src/main/onboarding/providers-crud.ts index 3e8dc0bff..018d6d6fa 100644 --- a/apps/desktop/src/main/onboarding/providers-crud.ts +++ b/apps/desktop/src/main/onboarding/providers-crud.ts @@ -309,6 +309,11 @@ export async function runUpdateProvider(input: UpdateProviderInput): Promise { expect(result.reasoningLevel).toBe('off'); }); + it('threads through the per-provider context window for the canonical active', () => { + const cfg = makeCfg({ + provider: 'ollama', + modelPrimary: 'qwen3:32b', + secrets: {}, + providers: { + ollama: { + ...BUILTIN_PROVIDERS.ollama, + defaultModel: 'qwen3:32b', + contextWindow: 32_768, + }, + }, + }); + const result = resolveActiveModel(cfg, { provider: 'ollama', modelId: 'qwen3:32b' }); + + expect(result.contextWindow).toBe(32_768); + }); + it('ignores stale hint baseUrl entry and returns active provider baseUrl on override', () => { const cfg = makeCfg({ provider: 'openrouter', diff --git a/apps/desktop/src/main/provider-settings.ts b/apps/desktop/src/main/provider-settings.ts index def8d82cc..8c85e2cd6 100644 --- a/apps/desktop/src/main/provider-settings.ts +++ b/apps/desktop/src/main/provider-settings.ts @@ -30,6 +30,8 @@ export interface ProviderRow { hasKey: boolean; requiresApiKey?: boolean; reasoningLevel?: ReasoningLevel; + /** Configured context window in tokens; absent when core resolves it. */ + contextWindow?: number; /** Per-provider TLS verification opt-out (#229). Only surfaced for * custom / imported providers; the runtime force-ignores it on built-ins. */ tlsRejectUnauthorized?: boolean; @@ -166,6 +168,7 @@ export function toProviderRows( hasKey: ref !== undefined || isKeylessProviderAllowed(provider, entry), requiresApiKey: !isKeylessProviderAllowed(provider, entry), ...(entry?.reasoningLevel !== undefined ? { reasoningLevel: entry.reasoningLevel } : {}), + ...(entry?.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}), ...(entry?.tlsRejectUnauthorized === true ? { tlsRejectUnauthorized: true } : {}), ...(rowError !== undefined ? { error: rowError } : {}), }); @@ -223,6 +226,7 @@ export interface ActiveModelResolution { httpHeaders: Record | undefined; queryParams: Record | undefined; reasoningLevel: ReasoningLevel | undefined; + contextWindow: number | undefined; allowKeyless: boolean; /** True when the renderer-supplied hint provider didn't match the canonical active. */ overridden: boolean; @@ -256,6 +260,7 @@ export function resolveActiveModel( httpHeaders: entry.httpHeaders, queryParams: entry.queryParams, reasoningLevel: entry.reasoningLevel, + contextWindow: entry.contextWindow, allowKeyless, overridden, }; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a32e35623..83e841520 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -214,6 +214,8 @@ export interface ProviderRow { hasKey: boolean; requiresApiKey?: boolean; reasoningLevel?: ReasoningLevel; + /** Configured context window in tokens; absent when the model catalog decides. */ + contextWindow?: number; /** Per-provider opt-in to skip TLS verification on outbound HTTPS. * Built-in providers force-ignore this flag at runtime; only surfaced * for custom/imported providers. See #229. */ @@ -617,6 +619,9 @@ const api = { /** `null` explicitly clears the override and falls back to the model * default; a level string sets it; omit to leave untouched. */ reasoningLevel?: ReasoningLevel | null; + /** Context window in tokens; `null` clears it so the model catalog + * decides; omit to leave untouched. */ + contextWindow?: number | null; /** Non-empty string rotates the stored secret; empty string clears it * (keyless providers); omit to leave the existing secret untouched. */ apiKey?: string; diff --git a/apps/desktop/src/renderer/src/components/settings/primitives.tsx b/apps/desktop/src/renderer/src/components/settings/primitives.tsx index 3b0a614a3..34aade95d 100644 --- a/apps/desktop/src/renderer/src/components/settings/primitives.tsx +++ b/apps/desktop/src/renderer/src/components/settings/primitives.tsx @@ -10,6 +10,7 @@ import { Loader2, MoreHorizontal, Pencil, + Ruler, Sliders, Trash2, } from 'lucide-react'; @@ -414,6 +415,13 @@ export function ProviderCard({ onUpdated={onRowChanged} /> )} + {!hasError && row.hasKey !== false && ( + + )} ); } @@ -622,6 +630,85 @@ export function ReasoningDepthSelector({ ); } +export function ContextWindowInput({ + provider, + value, + onUpdated, +}: { + provider: string; + value: number | undefined; + onUpdated: (row: ProviderRow) => void; +}) { + const t = useT(); + const pushToast = useCodesignStore((s) => s.pushToast); + const reportableErrorToast = useCodesignStore((s) => s.reportableErrorToast); + const saved = value === undefined ? '' : String(value); + const [draft, setDraft] = useState(saved); + const [saving, setSaving] = useState(false); + useEffect(() => { + setDraft(saved); + }, [saved]); + + async function save() { + if (!window.codesign?.config?.updateProvider) return; + const trimmed = draft.trim(); + if (trimmed === saved) return; + const next = trimmed === '' ? null : Number(trimmed); + if (next !== null && (!Number.isInteger(next) || next <= 0)) { + setDraft(saved); + pushToast({ variant: 'error', title: t('settings.providers.contextWindow.invalid') }); + return; + } + setSaving(true); + try { + await window.codesign.config.updateProvider({ id: provider, contextWindow: next }); + pushToast({ variant: 'success', title: t('settings.providers.toast.contextWindowSaved') }); + if (window.codesign?.settings?.listProviders) { + const rows = await window.codesign.settings.listProviders(); + const row = rows.find((r) => r.provider === provider); + if (row) onUpdated(row); + } + } catch (err) { + setDraft(saved); + reportableErrorToast({ + code: 'PROVIDER_CONTEXT_WINDOW_SAVE_FAILED', + scope: 'settings', + title: t('settings.providers.toast.contextWindowSaveFailed'), + description: cleanIpcError(err) || t('settings.common.unknownError'), + ...(err instanceof Error && err.stack !== undefined ? { stack: err.stack } : {}), + context: { provider }, + }); + } finally { + setSaving(false); + } + } + + return ( +
+ + {t('settings.providers.contextWindow.label')} + setDraft(e.target.value)} + onBlur={() => void save()} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + className="h-8 w-24 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] tabular-nums focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-50" + /> + {t('settings.providers.contextWindow.unit')} +
+ ); +} + export function ImportBanner({ label, onImport, diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 8b9958ea3..e3d8c9184 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -643,6 +643,21 @@ describe('generateViaAgent()', () => { }); }); + it('gives the agent model the configured context window', async () => { + scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; + await generateViaAgent({ + prompt: 'design a dashboard', + history: [], + model: { provider: 'custom-lmstudio', modelId: 'qwen3.6-35b-a3b' }, + apiKey: 'sk-test', + baseUrl: 'http://127.0.0.1:1234/v1', + wire: 'openai-chat', + contextWindow: 32_768, + }); + + expect(agentCalls[0]?.options.initialState?.model?.contextWindow).toBe(32_768); + }); + it("keeps pi-agent-core's default stream when no request timeout is configured", async () => { scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; await generateViaAgent({ diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index b7d96f555..1fc7e44fd 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -63,6 +63,7 @@ import { import type { TSchema } from '@sinclair/typebox'; import type { ActiveRunMessages } from './active-messages.js'; import { buildTransformContext } from './context-prune.js'; +import { resolveContextWindow } from './context-window.js'; import { remapProviderError } from './errors.js'; import type { GenerateInput, GenerateOutput } from './index.js'; import { reasoningForModel } from './index.js'; @@ -280,6 +281,7 @@ function buildPiModel( model: ModelRef, wire: WireApi | undefined, baseUrl: string | undefined, + contextWindow: number | undefined, httpHeaders?: Record | undefined, apiKey?: string, ): PiModel { @@ -314,7 +316,7 @@ function buildPiModel( reasoning: inferReasoning(wire, effectiveModelId, canonicalBase), input: supportsImageInput(wire, effectiveModelId) ? ['text', 'image'] : ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, + contextWindow: resolveContextWindow(model, contextWindow), maxTokens: 32000, }; const compat = openAIChatCompatForBaseUrl(wire, canonicalBase); @@ -972,6 +974,7 @@ async function generateViaAgentInternal( input.model, input.wire, input.baseUrl, + input.contextWindow, input.httpHeaders, initialApiKey, ); diff --git a/packages/core/src/context-window.test.ts b/packages/core/src/context-window.test.ts new file mode 100644 index 000000000..11f29345c --- /dev/null +++ b/packages/core/src/context-window.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +const catalog: Record> = { + openai: [{ id: 'gpt-4.1', contextWindow: 1_047_576 }], + 'github-copilot': [{ id: 'gpt-4.1', contextWindow: 128_000 }], +}; + +vi.mock('@mariozechner/pi-ai', () => ({ + getProviders: () => Object.keys(catalog), + getModels: (provider: string) => catalog[provider] ?? [], + getModel: (provider: string, modelId: string) => + catalog[provider]?.find((entry) => entry.id === modelId), +})); + +const { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } = await import('./context-window.js'); + +describe('resolveContextWindow', () => { + it('uses the configured window first', () => { + expect(resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, 32_768)).toBe(32_768); + }); + + it("uses the catalog entry for the model's own provider", () => { + expect(resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, undefined)).toBe( + 1_047_576, + ); + }); + + it('uses the smallest catalog window for the same model id on other providers', () => { + expect(resolveContextWindow({ provider: 'my-relay', modelId: 'gpt-4.1' }, undefined)).toBe( + 128_000, + ); + }); + + it('falls back to the default for models the catalog does not know', () => { + expect(resolveContextWindow({ provider: 'ollama', modelId: 'qwen3:32b' }, undefined)).toBe( + DEFAULT_CONTEXT_WINDOW, + ); + }); +}); diff --git a/packages/core/src/context-window.ts b/packages/core/src/context-window.ts new file mode 100644 index 000000000..3bb93c4a7 --- /dev/null +++ b/packages/core/src/context-window.ts @@ -0,0 +1,34 @@ +import { + type Api, + getModel, + getModels, + getProviders, + type KnownProvider, + type Model, +} from '@mariozechner/pi-ai'; +import type { ModelRef } from '@open-codesign/shared'; + +/** Used when neither the provider settings nor pi-ai's catalog know the model. */ +export const DEFAULT_CONTEXT_WINDOW = 200_000; + +/** + * Context window, in tokens, for a model: the provider's configured value, + * then pi-ai's catalog entry for this provider and model, then the smallest + * window among catalog entries with the same model id (relays and gateways + * usually serve upstream ids, and an overestimate overflows the request while + * an underestimate only trims context sooner), then DEFAULT_CONTEXT_WINDOW. + */ +export function resolveContextWindow(model: ModelRef, configured: number | undefined): number { + if (configured !== undefined) return configured; + const exact: Model | undefined = getModel( + model.provider as KnownProvider, + model.modelId as never, + ); + if (exact !== undefined) return exact.contextWindow; + const sameId = getProviders().flatMap((provider) => + getModels(provider).filter((entry) => entry.id === model.modelId), + ); + return sameId.length > 0 + ? Math.min(...sameId.map((entry) => entry.contextWindow)) + : DEFAULT_CONTEXT_WINDOW; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aec17b70e..2a575258c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,7 @@ export { ModelRegistry, SessionManager, } from './agent-session.js'; +export { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from './context-window.js'; export { type BuildDesignContextPackInput, buildDesignContextPack, @@ -225,6 +226,12 @@ export interface GenerateInput { * via `reasoningForModel`. */ reasoningLevel?: ReasoningLevel | undefined; + /** + * Context window, in tokens, configured for the provider + * (`ProviderEntry.contextWindow`). When absent, core resolves it from pi-ai's + * catalog via `resolveContextWindow`. + */ + contextWindow?: number | undefined; designSystem?: StoredDesignSystem | null | undefined; attachments?: AttachmentContext[] | undefined; referenceUrl?: ReferenceUrlContext | null | undefined; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 31e263609..e8a7aa5ec 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -692,6 +692,8 @@ "modelSaveFailed": "Failed to save model selection", "reasoningSaved": "Reasoning depth saved", "reasoningSaveFailed": "Failed to save reasoning depth", + "contextWindowSaved": "Context window saved", + "contextWindowSaveFailed": "Failed to save context window", "connectionOk": "Connection OK", "connectionFailed": "Connection failed" }, @@ -727,6 +729,13 @@ "medium": "Medium", "high": "High", "xhigh": "Extra high" + }, + "contextWindow": { + "label": "Context window", + "placeholder": "Auto", + "unit": "tokens", + "hint": "Leave empty to use the model catalog. Set it for local models and relays the catalog does not know.", + "invalid": "Enter a whole number of tokens greater than 0" } }, "appearance": { diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index d3d596ed1..8d7453cde 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -516,6 +516,8 @@ "modelSaveFailed": "Error al guardar la selección de modelo", "reasoningSaved": "Profundidad de razonamiento guardada", "reasoningSaveFailed": "Error al guardar la profundidad de razonamiento", + "contextWindowSaved": "Ventana de contexto guardada", + "contextWindowSaveFailed": "Error al guardar la ventana de contexto", "connectionOk": "Conexión OK", "connectionFailed": "Conexión fallida" }, @@ -550,6 +552,13 @@ "medium": "Medio", "high": "Alto", "xhigh": "Extra alto" + }, + "contextWindow": { + "label": "Ventana de contexto", + "placeholder": "Auto", + "unit": "tokens", + "hint": "Déjalo vacío para usar el catálogo de modelos. Indícalo para modelos locales y relays que el catálogo no conoce.", + "invalid": "Introduce un número entero de tokens mayor que 0" } }, "appearance": { diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index f52f29553..86487a592 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -510,6 +510,8 @@ "modelSaveFailed": "Falha ao salvar a seleção de modelo", "reasoningSaved": "Profundidade de raciocínio salva", "reasoningSaveFailed": "Falha ao salvar a profundidade de raciocínio", + "contextWindowSaved": "Janela de contexto salva", + "contextWindowSaveFailed": "Falha ao salvar a janela de contexto", "connectionOk": "Conexão OK", "connectionFailed": "Falha na conexão", "saveFailed": "Falha ao salvar o provedor" @@ -524,6 +526,13 @@ "high": "Alta", "xhigh": "Muito alta" }, + "contextWindow": { + "label": "Janela de contexto", + "placeholder": "Auto", + "unit": "tokens", + "hint": "Deixe vazio para usar o catálogo de modelos. Defina para modelos locais e relays que o catálogo não conhece.", + "invalid": "Digite um número inteiro de tokens maior que 0" + }, "addKey": "Adicionar chave", "missingKey": "Chave ausente", "litellm": { diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 92e4511ec..eb1dc727a 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -692,6 +692,8 @@ "modelSaveFailed": "保存模型选择失败", "reasoningSaved": "已保存推理深度", "reasoningSaveFailed": "保存推理深度失败", + "contextWindowSaved": "已保存上下文窗口", + "contextWindowSaveFailed": "保存上下文窗口失败", "connectionOk": "连接正常", "connectionFailed": "连接失败" }, @@ -727,6 +729,13 @@ "medium": "中", "high": "高", "xhigh": "最高" + }, + "contextWindow": { + "label": "上下文窗口", + "placeholder": "自动", + "unit": "token", + "hint": "留空则使用模型目录中的值。本地模型和目录里没有的中转模型请手动填写。", + "invalid": "请输入大于 0 的整数 token 数" } }, "appearance": { diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index dce5eabd4..4d128122d 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -98,6 +98,31 @@ describe('config v3 schema', () => { expect(parsed.providers['custom-lite']?.reasoningLevel).toBe('off'); }); + it('accepts a positive integer provider context window and rejects others', () => { + const config = (contextWindow: unknown) => ({ + version: 3, + activeProvider: 'ollama-local', + activeModel: 'qwen3:32b', + secrets: {}, + providers: { + 'ollama-local': { + id: 'ollama-local', + name: 'Ollama', + builtin: false, + wire: 'openai-chat', + baseUrl: 'http://localhost:11434/v1', + defaultModel: 'qwen3:32b', + contextWindow, + }, + }, + }); + expect(ConfigV3Schema.parse(config(32_768)).providers['ollama-local']?.contextWindow).toBe( + 32_768, + ); + expect(ConfigV3Schema.safeParse(config(0)).success).toBe(false); + expect(ConfigV3Schema.safeParse(config(8192.5)).success).toBe(false); + }); + it('does not treat an explicit off override as reasoning support', () => { const caps = defaultProviderCapabilities('custom-lite', { wire: 'openai-chat', diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 1b79db948..8b7783347 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -182,6 +182,13 @@ export const ProviderEntrySchema = z * per endpoint. The UI surfaces this as a "Reasoning depth" dropdown. */ reasoningLevel: ReasoningLevelSchema.optional(), + /** + * Context window, in tokens, of the models served through this provider. + * Overrides pi-ai's model catalog in core's `resolveContextWindow`; needed + * for local runtimes and relays whose windows the catalog does not know. + * The UI surfaces this as "Context window". + */ + contextWindow: z.number().int().positive().optional(), capabilities: ProviderCapabilitiesSchema.optional(), /** * Per-provider opt-in to skip TLS certificate verification on outbound From b77696202a68544d98178d0341455ca2ab1fdf62 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sun, 11 Oct 2026 02:40:50 +0800 Subject: [PATCH 2/4] fix(core): look up the context window with the id sent on the wire Gemini's OpenAI-compatible model listing stores ids such as models/gemini-2.5-pro, while pi-ai's catalog ids have no models/ prefix, so those models missed the catalog and got the 200k default. resolveContextWindow now takes the base URL and normalizes the id with normalizeGeminiModelId, as the request does. The exact lookup searches getModels(provider), which removes the cast to never. --- apps/desktop/src/main/ipc/generate.ts | 2 +- .../ipc/generate.workspace-rename.test.ts | 4 ++- packages/core/src/agent.test.ts | 2 ++ packages/core/src/agent.ts | 2 +- packages/core/src/context-window.test.ts | 35 ++++++++++++------- packages/core/src/context-window.ts | 26 +++++++------- 6 files changed, 42 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index 8a8287a78..dfc71f566 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -1316,7 +1316,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe brief: existingBrief, runPreferences, resourceState, - modelContextWindow: resolveContextWindow(active.model, active.contextWindow), + modelContextWindow: resolveContextWindow(active.model, baseUrl, active.contextWindow), workspaceState: { sourcePath: payload.previousSource ? 'App.jsx' : null, hasSource: Boolean(payload.previousSource?.trim()), diff --git a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts index 3a14a177c..b9b8cc21b 100644 --- a/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts +++ b/apps/desktop/src/main/ipc/generate.workspace-rename.test.ts @@ -119,9 +119,11 @@ vi.mock('@open-codesign/core', async (importOriginal) => { }; }); -vi.mock('@open-codesign/providers', () => ({ +vi.mock('@open-codesign/providers', async (importOriginal) => ({ detectProviderFromKey: vi.fn(() => 'mock'), generateImage: vi.fn(), + normalizeGeminiModelId: (await importOriginal()) + .normalizeGeminiModelId, })); vi.mock('../provider-settings', () => ({ diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index e3d8c9184..8c45d4312 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -324,6 +324,8 @@ vi.mock('@mariozechner/pi-ai', () => ({ contextWindow: 200000, maxTokens: 64000, }), + getModels: () => [], + getProviders: () => [], })); import { generateViaAgent, sanitizeOpenAIResponsesPayloadForStoreFalse } from './agent.js'; diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 1fc7e44fd..bbf2a47de 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -316,7 +316,7 @@ function buildPiModel( reasoning: inferReasoning(wire, effectiveModelId, canonicalBase), input: supportsImageInput(wire, effectiveModelId) ? ['text', 'image'] : ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: resolveContextWindow(model, contextWindow), + contextWindow: resolveContextWindow(model, canonicalBase, contextWindow), maxTokens: 32000, }; const compat = openAIChatCompatForBaseUrl(wire, canonicalBase); diff --git a/packages/core/src/context-window.test.ts b/packages/core/src/context-window.test.ts index 11f29345c..c774a0a57 100644 --- a/packages/core/src/context-window.test.ts +++ b/packages/core/src/context-window.test.ts @@ -3,37 +3,48 @@ import { describe, expect, it, vi } from 'vitest'; const catalog: Record> = { openai: [{ id: 'gpt-4.1', contextWindow: 1_047_576 }], 'github-copilot': [{ id: 'gpt-4.1', contextWindow: 128_000 }], + google: [{ id: 'gemini-2.5-pro', contextWindow: 1_048_576 }], }; vi.mock('@mariozechner/pi-ai', () => ({ getProviders: () => Object.keys(catalog), getModels: (provider: string) => catalog[provider] ?? [], - getModel: (provider: string, modelId: string) => - catalog[provider]?.find((entry) => entry.id === modelId), })); const { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } = await import('./context-window.js'); describe('resolveContextWindow', () => { it('uses the configured window first', () => { - expect(resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, 32_768)).toBe(32_768); + expect( + resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, undefined, 32_768), + ).toBe(32_768); }); it("uses the catalog entry for the model's own provider", () => { - expect(resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, undefined)).toBe( - 1_047_576, - ); + expect( + resolveContextWindow({ provider: 'openai', modelId: 'gpt-4.1' }, undefined, undefined), + ).toBe(1_047_576); }); it('uses the smallest catalog window for the same model id on other providers', () => { - expect(resolveContextWindow({ provider: 'my-relay', modelId: 'gpt-4.1' }, undefined)).toBe( - 128_000, - ); + expect( + resolveContextWindow({ provider: 'my-relay', modelId: 'gpt-4.1' }, undefined, undefined), + ).toBe(128_000); + }); + + it('matches Gemini models stored with the models/ prefix', () => { + expect( + resolveContextWindow( + { provider: 'gemini-import', modelId: 'models/gemini-2.5-pro' }, + 'https://generativelanguage.googleapis.com/v1beta/openai/', + undefined, + ), + ).toBe(1_048_576); }); it('falls back to the default for models the catalog does not know', () => { - expect(resolveContextWindow({ provider: 'ollama', modelId: 'qwen3:32b' }, undefined)).toBe( - DEFAULT_CONTEXT_WINDOW, - ); + expect( + resolveContextWindow({ provider: 'ollama', modelId: 'qwen3:32b' }, undefined, undefined), + ).toBe(DEFAULT_CONTEXT_WINDOW); }); }); diff --git a/packages/core/src/context-window.ts b/packages/core/src/context-window.ts index 3bb93c4a7..634df499d 100644 --- a/packages/core/src/context-window.ts +++ b/packages/core/src/context-window.ts @@ -1,11 +1,5 @@ -import { - type Api, - getModel, - getModels, - getProviders, - type KnownProvider, - type Model, -} from '@mariozechner/pi-ai'; +import { getModels, getProviders, type KnownProvider } from '@mariozechner/pi-ai'; +import { normalizeGeminiModelId } from '@open-codesign/providers'; import type { ModelRef } from '@open-codesign/shared'; /** Used when neither the provider settings nor pi-ai's catalog know the model. */ @@ -17,16 +11,20 @@ export const DEFAULT_CONTEXT_WINDOW = 200_000; * window among catalog entries with the same model id (relays and gateways * usually serve upstream ids, and an overestimate overflows the request while * an underestimate only trims context sooner), then DEFAULT_CONTEXT_WINDOW. + * The catalog is searched with the id sent on the wire, so Gemini's + * `models/`-prefixed ids match. */ -export function resolveContextWindow(model: ModelRef, configured: number | undefined): number { +export function resolveContextWindow( + model: ModelRef, + baseUrl: string | undefined, + configured: number | undefined, +): number { if (configured !== undefined) return configured; - const exact: Model | undefined = getModel( - model.provider as KnownProvider, - model.modelId as never, - ); + const modelId = normalizeGeminiModelId(model.modelId, baseUrl); + const exact = getModels(model.provider as KnownProvider).find((entry) => entry.id === modelId); if (exact !== undefined) return exact.contextWindow; const sameId = getProviders().flatMap((provider) => - getModels(provider).filter((entry) => entry.id === model.modelId), + getModels(provider).filter((entry) => entry.id === modelId), ); return sameId.length > 0 ? Math.min(...sameId.map((entry) => entry.contextWindow)) From 47d78bc4b1461dacca8992e9a3f29939362e0cf6 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sun, 11 Oct 2026 02:58:04 +0800 Subject: [PATCH 3/4] test(core): resolve an unknown provider against the installed pi-ai catalog --- packages/core/src/context-window.catalog.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/core/src/context-window.catalog.test.ts diff --git a/packages/core/src/context-window.catalog.test.ts b/packages/core/src/context-window.catalog.test.ts new file mode 100644 index 000000000..e3d78a38c --- /dev/null +++ b/packages/core/src/context-window.catalog.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from './context-window.js'; + +describe('resolveContextWindow with the installed pi-ai catalog', () => { + it('falls back to the default for a provider and model the catalog does not know', () => { + expect( + resolveContextWindow({ provider: 'my-relay', modelId: 'qwen3:32b' }, undefined, undefined), + ).toBe(DEFAULT_CONTEXT_WINDOW); + }); +}); From d5adbec48160d787e5e111a0e9060ea02f54abcd Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sun, 11 Oct 2026 03:09:26 +0800 Subject: [PATCH 4/4] fix(desktop): skip a second context window save while one is in flight Leaving the window blurs the focused input without moving focus, so disabling it during the save can blur it again and repeat the write and the success toast. The catalog test now uses a synthetic model id, so a future catalog entry cannot change its result. --- .../src/renderer/src/components/settings/primitives.tsx | 4 +++- packages/core/src/context-window.catalog.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/settings/primitives.tsx b/apps/desktop/src/renderer/src/components/settings/primitives.tsx index 34aade95d..1b9e2061a 100644 --- a/apps/desktop/src/renderer/src/components/settings/primitives.tsx +++ b/apps/desktop/src/renderer/src/components/settings/primitives.tsx @@ -650,7 +650,9 @@ export function ContextWindowInput({ }, [saved]); async function save() { - if (!window.codesign?.config?.updateProvider) return; + // Leaving the window blurs the input without moving focus, so disabling it + // while saving can blur it again. + if (saving || !window.codesign?.config?.updateProvider) return; const trimmed = draft.trim(); if (trimmed === saved) return; const next = trimmed === '' ? null : Number(trimmed); diff --git a/packages/core/src/context-window.catalog.test.ts b/packages/core/src/context-window.catalog.test.ts index e3d78a38c..ca7a6c10f 100644 --- a/packages/core/src/context-window.catalog.test.ts +++ b/packages/core/src/context-window.catalog.test.ts @@ -4,7 +4,11 @@ import { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from './context-window.j describe('resolveContextWindow with the installed pi-ai catalog', () => { it('falls back to the default for a provider and model the catalog does not know', () => { expect( - resolveContextWindow({ provider: 'my-relay', modelId: 'qwen3:32b' }, undefined, undefined), + resolveContextWindow( + { provider: 'codesign-test-relay', modelId: 'codesign-test-unlisted-model' }, + undefined, + undefined, + ), ).toBe(DEFAULT_CONTEXT_WINDOW); }); });