From ced486600f726e9fec396dc20d39a7aea94c79b7 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 18:27:54 +0200 Subject: [PATCH] feat: accept a custom context window per model for Windmill AI Co-Authored-By: Claude Opus 5 --- backend/windmill-api/openapi.yaml | 10 +++ backend/windmill-api/src/ai.rs | 4 ++ frontend/src/lib/aiStore.ts | 5 ++ .../copilot/autocomplete/Autocompletor.ts | 8 ++- .../copilot/chat/AIChatManager.svelte.ts | 4 +- .../copilot/chat/ContextUsageIndicator.svelte | 17 ++++- .../components/copilot/chat/script/core.ts | 9 ++- .../src/lib/components/copilot/lib.test.ts | 12 +++- .../src/lib/components/copilot/modelConfig.ts | 23 +++++-- .../workspaceSettings/AISettings.svelte | 35 +++++++++- .../workspaceSettings/ModelTokenLimits.svelte | 69 ++++++++++--------- 11 files changed, 145 insertions(+), 51 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d37c647db2..cc8eb42908 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -28825,6 +28825,16 @@ components: type: integer minimum: 1 maximum: 2000000 + context_window_per_model: + type: object + description: >- + Context window, in tokens, the AI chat budgets against for a model, keyed + `provider:model`. Overrides the built-in window, or the assumed one for a + model the chat does not know. + additionalProperties: + type: integer + minimum: 1000 + maximum: 10000000 free_tier: $ref: "#/components/schemas/FreeTierInfo" model_pricing: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 1710db9cec..1fe88a3d8d 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -436,6 +436,10 @@ pub struct AIConfig { pub custom_prompts: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens_per_model: Option>, + /// Context windows the chat budgets against, keyed `provider:model` like + /// `max_tokens_per_model`. Only models whose window differs from the built-in one are stored. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window_per_model: Option>, /// Response-only: this same struct is the request body for saving a workspace's AI /// config, and `skip_deserializing` is what stops a client from storing a forged /// free-tier marker. Only the server sets it, per-request. diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 6ac98d69e8..089ba92167 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -51,6 +51,8 @@ export const copilotInfo = writable<{ aiModels: AIProviderModel[] customPrompts?: Record maxTokensPerModel?: Record + /** Context windows per `provider:model`, overriding the built-in table. */ + contextWindowPerModel?: Record /** Negotiated rates per `provider:model`, overriding the built-in price table. */ modelPricing?: Record webSearchEnabledProviders?: Partial> @@ -67,6 +69,7 @@ export const copilotInfo = writable<{ aiModels: [], customPrompts: {}, maxTokensPerModel: {}, + contextWindowPerModel: {}, modelPricing: {}, webSearchEnabledProviders: {} }) @@ -144,6 +147,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: aiModels, customPrompts: aiConfig.custom_prompts ?? {}, maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}, + contextWindowPerModel: aiConfig.context_window_per_model ?? {}, webSearchEnabledProviders, modelPricing: aiConfig.model_pricing ?? {}, freeTier: aiConfig.free_tier @@ -160,6 +164,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: [], customPrompts: {}, maxTokensPerModel: {}, + contextWindowPerModel: {}, webSearchEnabledProviders: {}, modelPricing: {}, // An exhausted free grant lands here — no providers, but the reason AI is off diff --git a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts index 0ee2ad3f78..4bb2538c02 100644 --- a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts +++ b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts @@ -94,8 +94,12 @@ export class Autocompletor { const deletionsCues = editor.createDecorationsCollection() - const completionModel = get(copilotInfo).codeCompletionModel - this.#contextWindow = getModelContextWindow(completionModel?.model ?? '') + const { codeCompletionModel: completionModel, contextWindowPerModel } = get(copilotInfo) + this.#contextWindow = getModelContextWindow( + completionModel?.provider ?? '', + completionModel?.model ?? '', + contextWindowPerModel + ) this.#completionDisposable = languages.registerInlineCompletionsProvider( { pattern: '**' }, diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 71c06d6194..1fbe1f9c50 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -3855,7 +3855,9 @@ export class AIChatManager implements ChatViewHost { // assumed window rather than no limit: without one the context grows // unbounded until the provider (or a proxy in front of it) times out. // Guessing low only compacts earlier, which is always recoverable. - const contextWindow = model ? getModelContextWindow(model.model) : undefined + const contextWindow = model + ? getModelContextWindow(model.provider, model.model, get(copilotInfo).contextWindowPerModel) + : undefined if ( contextWindow !== undefined && projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index cd6c58b2a3..2718e78a40 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -1,6 +1,6 @@ {#if Object.keys(aiProviders).length > 0} - +
{#each Object.entries(modelsByProvider).filter(([provider, models]) => models.length > 0) as [provider, models]} {@const isExpanded = !collapsedProviders[provider]} @@ -131,7 +134,7 @@
{#each models as { model }} {@const currentTokens = getCurrentTokensForModel(provider as AIProvider, model)} - {@const defaultTokens = getDefaultTokensForModel(provider as AIProvider, model)} + {@const defaultTokens = defaultFor(provider as AIProvider, model)} {@const isAtDefault = isModelAtDefault(provider as AIProvider, model)}
@@ -141,8 +144,8 @@
{ const value = parseInt(e.currentTarget.value) @@ -150,7 +153,7 @@ updateTokensForModel(provider as AIProvider, model, value) } }} - class="w-20 px-2 py-1 text-xs text-center border border-gray-200 dark:border-gray-700 rounded bg-surface focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + class="w-24 px-2 py-1 text-xs text-center border border-gray-200 dark:border-gray-700 rounded bg-surface focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> tokens
@@ -166,11 +169,11 @@ Reset
- {#if errors[getModelKey(provider as AIProvider, model)]} -
{errors[getModelKey(provider as AIProvider, model)]}
- {/if} + {/if} + {#if errors[getModelKey(provider as AIProvider, model)]} +
{errors[getModelKey(provider as AIProvider, model)]}
{/if}
{/each}