From b5404db0319672e04d9f7e30734f29f64ad40d7d Mon Sep 17 00:00:00 2001 From: Mike SEO Bot Date: Tue, 18 Aug 2026 18:14:22 +0300 Subject: [PATCH] Every V4 session was capped at 200K because the limits were left unstated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TASK-393. ClawBox sells V4 on its 1M context window and no box was getting it. A provider configured in openclaw.json overrides OpenClaw's bundled model catalog outright, so the fields we left out did not fall back to the canonical V4 spec — they fell back to the generic 200,000-token default. The provider definition omitted contextWindow, maxTokens and input on purpose, with a comment explaining that duplicating them would only create drift. That reasoning was sound and the premise was wrong. Reproduced on a real device running OpenClaw 2026.7.1 on 2026-08-17: with the fields absent `openclaw models list` resolved both V4 models to 200K; with them present it reports 1M, and a live session reports contextTokens=1,000,000. The static ClawBox AI catalog had a second, independent version of the same problem: it hardcoded 128,000 for both tiers, a number that was never V4's limit, so every model picker on the device under-reported the window even where the gateway had it right. It also advertised text+image on a text-only proxy. Fixes both, and backfills devices already in the field from the boot script. The migration recognises the three states a shipped box can be in — absent, an old explicit 128000, or a 200000 written back by an earlier run — and corrects all three for Flash and Pro alike. It deliberately does not touch a contextWindow it doesn't recognise: a value we never shipped is one somebody chose, and overruling that would be the migration picking a fight with its operator. maxTokens is only filled when absent for the same reason. Tests exercise the migration block extracted from the shipped .sh rather than a copy, so the suite fails if the script drifts. Remaining from the acceptance criteria: end-to-end validation on a real box through the update path, and confirmation of whether the proxy honours the declared 384K output ceiling or enforces something lower. Co-Authored-By: Claude Opus 5 --- scripts/gateway-pre-start.sh | 26 ++++ src/app/setup-api/ai-models/catalog/route.ts | 15 +- .../setup-api/ai-models/configure/route.ts | 27 +++- .../routes/ai-models/catalog-clawai.test.ts | 38 +++++ src/tests/routes/ai-models/configure.test.ts | 10 ++ .../unit/gateway-pre-start-v4-context.test.ts | 138 ++++++++++++++++++ 6 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 src/tests/routes/ai-models/catalog-clawai.test.ts create mode 100644 src/tests/unit/gateway-pre-start-v4-context.test.ts diff --git a/scripts/gateway-pre-start.sh b/scripts/gateway-pre-start.sh index 97259a79a..1b6402ec4 100755 --- a/scripts/gateway-pre-start.sh +++ b/scripts/gateway-pre-start.sh @@ -475,6 +475,32 @@ if isinstance(ds_models, list): compat["supportsReasoningEffort"] = True changed = True + # Context/output/modality backfill. A configured provider entry + # overrides OpenClaw's bundled catalog outright, so a model that + # omits contextWindow does not inherit V4's real 1M window — it + # silently resolves to the generic 200,000 default. Boxes shipped + # before this fix are in one of three states: absent, an old + # explicit 128000, or the 200000 fallback written back by a + # previous run. All three are wrong and all three are corrected. + # + # Only those three values are touched. A number we did not ship + # is left alone: someone capped it deliberately (a small-RAM box, + # a cost experiment) and stamping over that would be the migration + # picking a fight with its operator. Same reason input is only + # written when absent or empty. + if model.get("contextWindow") in (None, 128000, 131072, 200000): + model["contextWindow"] = 1000000 + changed = True + # maxTokens is only filled in when absent. Unlike contextWindow there + # is no wrong-value set to recognise here, and a number someone chose + # is a choice — a box told to cap output at 8K meant it. + if model.get("maxTokens") is None: + model["maxTokens"] = 384000 + changed = True + if not isinstance(model.get("input"), list) or not model.get("input"): + model["input"] = ["text"] + changed = True + if changed: # Atomic write so a crash mid-rewrite can't leave a half-written # file where the gateway would refuse to boot. diff --git a/src/app/setup-api/ai-models/catalog/route.ts b/src/app/setup-api/ai-models/catalog/route.ts index 0b0f8953c..248667ce9 100644 --- a/src/app/setup-api/ai-models/catalog/route.ts +++ b/src/app/setup-api/ai-models/catalog/route.ts @@ -75,19 +75,24 @@ const DEFAULT_MODEL_BY_PROVIDER: Record = { // upstream but the only end-user-pickable variants are the two device // tiers (Flash + Pro), gated by subscription. Skipping the openclaw // spawn for clawai also dodges the 3-min CLI execution time on Jetson. -const CLAWAI_STATIC_MODELS: CatalogModel[] = [ +export const CLAWAI_STATIC_MODELS: CatalogModel[] = [ + // 1M/text matches what the provider definition writes to openclaw.json and + // what a real device reports back from `openclaw models list`. The previous + // 128K here was never the model's limit — it under-reported the window to + // every picker that reads this catalog. `text+image` was wrong too: V4 is + // text-in upstream, and offering image attachments only produced rejects. { id: "deepseek-v4-flash", label: "Free/Pro Tier", - contextWindow: 128_000, - input: "text+image", + contextWindow: 1_000_000, + input: "text", hint: "Default. Faster.", }, { id: "deepseek-v4-pro", label: "Max Tier", - contextWindow: 128_000, - input: "text+image", + contextWindow: 1_000_000, + input: "text", hint: "1.6T frontier model. Max plan only.", }, ]; diff --git a/src/app/setup-api/ai-models/configure/route.ts b/src/app/setup-api/ai-models/configure/route.ts index 11976067d..3b0df5eb0 100644 --- a/src/app/setup-api/ai-models/configure/route.ts +++ b/src/app/setup-api/ai-models/configure/route.ts @@ -246,13 +246,22 @@ async function getConfiguredClawboxAiToken(preferredToken?: string) { return ""; } +// Canonical DeepSeek V4 limits. Declared explicitly on every model entry +// rather than left to OpenClaw's bundled catalog: a configured provider in +// openclaw.json overrides the plugin catalog entirely, so an omitted +// contextWindow does NOT inherit the canonical spec — it falls through to the +// generic 200,000-token default. Verified on a real device running OpenClaw +// 2026.7.1 (2026-08-17): with these fields absent, `openclaw models list` +// resolved both V4 models to 200K; with them present it reports 1M. +const CLAWBOX_AI_CONTEXT_WINDOW = 1_000_000; +const CLAWBOX_AI_MAX_TOKENS = 384_000; +// V4 is text-in/text-out upstream. Stated rather than inferred so the picker +// never offers image attachments the proxy would reject. +const CLAWBOX_AI_INPUT_MODALITIES = ["text"] as const; + function buildClawboxAiProviderDefinition(apiKey: string) { - // Only emit fields that override defaults: the proxy URL, our auth, and - // per-tier identity/branding/reasoning. contextWindow, maxTokens, and - // input modalities are intentionally omitted — OpenClaw's bundled - // provider catalog (2026.4.24+) already knows the canonical V4 specs - // (1M context, 384K output, text-in/text-out), so duplicating them - // here just creates drift the next time DeepSeek bumps a number. + // Emit the proxy URL, our auth, per-tier identity/branding/reasoning, and + // the context/output/modality limits above. // `cost` stays zero to mark these as included-in-subscription so the // gateway doesn't surface DeepSeek's real per-token prices in the UI. return JSON.stringify({ @@ -280,6 +289,9 @@ function buildClawboxAiProviderDefinition(apiKey: string) { id: CLAWBOX_AI_FLASH_MODEL_ID, name: "ClawBox AI Flash", reasoning: true, + input: [...CLAWBOX_AI_INPUT_MODALITIES], + contextWindow: CLAWBOX_AI_CONTEXT_WINDOW, + maxTokens: CLAWBOX_AI_MAX_TOKENS, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compat: { supportsReasoningEffort: true, @@ -290,6 +302,9 @@ function buildClawboxAiProviderDefinition(apiKey: string) { id: CLAWBOX_AI_PRO_MODEL_ID, name: "ClawBox AI Pro", reasoning: true, + input: [...CLAWBOX_AI_INPUT_MODALITIES], + contextWindow: CLAWBOX_AI_CONTEXT_WINDOW, + maxTokens: CLAWBOX_AI_MAX_TOKENS, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compat: { supportsReasoningEffort: true, diff --git a/src/tests/routes/ai-models/catalog-clawai.test.ts b/src/tests/routes/ai-models/catalog-clawai.test.ts new file mode 100644 index 000000000..2eaa02057 --- /dev/null +++ b/src/tests/routes/ai-models/catalog-clawai.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +// The ClawBox AI catalog is hardcoded rather than fetched, so nothing upstream +// will ever correct it: whatever these entries say is what every model picker +// on the device shows. They used to claim 128K — a number that was never V4's +// limit — and text+image, which the text-only proxy rejects. This pins them to +// the same values the provider definition writes into openclaw.json, so the +// picker and the gateway can't drift apart again. + +vi.mock("child_process", () => ({ spawn: vi.fn() })); +vi.mock("@/lib/openclaw-config", () => ({ + findOpenclawBin: () => "openclaw", + openclawIsAbsent: () => false, +})); +vi.mock("@/lib/config-store", () => ({ DATA_DIR: "/tmp/clawbox-catalog-clawai-test" })); + +import { CLAWAI_STATIC_MODELS } from "@/app/setup-api/ai-models/catalog/route"; + +describe("ClawBox AI static catalog", () => { + it("offers exactly the two subscription tiers", () => { + expect(CLAWAI_STATIC_MODELS.map((m) => m.id)).toEqual([ + "deepseek-v4-flash", + "deepseek-v4-pro", + ]); + }); + + it("reports V4's real 1M context window on both tiers", () => { + for (const model of CLAWAI_STATIC_MODELS) { + expect(model.contextWindow).toBe(1_000_000); + } + }); + + it("declares text-only input, matching the proxy", () => { + for (const model of CLAWAI_STATIC_MODELS) { + expect(model.input).toBe("text"); + } + }); +}); diff --git a/src/tests/routes/ai-models/configure.test.ts b/src/tests/routes/ai-models/configure.test.ts index d8843d191..2632cd326 100644 --- a/src/tests/routes/ai-models/configure.test.ts +++ b/src/tests/routes/ai-models/configure.test.ts @@ -347,6 +347,16 @@ describe("POST /setup-api/ai-models/configure", () => { expect(providerDef.models[0].compat.supportedReasoningEfforts).toEqual(["off", "high", "xhigh"]); expect(providerDef.models[1].compat.supportedReasoningEfforts).toEqual(["off", "high", "xhigh"]); + // A configured provider overrides OpenClaw's bundled catalog, so these + // three fields have to be stated on every model. Omit contextWindow and + // the gateway falls back to a generic 200,000 rather than V4's real 1M — + // reproduced on a device running 2026.7.1 on 2026-08-17. + for (const model of providerDef.models) { + expect(model.contextWindow).toBe(1_000_000); + expect(model.maxTokens).toBe(384_000); + expect(model.input).toEqual(["text"]); + } + expect(mockSetMany).toHaveBeenCalledWith( expect.objectContaining({ clawai_token: "portal-token-123", diff --git a/src/tests/unit/gateway-pre-start-v4-context.test.ts b/src/tests/unit/gateway-pre-start-v4-context.test.ts new file mode 100644 index 000000000..94aed01e4 --- /dev/null +++ b/src/tests/unit/gateway-pre-start-v4-context.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// A configured provider entry in openclaw.json overrides OpenClaw's bundled +// model catalog outright. A V4 model that omits contextWindow therefore does +// NOT inherit the canonical 1M window — it resolves to the generic 200,000 +// default. Confirmed on a real device on OpenClaw 2026.7.1 (2026-08-17): +// `openclaw models list` reported 200K with the field absent and 1M once it +// was written. Devices in the field are in one of three states (absent, an old +// explicit 128000, or a 200000 written back by an earlier run) and the boot +// migration has to fix all three without touching a cap someone set on purpose. +// +// These run the migration block out of the shipped .sh, not a copy of it, so +// the test fails if the real script drifts. + +const SCRIPT = path.resolve(process.cwd(), "scripts/gateway-pre-start.sh"); +const hasPython3 = spawnSync("python3", ["--version"], { stdio: "ignore" }).status === 0; + +/** Pull the DeepSeek V4 model-normalisation block out of the .sh verbatim. */ +function extractPolicy(): string { + const src = readFileSync(SCRIPT, "utf-8"); + const start = src.indexOf("if isinstance(ds_models, list):"); + const end = src.indexOf("if changed:", start); + if (start < 0 || end < 0) throw new Error("deepseek V4 model block not found"); + return src.slice(start, end); +} + +const POLICY = hasPython3 ? extractPolicy() : ""; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), "v4-context-")); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +type MigratedModel = { + id: string; + contextWindow?: number; + maxTokens?: number; + input?: string[]; + [key: string]: unknown; +}; + +/** Run the extracted block over a model list and return the migrated models. */ +function migrate(models: Record[]): MigratedModel[] { + const file = path.join(dir, "models.json"); + writeFileSync(file, JSON.stringify(models)); + const program = [ + "import json, sys", + "ds_models = json.load(open(sys.argv[1]))", + "changed = False", + POLICY, + "print(json.dumps({'models': ds_models, 'changed': changed}))", + ].join("\n"); + const out = JSON.parse(execFileSync("python3", ["-c", program, file], { encoding: "utf-8" }).trim()); + return out.models; +} + +/** Same, but reporting whether the script decided a rewrite was needed. */ +function migrateChanged(models: Record[]): boolean { + const file = path.join(dir, "models.json"); + writeFileSync(file, JSON.stringify(models)); + const program = [ + "import json, sys", + "ds_models = json.load(open(sys.argv[1]))", + "changed = False", + POLICY, + "print(json.dumps({'changed': changed}))", + ].join("\n"); + return JSON.parse(execFileSync("python3", ["-c", program, file], { encoding: "utf-8" }).trim()).changed; +} + +const V4_IDS = ["deepseek-v4-flash", "deepseek-v4-pro"] as const; + +describe.skipIf(!hasPython3)("gateway-pre-start.sh V4 context migration", () => { + it.each(V4_IDS)("fills an absent contextWindow with 1M on %s", (id) => { + const [m] = migrate([{ id, name: "ClawBox AI" }]); + expect(m.contextWindow).toBe(1_000_000); + expect(m.maxTokens).toBe(384_000); + expect(m.input).toEqual(["text"]); + }); + + it.each(V4_IDS)("replaces the old explicit 128K on %s", (id) => { + const [m] = migrate([{ id, contextWindow: 128000 }]); + expect(m.contextWindow).toBe(1_000_000); + }); + + it.each(V4_IDS)("replaces the 200K fallback written back by an earlier run on %s", (id) => { + const [m] = migrate([{ id, contextWindow: 200000 }]); + expect(m.contextWindow).toBe(1_000_000); + }); + + it("migrates Flash and Pro in the same pass", () => { + const models = migrate([ + { id: "deepseek-v4-flash", contextWindow: 128000 }, + { id: "deepseek-v4-pro" }, + ]); + expect(models.map((m) => m.contextWindow)).toEqual([1_000_000, 1_000_000]); + }); + + it("is idempotent — a second run reports no change", () => { + const once = migrate([{ id: "deepseek-v4-flash", contextWindow: 128000 }]); + expect(migrateChanged(once)).toBe(false); + }); + + it("leaves a deliberate non-standard cap alone", () => { + // 32K is not a value we ever shipped, so someone chose it — probably to fit + // a smaller box. Overwriting it would be the migration overruling its owner. + const [m] = migrate([{ id: "deepseek-v4-flash", contextWindow: 32000, maxTokens: 8192 }]); + expect(m.contextWindow).toBe(32000); + expect(m.maxTokens).toBe(8192); + }); + + it("does not touch models from other providers or other deepseek ids", () => { + const models = migrate([{ id: "deepseek-chat", contextWindow: 65536 }]); + expect(models[0].contextWindow).toBe(65536); + expect(models[0].maxTokens).toBeUndefined(); + expect(models[0].input).toBeUndefined(); + }); + + it("preserves unrelated fields it did not come to change", () => { + const [m] = migrate([{ + id: "deepseek-v4-pro", + name: "ClawBox AI Pro", + reasoning: true, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }]); + expect(m.name).toBe("ClawBox AI Pro"); + expect(m.reasoning).toBe(true); + expect(m.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); + + it("replaces an input that is present but not a usable list", () => { + const [m] = migrate([{ id: "deepseek-v4-flash", input: [] }]); + expect(m.input).toEqual(["text"]); + }); +});