diff --git a/CHANGELOG.md b/CHANGELOG.md index a30e57c5f0a..21f72564ee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007. - Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. - Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc. diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index e65c1e38141..34e420d208f 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; import { defaultRuntime } from "../runtime.js"; import { applyCustomApiConfig, @@ -326,6 +327,91 @@ describe("promptCustomApiConfig", () => { }); describe("applyCustomApiConfig", () => { + it("uses hard-min context window for newly added custom models", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + }); + + const model = result.config.models?.providers?.custom?.models?.find( + (entry) => entry.id === "foo-large", + ); + expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS); + }); + + it("upgrades existing custom model context window when below hard minimum", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://llm.example.com/v1", + models: [ + { + id: "foo-large", + name: "foo-large", + contextWindow: 4096, + maxTokens: 1024, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + }); + + const model = result.config.models?.providers?.custom?.models?.find( + (entry) => entry.id === "foo-large", + ); + expect(model?.contextWindow).toBe(CONTEXT_WINDOW_HARD_MIN_TOKENS); + }); + + it("preserves existing custom model context window when already above minimum", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "https://llm.example.com/v1", + models: [ + { + id: "foo-large", + name: "foo-large", + contextWindow: 131072, + maxTokens: 4096, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + }); + + const model = result.config.models?.providers?.custom?.models?.find( + (entry) => entry.id === "foo-large", + ); + expect(model?.contextWindow).toBe(131072); + }); + it.each([ { name: "invalid compatibility values at runtime", diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index bfcd4b01415..a05922aafe0 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -1,3 +1,4 @@ +import { CONTEXT_WINDOW_HARD_MIN_TOKENS } from "../agents/context-window-guard.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -16,10 +17,15 @@ import { normalizeAlias } from "./models/shared.js"; import type { SecretInputMode } from "./onboard-types.js"; const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1"; -const DEFAULT_CONTEXT_WINDOW = 4096; +const DEFAULT_CONTEXT_WINDOW = CONTEXT_WINDOW_HARD_MIN_TOKENS; const DEFAULT_MAX_TOKENS = 4096; const VERIFY_TIMEOUT_MS = 30_000; +function normalizeContextWindowForCustomModel(value: unknown): number { + const parsed = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0; + return parsed >= CONTEXT_WINDOW_HARD_MIN_TOKENS ? parsed : CONTEXT_WINDOW_HARD_MIN_TOKENS; +} + /** * Detects if a URL is from Azure AI Foundry or Azure OpenAI. * Matches both: @@ -600,7 +606,16 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, reasoning: false, }; - const mergedModels = hasModel ? existingModels : [...existingModels, nextModel]; + const mergedModels = hasModel + ? existingModels.map((model) => + model.id === modelId + ? { + ...model, + contextWindow: normalizeContextWindowForCustomModel(model.contextWindow), + } + : model, + ) + : [...existingModels, nextModel]; const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {}; const normalizedApiKey = normalizeOptionalProviderApiKey(params.apiKey) ??