From 8f2dd02d2df69224b62c6bb70849c3c27a790939 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Wed, 29 Apr 2026 22:38:11 +1000 Subject: [PATCH] fix(deepseek): add provider-policy-api to hydrate contextWindow and cost from catalog (#74326) DeepSeek models had no provider-policy-api.ts, so materializeRuntimeConfig filled contextWindow with DEFAULT_CONTEXT_TOKENS (200k) and cost with zeros for all DeepSeek models. This caused premature session compaction at ~125k instead of using the full 1M window, and zero-cost display for v4 models. Add a normalizeConfig surface that hydrates missing contextWindow, maxTokens, and cost from the bundled DeepSeek model catalog for matching model ids. Explicit user overrides are preserved. Fixes #74245 --- .../deepseek/provider-policy-api.test.ts | 235 ++++++++++++++++++ extensions/deepseek/provider-policy-api.ts | 97 ++++++++ 2 files changed, 332 insertions(+) create mode 100644 extensions/deepseek/provider-policy-api.test.ts create mode 100644 extensions/deepseek/provider-policy-api.ts diff --git a/extensions/deepseek/provider-policy-api.test.ts b/extensions/deepseek/provider-policy-api.test.ts new file mode 100644 index 00000000000..6645ebc9f74 --- /dev/null +++ b/extensions/deepseek/provider-policy-api.test.ts @@ -0,0 +1,235 @@ +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; +import { describe, expect, it } from "vitest"; +import { normalizeConfig } from "./provider-policy-api.js"; + +describe("deepseek provider-policy-api", () => { + it("hydrates contextWindow and cost from catalog for known models", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + + expect(result).not.toBe(providerConfig); + const model = result.models[0]; + expect(model.contextWindow).toBe(1_000_000); + expect(model.maxTokens).toBe(384_000); + expect(model.cost).toEqual({ + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }); + }); + + it("hydrates deepseek-v4-pro with correct metadata", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + reasoning: true, + input: ["text"], + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + const model = result.models[0]; + expect(model.contextWindow).toBe(1_000_000); + expect(model.maxTokens).toBe(384_000); + expect(model.cost).toEqual({ + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }); + }); + + it("hydrates deepseek-chat with 131k context", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-chat", + name: "DeepSeek Chat", + reasoning: false, + input: ["text"], + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + const model = result.models[0]; + expect(model.contextWindow).toBe(131_072); + }); + + it("preserves explicit user contextWindow override", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + contextWindow: 500_000, + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + const model = result.models[0]; + expect(model.contextWindow).toBe(500_000); + // cost should still be hydrated since it was missing + expect(model.cost).toEqual({ + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }); + }); + + it("preserves explicit user cost override", () => { + const userCost = { input: 99, output: 99, cacheRead: 99, cacheWrite: 99 }; + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + cost: userCost, + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + const model = result.models[0]; + expect(model.cost).toEqual(userCost); + // contextWindow should still be hydrated since it was missing + expect(model.contextWindow).toBe(1_000_000); + }); + + it("preserves explicit user maxTokens override", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + maxTokens: 100_000, + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + const model = result.models[0]; + expect(model.maxTokens).toBe(100_000); + }); + + it("returns providerConfig unchanged when all models already have metadata", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + contextWindow: 1_000_000, + maxTokens: 384_000, + cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + expect(result).toBe(providerConfig); + }); + + it("passes through unknown model ids unchanged", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-custom-finetune", + name: "Custom Fine-tune", + reasoning: false, + input: ["text"], + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + expect(result).toBe(providerConfig); + }); + + it("returns providerConfig unchanged when models array is empty", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + expect(result).toBe(providerConfig); + }); + + it("hydrates only the models that need it in a mixed list", () => { + const providerConfig: ModelProviderConfig = { + baseUrl: "https://api.deepseek.com", + api: "openai-completions", + models: [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + reasoning: true, + input: ["text"], + contextWindow: 1_000_000, + maxTokens: 384_000, + cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 }, + } as never, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + reasoning: true, + input: ["text"], + } as never, + ], + }; + + const result = normalizeConfig({ provider: "deepseek", providerConfig }); + expect(result).not.toBe(providerConfig); + // First model should be unchanged (same reference) + expect(result.models[0]).toBe(providerConfig.models[0]); + // Second model should be hydrated + expect(result.models[1].contextWindow).toBe(1_000_000); + expect(result.models[1].cost).toEqual({ + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }); + }); +}); diff --git a/extensions/deepseek/provider-policy-api.ts b/extensions/deepseek/provider-policy-api.ts new file mode 100644 index 00000000000..994aee24be3 --- /dev/null +++ b/extensions/deepseek/provider-policy-api.ts @@ -0,0 +1,97 @@ +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; +import { DEEPSEEK_MODEL_CATALOG } from "./models.js"; + +type ModelDefinitionDraft = Partial & + Pick; + +/** + * Build a lookup from the bundled DeepSeek model catalog so we can hydrate + * missing metadata (contextWindow, cost, maxTokens) into user-configured + * model rows without overwriting explicit overrides. + */ +function buildCatalogIndex(): Map { + const index = new Map(); + for (const model of DEEPSEEK_MODEL_CATALOG) { + index.set(model.id, model); + } + return index; +} + +function isPositiveNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function hasCostValues(cost: unknown): cost is ModelDefinitionConfig["cost"] { + if (!cost || typeof cost !== "object") { + return false; + } + const c = cost as Record; + return ( + typeof c.input === "number" || + typeof c.output === "number" || + typeof c.cacheRead === "number" || + typeof c.cacheWrite === "number" + ); +} + +/** + * Provider policy surface for DeepSeek. + * + * Hydrates missing `contextWindow`, `cost`, and `maxTokens` from the bundled + * catalog for matching model ids. Explicit user overrides are preserved. + */ +export function normalizeConfig(params: { + provider: string; + providerConfig: ModelProviderConfig; +}): ModelProviderConfig { + const { providerConfig } = params; + if (!Array.isArray(providerConfig.models) || providerConfig.models.length === 0) { + return providerConfig; + } + + const catalog = buildCatalogIndex(); + let mutated = false; + + const nextModels = providerConfig.models.map((model) => { + const raw = model as ModelDefinitionDraft; + const catalogEntry = catalog.get(raw.id); + if (!catalogEntry) { + return model; + } + + let modelMutated = false; + const patched: Record = {}; + + // Hydrate contextWindow from catalog when missing or not a positive number. + if (!isPositiveNumber(raw.contextWindow) && isPositiveNumber(catalogEntry.contextWindow)) { + patched.contextWindow = catalogEntry.contextWindow; + modelMutated = true; + } + + // Hydrate maxTokens from catalog when missing or not a positive number. + if (!isPositiveNumber(raw.maxTokens) && isPositiveNumber(catalogEntry.maxTokens)) { + patched.maxTokens = catalogEntry.maxTokens; + modelMutated = true; + } + + // Hydrate cost from catalog when missing or when all fields are zero/absent. + if (!hasCostValues(raw.cost) && hasCostValues(catalogEntry.cost)) { + patched.cost = catalogEntry.cost; + modelMutated = true; + } + + if (!modelMutated) { + return model; + } + + mutated = true; + return { ...raw, ...patched }; + }); + + if (!mutated) { + return providerConfig; + } + + return { ...providerConfig, models: nextModels as ModelDefinitionConfig[] }; +}