feat(cli): add dench-cloud model catalog and config helpers

Shared module for Dench Cloud catalog fetching, API key validation, and OpenClaw config patching.
This commit is contained in:
kumarabhirup 2026-03-15 04:15:29 -07:00
parent e490380d01
commit 1af869f64b
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 626 additions and 1 deletions

View File

@ -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",

172
src/cli/dench-cloud.test.ts Normal file
View File

@ -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",
});
});
});

453
src/cli/dench-cloud.ts Normal file
View File

@ -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<string, unknown>;
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<string>();
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<DenchCloudCatalogLoadResult> {
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<void> {
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<string, unknown> | 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,
};
}