diff --git a/docs/providers/longcat.md b/docs/providers/longcat.md index d15b2f823ddc..335b46f5c49e 100644 --- a/docs/providers/longcat.md +++ b/docs/providers/longcat.md @@ -8,7 +8,7 @@ read_when: [LongCat](https://longcat.ai) provides a hosted API for LongCat-2.0, a reasoning model built for coding and agentic workloads. OpenClaw provides the -official `longcat` plugin for LongCat's OpenAI-compatible endpoint. +official LongCat plugin for LongCat's OpenAI-compatible endpoint. | Property | Value | | ---------- | ---------------------------------- | @@ -77,7 +77,7 @@ the provider's expected message shape. The built-in catalog uses LongCat's pay-as-you-go list prices in USD per million tokens: $0.75 uncached input, $0.015 cached input, and $2.95 output. LongCat may -offer temporary discounts; the [pricing page](https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html) +offer temporary discounts; the [pricing page](https://longcat.chat/platform/docs/pricing/long-cat-2.0) and your billing records are authoritative. ## Self-hosted LongCat-2.0 diff --git a/extensions/longcat/doctor-contract-api.test.ts b/extensions/longcat/doctor-contract-api.test.ts new file mode 100644 index 000000000000..4d564c924985 --- /dev/null +++ b/extensions/longcat/doctor-contract-api.test.ts @@ -0,0 +1,99 @@ +// LongCat tests cover the plugin-owned persisted catalog repair. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js"; + +const LEGACY_STOCK_MODEL = { + id: "LongCat-2.0", + name: "LongCat 2.0", + reasoning: true, + input: ["text"], + contextWindow: 1_048_576, + maxTokens: 131_072, + cost: { input: 0.75, output: 2.95, cacheRead: 0.015, cacheWrite: 0.75 }, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + }, +}; + +function longcatConfig(models: unknown[]): OpenClawConfig { + return { + models: { + providers: { + longcat: { + baseUrl: "https://api.longcat.chat/openai", + api: "openai-completions", + models, + }, + }, + }, + } as OpenClawConfig; +} + +describe("LongCat doctor contract", () => { + it("repairs only the exact historical stock row", () => { + const custom = { + ...LEGACY_STOCK_MODEL, + name: "My LongCat", + cost: { ...LEGACY_STOCK_MODEL.cost }, + }; + const other = { id: "custom-model", name: "Custom" }; + const config = longcatConfig([structuredClone(LEGACY_STOCK_MODEL), custom, other]); + + expect(legacyConfigRules[0]?.match?.(config.models?.providers?.longcat?.models)).toBe(true); + + const result = normalizeCompatibilityConfig({ cfg: config }); + expect(result.changes).toEqual([ + "Updated the historical stock LongCat-2.0 cache-write price from $0.75 to $0.", + ]); + expect(result.config.models?.providers?.longcat?.models).toEqual([ + { + ...LEGACY_STOCK_MODEL, + cost: { ...LEGACY_STOCK_MODEL.cost, cacheWrite: 0 }, + }, + custom, + other, + ]); + expect(config.models?.providers?.longcat?.models?.[0]?.cost.cacheWrite).toBe(0.75); + expect(normalizeCompatibilityConfig({ cfg: result.config })).toEqual({ + config: result.config, + changes: [], + }); + }); + + it("repairs the historical row after core Doctor removes catalog-owned compat", () => { + const { compat: _compat, ...normalizedLegacyStockModel } = LEGACY_STOCK_MODEL; + const custom = { + ...normalizedLegacyStockModel, + compat: { supportsStore: true }, + }; + const config = longcatConfig([normalizedLegacyStockModel, custom]); + + const result = normalizeCompatibilityConfig({ cfg: config }); + expect(result.config.models?.providers?.longcat?.models).toEqual([ + { + ...normalizedLegacyStockModel, + cost: { ...normalizedLegacyStockModel.cost, cacheWrite: 0 }, + }, + custom, + ]); + }); + + it("preserves customized prices and already-correct rows", () => { + for (const cacheWrite of [0, 0.5]) { + const model = { + ...LEGACY_STOCK_MODEL, + cost: { ...LEGACY_STOCK_MODEL.cost, cacheWrite }, + }; + const config = longcatConfig([model]); + expect(normalizeCompatibilityConfig({ cfg: config })).toEqual({ config, changes: [] }); + } + }); +}); diff --git a/extensions/longcat/doctor-contract-api.ts b/extensions/longcat/doctor-contract-api.ts new file mode 100644 index 000000000000..ef9b6bfaa5f6 --- /dev/null +++ b/extensions/longcat/doctor-contract-api.ts @@ -0,0 +1,104 @@ +// LongCat doctor contract repairs the historical stock model price persisted +// by onboarding. Match the complete stock row so operator-customized models +// and prices are never rewritten when the vendor catalog changes. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor-migrations"; + +const MODELS_PATH = ["models", "providers", "longcat", "models"]; +const LEGACY_CACHE_WRITE_PRICE = 0.75; + +function isStringArray(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => entry === expected[index]) + ); +} + +function isLegacyStockLongCatModel(value: unknown): boolean { + const model = asObjectRecord(value); + const cost = asObjectRecord(model?.cost); + const compatValue = model?.compat; + const compat = asObjectRecord(compatValue); + // Core Doctor removes catalog-owned compat fields before plugin repairs run. + // Accept that normalized shape, but preserve rows with any divergent compat override. + const hasHistoricalCompat = + compatValue === undefined || + Boolean( + compat && + compat.supportsStore === false && + compat.supportsDeveloperRole === false && + compat.supportsReasoningEffort === false && + compat.supportsUsageInStreaming === false && + compat.supportsStrictMode === false && + compat.maxTokensField === "max_tokens" && + compat.requiresReasoningContentOnAssistantMessages === true && + compat.thinkingFormat === "deepseek" && + Object.keys(compat).length === 8, + ); + return Boolean( + model && + model.id === "LongCat-2.0" && + model.name === "LongCat 2.0" && + model.reasoning === true && + isStringArray(model.input, ["text"]) && + model.contextWindow === 1_048_576 && + model.maxTokens === 131_072 && + cost?.input === 0.75 && + cost.output === 2.95 && + cost.cacheRead === 0.015 && + cost.cacheWrite === LEGACY_CACHE_WRITE_PRICE && + hasHistoricalCompat, + ); +} + +function hasLegacyStockLongCatModel(value: unknown): boolean { + return Array.isArray(value) && value.some(isLegacyStockLongCatModel); +} + +export const legacyConfigRules = [ + { + path: MODELS_PATH, + message: + 'models.providers.longcat.models contains the historical stock LongCat-2.0 cache-write price; run "openclaw doctor --fix" to update it without changing customized rows.', + match: hasLegacyStockLongCatModel, + }, +]; + +export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): { + config: OpenClawConfig; + changes: string[]; +} { + const models = asObjectRecord(cfg.models); + const providers = asObjectRecord(models?.providers); + const provider = asObjectRecord(providers?.longcat); + const configuredModels = provider?.models; + if (!hasLegacyStockLongCatModel(configuredModels) || !Array.isArray(configuredModels)) { + return { config: cfg, changes: [] }; + } + + const nextModels = configuredModels.map((model) => { + if (!isLegacyStockLongCatModel(model)) { + return model; + } + const row = asObjectRecord(model) ?? {}; + const cost = asObjectRecord(row.cost) ?? {}; + return Object.assign({}, row, { + cost: Object.assign({}, cost, { cacheWrite: 0 }), + }); + }); + + return { + config: { + ...cfg, + models: { + ...models, + providers: { + ...providers, + longcat: { ...provider, models: nextModels }, + }, + } as unknown as OpenClawConfig["models"], + }, + changes: ["Updated the historical stock LongCat-2.0 cache-write price from $0.75 to $0."], + }; +} diff --git a/extensions/longcat/openclaw.plugin.json b/extensions/longcat/openclaw.plugin.json index df1ef242908f..6ed442f14de3 100644 --- a/extensions/longcat/openclaw.plugin.json +++ b/extensions/longcat/openclaw.plugin.json @@ -2,6 +2,9 @@ "id": "longcat", "name": "LongCat", "description": "OpenClaw LongCat provider plugin.", + "doctorContract": { + "configRepair": true + }, "activation": { "onStartup": false }, @@ -31,7 +34,7 @@ "input": 0.75, "output": 2.95, "cacheRead": 0.015, - "cacheWrite": 0.75 + "cacheWrite": 0 }, "compat": { "supportsStore": false, diff --git a/ui/public/provider-icons/ATTRIBUTION.md b/ui/public/provider-icons/ATTRIBUTION.md index dd30b166c2b7..3fd338684cda 100644 --- a/ui/public/provider-icons/ATTRIBUTION.md +++ b/ui/public/provider-icons/ATTRIBUTION.md @@ -34,6 +34,18 @@ by LM Studio: - Brand guidelines: https://lmstudio.ai/brand +## LongCat icon + +`ProviderIcon-longcat.svg` is a metadata-cleaned copy of the official LongCat +brand mark used across the LongCat API Platform (favicon and docs branding), +contributed by the LongCat team at Meituan with permission to use it here: + +- Source: https://s3plus.meituan.net/aigc-media-resources/longcat/yeqian-logo.svg + (the favicon of https://longcat.chat/platform/docs/) +- The white background plate was removed and the inner glyph recolored to + `currentColor` so the mark renders on both light and dark themes; the + geometry otherwise matches the cited source. + ## llama.cpp icon `ProviderIcon-llamacpp.svg` is a metadata-cleaned copy of diff --git a/ui/public/provider-icons/ProviderIcon-longcat.svg b/ui/public/provider-icons/ProviderIcon-longcat.svg new file mode 100644 index 000000000000..30ef06481eac --- /dev/null +++ b/ui/public/provider-icons/ProviderIcon-longcat.svg @@ -0,0 +1 @@ +LongCat diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index 0f176f571108..654a9333e29c 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -40,6 +40,7 @@ const PROVIDER_ICON_NAMES = new Set([ "llamacpp", "llmproxy", "lmstudio", + "longcat", "manus", "mimo", "minimax", @@ -93,6 +94,7 @@ const PROVIDER_DISPLAY_LABELS: Readonly> = { "github-copilot": "GitHub", "llama-cpp": "llama.cpp", lmstudio: "LM Studio", + longcat: "LongCat", openai: "OpenAI", moonshot: "Moonshot AI", opencode: "OpenCode",