Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/provider-context-window.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 0 additions & 13 deletions apps/desktop/src/main/ipc/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 8 additions & 10 deletions apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
inspectWorkspaceFiles,
loadDesignSkills,
loadFrameTemplates,
resolveContextWindow,
routeRunPreferences,
updateDesignSessionBrief,
} from '@open-codesign/core';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/main/ipc/generate.workspace-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ vi.mock('@open-codesign/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@open-codesign/core')>();
return {
...actual,
buildDesignContextPack: vi.fn(actual.buildDesignContextPack),
generateViaAgent: vi.fn(async (input: unknown) => {
coreCalls.generateInputs.push(input);
generateControl.markStarted();
Expand All @@ -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<typeof import('@open-codesign/providers')>())
.normalizeGeminiModelId,
}));

vi.mock('../provider-settings', () => ({
Expand Down Expand Up @@ -195,6 +198,7 @@ vi.mock('../ask-ipc', () => ({
import {
type AskInput,
type AskResult,
buildDesignContextPack,
generateViaAgent,
makeAskTool,
makeTextEditorTool,
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/main/onboarding/provider-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface UpdateProviderInput {
queryParams?: Record<string, string>;
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". */
Expand Down Expand Up @@ -88,6 +91,7 @@ const UPDATE_PROVIDER_FIELDS = [
'queryParams',
'wire',
'reasoningLevel',
'contextWindow',
'apiKey',
'requiresApiKey',
'tlsRejectUnauthorized',
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/onboarding/providers-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ export async function runUpdateProvider(input: UpdateProviderInput): Promise<Onb
} else if (input.reasoningLevel !== undefined) {
updated.reasoningLevel = input.reasoningLevel;
}
if (input.contextWindow === null) {
updated.contextWindow = undefined;
} else if (input.contextWindow !== undefined) {
updated.contextWindow = input.contextWindow;
}
// tlsRejectUnauthorized tri-state: null clears the field (back to strict
// TLS), true persists the opt-out, false also clears (omit-when-default).
// Builtin providers force-ignore the flag at the connect / generate paths,
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/provider-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,24 @@ describe('resolveActiveModel', () => {
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',
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
});
Expand Down Expand Up @@ -223,6 +226,7 @@ export interface ActiveModelResolution {
httpHeaders: Record<string, string> | undefined;
queryParams: Record<string, string> | undefined;
reasoningLevel: ReasoningLevel | undefined;
contextWindow: number | undefined;
allowKeyless: boolean;
/** True when the renderer-supplied hint provider didn't match the canonical active. */
overridden: boolean;
Expand Down Expand Up @@ -256,6 +260,7 @@ export function resolveActiveModel(
httpHeaders: entry.httpHeaders,
queryParams: entry.queryParams,
reasoningLevel: entry.reasoningLevel,
contextWindow: entry.contextWindow,
allowKeyless,
overridden,
};
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading