diff --git a/package.json b/package.json index 010a93f1b1d..5dac2f190d3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "prepack": "pnpm build:plugin-env && pnpm build && pnpm web:build && pnpm web:prepack", "start": "node denchclaw.mjs", "test": "pnpm test:cli && pnpm --dir apps/web test", - "test:cli": "vitest run --config vitest.unit.config.ts src/cli/run-main.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts src/cli/workspace-seed.test.ts src/cli/web-runtime.test.ts src/cli/web-runtime-command.test.ts src/cli/flatten-standalone-deps.test.ts", + "test:cli": "vitest run --config vitest.unit.config.ts src/cli/run-main.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts src/cli/dench-cloud.test.ts src/cli/workspace-seed.test.ts src/cli/web-runtime.test.ts src/cli/web-runtime-command.test.ts src/cli/flatten-standalone-deps.test.ts", "test:web": "pnpm --dir apps/web test", "web:build": "pnpm --dir apps/web build", "web:dev": "pnpm --dir apps/web dev", diff --git a/src/cli/dench-cloud.test.ts b/src/cli/dench-cloud.test.ts new file mode 100644 index 00000000000..f6d9962bb8b --- /dev/null +++ b/src/cli/dench-cloud.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildDenchCloudConfigPatch, + fetchDenchCloudCatalog, + normalizeDenchCloudCatalogResponse, + readConfiguredDenchCloudSettings, + validateDenchCloudApiKey, +} from "./dench-cloud.js"; + +function createJsonResponse(params?: { + status?: number; + payload?: unknown; +}): Response { + const status = params?.status ?? 200; + return { + status, + ok: status >= 200 && status < 300, + json: async () => params?.payload ?? {}, + } as unknown as Response; +} + +describe("dench-cloud helpers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("normalizes the public gateway catalog into stable model records", () => { + const models = normalizeDenchCloudCatalogResponse({ + object: "list", + data: [ + { + id: "gpt-5.4", + stableId: "gpt-5.4", + name: "GPT-5.4", + provider: "openai", + transportProvider: "openai", + input: ["text", "image"], + contextWindow: 128000, + maxTokens: 128000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 3.375, + output: 20.25, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + ], + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "gpt-5.4", + stableId: "gpt-5.4", + displayName: "GPT-5.4", + contextWindow: 128000, + maxTokens: 128000, + cost: expect.objectContaining({ + input: 3.375, + output: 20.25, + marginPercent: 0.35, + }), + }), + ]); + }); + + it("falls back to the bundled model list when the public catalog is unavailable", async () => { + const fetchMock = vi.fn(async () => createJsonResponse({ status: 503, payload: {} })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const result = await fetchDenchCloudCatalog("https://gateway.merseoriginals.com"); + expect(fetchMock).toHaveBeenCalledWith("https://gateway.merseoriginals.com/v1/public/models"); + expect(result.source).toBe("fallback"); + expect(result.models.map((model) => model.stableId)).toEqual([ + "anthropic.claude-opus-4-6-v1", + "gpt-5.4", + "anthropic.claude-sonnet-4-6-v1", + ]); + }); + + it("rejects invalid Dench Cloud API keys with an actionable message", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => createJsonResponse({ status: 401, payload: {} })) as unknown as typeof fetch, + ); + + await expect( + validateDenchCloudApiKey("https://gateway.merseoriginals.com", "bad-key"), + ).rejects.toThrow("Check your key at dench.com/settings"); + }); + + it("builds the Dench Cloud config patch with provider models and agent aliases", () => { + const patch = buildDenchCloudConfigPatch({ + gatewayUrl: "https://gateway.merseoriginals.com", + apiKey: "dench_live_key", + models: [ + { + id: "claude-opus-4.6", + stableId: "anthropic.claude-opus-4-6-v1", + displayName: "Claude Opus 4.6", + provider: "anthropic", + transportProvider: "bedrock", + api: "openai-completions", + input: ["text", "image"], + reasoning: false, + contextWindow: 200000, + maxTokens: 64000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: 6.75, + output: 33.75, + cacheRead: 0, + cacheWrite: 0, + marginPercent: 0.35, + }, + }, + ], + }); + + expect(patch.models.providers["dench-cloud"]).toEqual( + expect.objectContaining({ + baseUrl: "https://gateway.merseoriginals.com/v1", + apiKey: "dench_live_key", + api: "openai-completions", + models: [ + expect.objectContaining({ + id: "anthropic.claude-opus-4-6-v1", + name: "Claude Opus 4.6 (Dench Cloud)", + }), + ], + }), + ); + expect(patch.agents.defaults.models["dench-cloud/anthropic.claude-opus-4-6-v1"]).toEqual( + expect.objectContaining({ + alias: "Claude Opus 4.6 (Dench Cloud)", + }), + ); + }); + + it("reads existing Dench Cloud gateway config from openclaw.json", () => { + const result = readConfiguredDenchCloudSettings({ + models: { + providers: { + "dench-cloud": { + baseUrl: "https://gateway.merseoriginals.com/v1", + apiKey: "dench_cfg_key", + }, + }, + }, + agents: { + defaults: { + model: { + primary: "dench-cloud/anthropic.claude-opus-4-6-v1", + }, + }, + }, + }); + + expect(result).toEqual({ + gatewayUrl: "https://gateway.merseoriginals.com", + apiKey: "dench_cfg_key", + selectedModel: "anthropic.claude-opus-4-6-v1", + }); + }); +}); diff --git a/src/cli/dench-cloud.ts b/src/cli/dench-cloud.ts new file mode 100644 index 00000000000..f1752a25b8f --- /dev/null +++ b/src/cli/dench-cloud.ts @@ -0,0 +1,453 @@ +export const DEFAULT_DENCH_CLOUD_GATEWAY_URL = "https://gateway.merseoriginals.com"; +export const DEFAULT_DENCH_CLOUD_MARGIN_PERCENT = 0.35; + +export type DenchCloudCatalogCost = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + marginPercent?: number; +}; + +export type DenchCloudCatalogModel = { + id: string; + stableId: string; + displayName: string; + provider: "openai" | "anthropic"; + transportProvider: "openai" | "bedrock"; + api: "openai-completions"; + input: Array<"text" | "image">; + reasoning: boolean; + contextWindow: number; + maxTokens: number; + supportsStreaming: boolean; + supportsImages: boolean; + supportsResponses: boolean; + supportsReasoning: boolean; + cost: DenchCloudCatalogCost; +}; + +export type DenchCloudCatalogSource = "live" | "fallback"; + +export type DenchCloudCatalogLoadResult = { + models: DenchCloudCatalogModel[]; + source: DenchCloudCatalogSource; + detail?: string; +}; + +type UnknownRecord = Record; + +function roundUsd(value: number): number { + return Number(value.toFixed(8)); +} + +function markupCost(value: number): number { + return roundUsd(value * (1 + DEFAULT_DENCH_CLOUD_MARGIN_PERCENT)); +} + +function asRecord(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" ? (value as UnknownRecord) : undefined; +} + +function readString(input: UnknownRecord, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +function readNumber(input: UnknownRecord, ...keys: string[]): number | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return undefined; +} + +function readBoolean(input: UnknownRecord, ...keys: string[]): boolean | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === "boolean") { + return value; + } + } + return undefined; +} + +function isProvider(value: string | undefined): value is "openai" | "anthropic" { + return value === "openai" || value === "anthropic"; +} + +function isTransportProvider(value: string | undefined): value is "openai" | "bedrock" { + return value === "openai" || value === "bedrock"; +} + +function normalizeInputKinds(input: unknown, supportsImages: boolean): Array<"text" | "image"> { + if (!Array.isArray(input)) { + return supportsImages ? ["text", "image"] : ["text"]; + } + + const kinds = new Set<"text" | "image">(); + for (const value of input) { + if (value === "text" || value === "image") { + kinds.add(value); + } + } + + if (!kinds.has("text")) { + kinds.add("text"); + } + if (supportsImages) { + kinds.add("image"); + } + return [...kinds]; +} + +export function normalizeDenchGatewayUrl(value: string | undefined): string { + const raw = (value || DEFAULT_DENCH_CLOUD_GATEWAY_URL).trim(); + const withProtocol = raw.startsWith("http://") || raw.startsWith("https://") + ? raw + : `https://${raw}`; + return withProtocol.replace(/\/+$/, "").replace(/\/v1$/u, ""); +} + +export function buildDenchGatewayApiBaseUrl(gatewayUrl: string | undefined): string { + return `${normalizeDenchGatewayUrl(gatewayUrl)}/v1`; +} + +export function buildDenchGatewayCatalogUrl(gatewayUrl: string | undefined): string { + return `${normalizeDenchGatewayUrl(gatewayUrl)}/v1/public/models`; +} + +export const RECOMMENDED_DENCH_CLOUD_MODEL_ID = "claude-opus-4.6"; + +export const FALLBACK_DENCH_CLOUD_MODELS: DenchCloudCatalogModel[] = [ + { + id: "claude-opus-4.6", + stableId: "anthropic.claude-opus-4-6-v1", + displayName: "Claude Opus 4.6", + provider: "anthropic", + transportProvider: "bedrock", + api: "openai-completions", + input: ["text", "image"], + reasoning: false, + contextWindow: 200000, + maxTokens: 64000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: markupCost(5), + output: markupCost(25), + cacheRead: 0, + cacheWrite: 0, + marginPercent: DEFAULT_DENCH_CLOUD_MARGIN_PERCENT, + }, + }, + { + id: "gpt-5.4", + stableId: "gpt-5.4", + displayName: "GPT-5.4", + provider: "openai", + transportProvider: "openai", + api: "openai-completions", + input: ["text", "image"], + reasoning: false, + contextWindow: 128000, + maxTokens: 128000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: markupCost(2.5), + output: markupCost(15), + cacheRead: 0, + cacheWrite: 0, + marginPercent: DEFAULT_DENCH_CLOUD_MARGIN_PERCENT, + }, + }, + { + id: "claude-sonnet-4.6", + stableId: "anthropic.claude-sonnet-4-6-v1", + displayName: "Claude Sonnet 4.6", + provider: "anthropic", + transportProvider: "bedrock", + api: "openai-completions", + input: ["text", "image"], + reasoning: false, + contextWindow: 200000, + maxTokens: 64000, + supportsStreaming: true, + supportsImages: true, + supportsResponses: true, + supportsReasoning: false, + cost: { + input: markupCost(3), + output: markupCost(15), + cacheRead: 0, + cacheWrite: 0, + marginPercent: DEFAULT_DENCH_CLOUD_MARGIN_PERCENT, + }, + }, +]; + +export function cloneFallbackDenchCloudModels(): DenchCloudCatalogModel[] { + return FALLBACK_DENCH_CLOUD_MODELS.map((model) => ({ + ...model, + input: [...model.input], + cost: { ...model.cost }, + })); +} + +export function normalizeDenchCloudCatalogModel(input: unknown): DenchCloudCatalogModel | null { + const record = asRecord(input); + if (!record) { + return null; + } + + const publicId = readString(record, "id", "publicId", "public_id"); + const stableId = readString(record, "stableId", "stable_id") || publicId; + const displayName = readString(record, "name", "displayName", "display_name"); + const provider = readString(record, "provider"); + const transportProvider = readString(record, "transportProvider", "transport_provider"); + if (!publicId || !stableId || !displayName || !isProvider(provider) || !isTransportProvider(transportProvider)) { + return null; + } + + const supportsImages = readBoolean(record, "supportsImages", "supports_images") ?? false; + const supportsStreaming = readBoolean(record, "supportsStreaming", "supports_streaming") ?? true; + const supportsResponses = readBoolean(record, "supportsResponses", "supports_responses") ?? true; + const supportsReasoning = readBoolean(record, "supportsReasoning", "supports_reasoning") + ?? readBoolean(record, "reasoning") + ?? false; + const contextWindow = readNumber(record, "contextWindow", "context_window") ?? 128000; + const maxTokens = readNumber(record, "maxTokens", "max_tokens", "maxOutputTokens", "max_output_tokens") ?? 128000; + + const costRecord = asRecord(record.cost) ?? {}; + const inputCost = readNumber(costRecord, "input") ?? 0; + const outputCost = readNumber(costRecord, "output") ?? 0; + const cacheRead = readNumber(costRecord, "cacheRead", "cache_read") ?? 0; + const cacheWrite = readNumber(costRecord, "cacheWrite", "cache_write") ?? 0; + const marginPercent = readNumber(costRecord, "marginPercent", "margin_percent"); + + return { + id: publicId, + stableId, + displayName, + provider, + transportProvider, + api: "openai-completions", + input: normalizeInputKinds(record.input, supportsImages), + reasoning: supportsReasoning, + contextWindow, + maxTokens, + supportsStreaming, + supportsImages, + supportsResponses, + supportsReasoning, + cost: { + input: inputCost, + output: outputCost, + cacheRead, + cacheWrite, + ...(marginPercent !== undefined ? { marginPercent } : {}), + }, + }; +} + +export function normalizeDenchCloudCatalogResponse(payload: unknown): DenchCloudCatalogModel[] { + const root = asRecord(payload); + const data = root?.data; + if (!Array.isArray(data)) { + return []; + } + + const models: DenchCloudCatalogModel[] = []; + const seen = new Set(); + for (const entry of data) { + const normalized = normalizeDenchCloudCatalogModel(entry); + if (!normalized || seen.has(normalized.stableId)) { + continue; + } + seen.add(normalized.stableId); + models.push(normalized); + } + return models; +} + +export async function fetchDenchCloudCatalog( + gatewayUrl: string, +): Promise { + try { + const response = await fetch(buildDenchGatewayCatalogUrl(gatewayUrl)); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = await response.json().catch(() => null); + const models = normalizeDenchCloudCatalogResponse(payload); + if (!models.length) { + throw new Error("response did not contain any usable models"); + } + + return { + models, + source: "live", + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + models: cloneFallbackDenchCloudModels(), + source: "fallback", + detail, + }; + } +} + +export async function validateDenchCloudApiKey( + gatewayUrl: string, + apiKey: string, +): Promise { + const response = await fetch(`${buildDenchGatewayApiBaseUrl(gatewayUrl)}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (response.ok) { + return; + } + + const message = + response.status === 401 || response.status === 403 + ? "Invalid Dench Cloud API key." + : `Dench Cloud validation failed with HTTP ${response.status}.`; + throw new Error(`${message} Check your key at dench.com/settings.`); +} + +export function buildDenchCloudProviderModels(models: DenchCloudCatalogModel[]) { + return models.map((model) => ({ + id: model.stableId, + name: `${model.displayName} (Dench Cloud)`, + reasoning: model.reasoning, + input: [...model.input], + cost: { + input: model.cost.input, + output: model.cost.output, + cacheRead: model.cost.cacheRead, + cacheWrite: model.cost.cacheWrite, + }, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })); +} + +export function buildDenchCloudAgentModelEntries(models: DenchCloudCatalogModel[]) { + return Object.fromEntries( + models.map((model) => [ + `dench-cloud/${model.stableId}`, + { alias: `${model.displayName} (Dench Cloud)` }, + ]), + ); +} + +export function buildDenchCloudProviderConfig(params: { + gatewayUrl: string; + apiKey: string; + models: DenchCloudCatalogModel[]; +}) { + return { + baseUrl: buildDenchGatewayApiBaseUrl(params.gatewayUrl), + apiKey: params.apiKey, + api: "openai-completions", + models: buildDenchCloudProviderModels(params.models), + }; +} + +export function buildDenchCloudConfigPatch(params: { + gatewayUrl: string; + apiKey: string; + models: DenchCloudCatalogModel[]; +}) { + return { + models: { + mode: "merge", + providers: { + "dench-cloud": buildDenchCloudProviderConfig(params), + }, + }, + agents: { + defaults: { + models: buildDenchCloudAgentModelEntries(params.models), + }, + }, + }; +} + +export function resolveDenchCloudModel( + models: DenchCloudCatalogModel[], + requestedId: string | undefined, +): DenchCloudCatalogModel | undefined { + const normalized = requestedId?.trim(); + if (!normalized) { + return ( + models.find((model) => model.id === RECOMMENDED_DENCH_CLOUD_MODEL_ID) || + models[0] + ); + } + + return models.find((model) => model.id === normalized || model.stableId === normalized); +} + +export function formatDenchCloudModelHint(model: DenchCloudCatalogModel): string { + const parts: string[] = [model.provider]; + if (model.reasoning) parts.push("reasoning"); + if (model.id === RECOMMENDED_DENCH_CLOUD_MODEL_ID) parts.push("recommended"); + return parts.join(" ยท "); +} + +export function readConfiguredDenchCloudSettings( + rawConfig: Record | undefined, +): { + gatewayUrl?: string; + apiKey?: string; + selectedModel?: string; +} { + const provider = asRecord( + asRecord(asRecord(rawConfig?.models)?.providers)?.["dench-cloud"], + ); + const defaults = asRecord(asRecord(rawConfig?.agents)?.defaults); + const modelValue = defaults?.model; + const modelSetting = asRecord(modelValue); + const modelPrimary = + typeof modelValue === "string" + ? modelValue + : typeof modelSetting?.primary === "string" + ? modelSetting.primary + : undefined; + + const selectedModel = + typeof modelPrimary === "string" && modelPrimary.startsWith("dench-cloud/") + ? modelPrimary.slice("dench-cloud/".length) + : undefined; + + const baseUrl = readString(provider ?? {}, "baseUrl", "base_url"); + return { + gatewayUrl: baseUrl ? normalizeDenchGatewayUrl(baseUrl) : undefined, + apiKey: readString(provider ?? {}, "apiKey", "api_key"), + selectedModel, + }; +}