diff --git a/.changeset/provider-context-window.md b/.changeset/provider-context-window.md new file mode 100644 index 00000000..f5ebe4bf --- /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 d13ff2bf..80966d6d 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 97922d70..dfc71f56 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, baseUrl, 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 8da2f2da..b9b8cc21 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(); @@ -118,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', () => ({ @@ -195,6 +198,7 @@ vi.mock('../ask-ipc', () => ({ import { type AskInput, type AskResult, + buildDesignContextPack, generateViaAgent, makeAskTool, makeTextEditorTool, @@ -205,6 +209,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 +847,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 2aaae947..d12168cd 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 827cf802..28679026 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 3e8dc0bf..018d6d6f 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 def8d82c..8c85e2cd 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 a32e3562..83e84152 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 3b0a614a..1b9e2061 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,87 @@ 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() { + // 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); + 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 8b9958ea..8c45d431 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'; @@ -643,6 +645,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 b7d96f55..bbf2a47d 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, canonicalBase, 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.catalog.test.ts b/packages/core/src/context-window.catalog.test.ts new file mode 100644 index 00000000..ca7a6c10 --- /dev/null +++ b/packages/core/src/context-window.catalog.test.ts @@ -0,0 +1,14 @@ +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: 'codesign-test-relay', modelId: 'codesign-test-unlisted-model' }, + undefined, + undefined, + ), + ).toBe(DEFAULT_CONTEXT_WINDOW); + }); +}); diff --git a/packages/core/src/context-window.test.ts b/packages/core/src/context-window.test.ts new file mode 100644 index 00000000..c774a0a5 --- /dev/null +++ b/packages/core/src/context-window.test.ts @@ -0,0 +1,50 @@ +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] ?? [], +})); + +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' }, 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, 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, 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, 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 00000000..634df499 --- /dev/null +++ b/packages/core/src/context-window.ts @@ -0,0 +1,32 @@ +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. */ +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. + * The catalog is searched with the id sent on the wire, so Gemini's + * `models/`-prefixed ids match. + */ +export function resolveContextWindow( + model: ModelRef, + baseUrl: string | undefined, + configured: number | undefined, +): number { + if (configured !== undefined) return configured; + 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 === 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 aec17b70..2a575258 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 31e26360..e8a7aa5e 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 d3d596ed..8d7453cd 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 f52f2955..86487a59 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 92e4511e..eb1dc727 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 dce5eabd..4d128122 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 1b79db94..8b778334 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