From 320a61109566cd9f579368898ff10cff6cb55cd5 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Mar 2026 04:14:45 -0700 Subject: [PATCH] chore(extensions): add dench-ai-gateway OpenClaw plugin Registers Dench Cloud as an OpenClaw model provider with live gateway-backed catalog discovery. --- extensions/dench-ai-gateway/index.ts | 352 ++++++++++++++++++ extensions/dench-ai-gateway/models.ts | 323 ++++++++++++++++ .../dench-ai-gateway/openclaw.plugin.json | 27 ++ extensions/dench-ai-gateway/package.json | 8 + 4 files changed, 710 insertions(+) create mode 100644 extensions/dench-ai-gateway/index.ts create mode 100644 extensions/dench-ai-gateway/models.ts create mode 100644 extensions/dench-ai-gateway/openclaw.plugin.json create mode 100644 extensions/dench-ai-gateway/package.json diff --git a/extensions/dench-ai-gateway/index.ts b/extensions/dench-ai-gateway/index.ts new file mode 100644 index 00000000000..87f93f7be2e --- /dev/null +++ b/extensions/dench-ai-gateway/index.ts @@ -0,0 +1,352 @@ +import { + buildDenchCloudAgentModelEntries, + buildDenchCloudProviderModels, + buildDenchGatewayApiBaseUrl, + buildDenchGatewayCatalogUrl, + cloneFallbackDenchCloudModels, + DEFAULT_DENCH_CLOUD_GATEWAY_URL, + formatDenchCloudModelHint, + normalizeDenchCloudCatalogResponse, + normalizeDenchGatewayUrl, + resolveDenchCloudModel, + type DenchCloudCatalogModel, +} from "./models.js"; + +export const id = "dench-ai-gateway"; + +const PROVIDER_ID = "dench-cloud"; +const PROVIDER_LABEL = "Dench Cloud"; +const API_KEY_ENV_VARS = ["DENCH_CLOUD_API_KEY", "DENCH_API_KEY"] as const; + +type CatalogSource = "live" | "fallback"; + +type CatalogLoadResult = { + models: DenchCloudCatalogModel[]; + source: CatalogSource; + detail?: string; +}; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | undefined { + return value && typeof value === "object" ? (value as UnknownRecord) : undefined; +} + +function resolvePluginConfig(api: any): UnknownRecord | undefined { + const pluginConfig = api?.config?.plugins?.entries?.["dench-ai-gateway"]?.config; + return asRecord(pluginConfig); +} + +function resolveGatewayUrl(api: any): string { + const pluginConfig = resolvePluginConfig(api); + const configured = typeof pluginConfig?.gatewayUrl === "string" ? pluginConfig.gatewayUrl : undefined; + return normalizeDenchGatewayUrl( + configured || process.env.DENCH_GATEWAY_URL || DEFAULT_DENCH_CLOUD_GATEWAY_URL, + ); +} + +function resolveEnvApiKey(): string | undefined { + for (const envVar of API_KEY_ENV_VARS) { + const value = process.env[envVar]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +function buildProviderConfig( + gatewayUrl: string, + apiKey: string, + models: DenchCloudCatalogModel[], +) { + return { + baseUrl: buildDenchGatewayApiBaseUrl(gatewayUrl), + apiKey, + api: "openai-completions", + models: buildDenchCloudProviderModels(models), + }; +} + +export function buildDenchCloudConfigPatch(params: { + gatewayUrl: string; + apiKey: string; + models: DenchCloudCatalogModel[]; +}) { + return { + models: { + mode: "merge", + providers: { + [PROVIDER_ID]: buildProviderConfig(params.gatewayUrl, params.apiKey, params.models), + }, + }, + agents: { + defaults: { + models: buildDenchCloudAgentModelEntries(params.models), + }, + }, + }; +} + +async function promptForApiKey(prompter: any): Promise { + if (typeof prompter?.secret === "function") { + return String( + await prompter.secret( + "Enter your Dench Cloud API key (sign up at dench.com and get it at dench.com/settings)", + ), + ).trim(); + } + + return String( + await prompter.text({ + message: + "Enter your Dench Cloud API key (sign up at dench.com and get it at dench.com/settings)", + }), + ).trim(); +} + +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.`); +} + +async function promptForModelSelection(params: { + prompter: any; + models: DenchCloudCatalogModel[]; + initialStableId?: string; +}): Promise { + const selectedStableId = String( + await params.prompter.select({ + message: "Choose your default Dench Cloud model", + options: params.models.map((model) => ({ + value: model.stableId, + label: model.displayName, + hint: formatDenchCloudModelHint(model), + })), + ...(params.initialStableId ? { initialValue: params.initialStableId } : {}), + }), + ); + + const selected = resolveDenchCloudModel(params.models, selectedStableId); + if (!selected) { + throw new Error(`Unknown Dench Cloud model "${selectedStableId}".`); + } + return selected; +} + +function buildAuthNotes(params: { + gatewayUrl: string; + catalog: CatalogLoadResult; +}): string[] { + const notes = [ + `Dench Cloud uses ${buildDenchGatewayApiBaseUrl(params.gatewayUrl)} for model traffic.`, + ]; + + if (params.catalog.source === "fallback") { + notes.push( + `Model catalog fell back to DenchClaw's bundled list (${params.catalog.detail ?? "public catalog unavailable"}).`, + ); + } + + return notes; +} + +function buildProviderAuthResult(params: { + gatewayUrl: string; + apiKey: string; + catalog: CatalogLoadResult; + selected: DenchCloudCatalogModel; +}) { + return { + profiles: [ + { + profileId: `${PROVIDER_ID}:default`, + credential: { + type: "api_key", + provider: PROVIDER_ID, + key: params.apiKey, + }, + }, + ], + defaultModel: `${PROVIDER_ID}/${params.selected.stableId}`, + configPatch: buildDenchCloudConfigPatch({ + gatewayUrl: params.gatewayUrl, + apiKey: params.apiKey, + models: params.catalog.models, + }), + notes: buildAuthNotes({ + gatewayUrl: params.gatewayUrl, + catalog: params.catalog, + }), + }; +} + +async function runInteractiveAuth(ctx: any, gatewayUrl: string) { + const apiKey = await promptForApiKey(ctx.prompter); + if (!apiKey) { + throw new Error("A Dench Cloud API key is required."); + } + + await validateDenchCloudApiKey(gatewayUrl, apiKey); + const catalog = await fetchDenchCloudCatalog(gatewayUrl); + const selected = await promptForModelSelection({ + prompter: ctx.prompter, + models: catalog.models, + }); + + return buildProviderAuthResult({ + gatewayUrl, + apiKey, + catalog, + selected, + }); +} + +async function runNonInteractiveAuth(ctx: any, gatewayUrl: string) { + const apiKey = String( + ctx?.opts?.denchCloudApiKey || + ctx?.opts?.denchCloudKey || + resolveEnvApiKey() || + "", + ).trim(); + if (!apiKey) { + throw new Error( + "Dench Cloud non-interactive auth requires DENCH_CLOUD_API_KEY or --dench-cloud-api-key.", + ); + } + + await validateDenchCloudApiKey(gatewayUrl, apiKey); + const catalog = await fetchDenchCloudCatalog(gatewayUrl); + const selected = resolveDenchCloudModel( + catalog.models, + String(ctx?.opts?.denchCloudModel || process.env.DENCH_CLOUD_MODEL || "").trim(), + ); + if (!selected) { + throw new Error("Configured Dench Cloud model is not available."); + } + + return buildProviderAuthResult({ + gatewayUrl, + apiKey, + catalog, + selected, + }); +} + +function buildDiscoveryProvider(api: any, gatewayUrl: string) { + const configured = api?.config?.models?.providers?.[PROVIDER_ID]; + if (configured && typeof configured === "object") { + return configured; + } + + const apiKey = resolveEnvApiKey(); + if (!apiKey) { + return null; + } + + const models = cloneFallbackDenchCloudModels(); + return buildProviderConfig(gatewayUrl, apiKey, models); +} + +export default function register(api: any) { + const pluginConfig = resolvePluginConfig(api); + if (pluginConfig?.enabled === false) { + return; + } + + const gatewayUrl = resolveGatewayUrl(api); + + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/models", + aliases: ["dench", "dench-cloud", "dench-ai-gateway"], + envVars: [...API_KEY_ENV_VARS], + auth: [ + { + id: "api-key", + label: "Dench Cloud API Key", + hint: "Use your Dench Cloud key from dench.com/settings", + kind: "api_key", + run: async (ctx: any) => await runInteractiveAuth(ctx, gatewayUrl), + // Newer OpenClaw builds can call this hook during headless onboarding. + runNonInteractive: async (ctx: any) => await runNonInteractiveAuth(ctx, gatewayUrl), + }, + ], + // Newer OpenClaw builds can surface provider-specific wizard entries. + wizard: { + onboarding: { + choiceId: PROVIDER_ID, + choiceLabel: PROVIDER_LABEL, + choiceHint: "Use Dench's managed AI gateway", + groupId: "dench", + groupLabel: "Dench", + groupHint: "Managed Dench Cloud models", + methodId: "api-key", + }, + modelPicker: { + label: PROVIDER_LABEL, + hint: "Connect Dench Cloud with your API key", + methodId: "api-key", + }, + }, + // Best-effort discovery so newer OpenClaw builds can rehydrate provider config. + discovery: { + order: "profile", + run: async () => { + const provider = buildDiscoveryProvider(api, gatewayUrl); + return provider ? { provider } : null; + }, + }, + } as any); + + api.registerService({ + id: "dench-ai-gateway", + start: () => { + api.logger?.info?.(`[dench-ai-gateway] active (gateway: ${gatewayUrl})`); + }, + stop: () => { + api.logger?.info?.("[dench-ai-gateway] stopped"); + }, + }); +} diff --git a/extensions/dench-ai-gateway/models.ts b/extensions/dench-ai-gateway/models.ts new file mode 100644 index 00000000000..f94fe8fa1ac --- /dev/null +++ b/extensions/dench-ai-gateway/models.ts @@ -0,0 +1,323 @@ +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; +}; + +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 FALLBACK_DENCH_CLOUD_MODELS: DenchCloudCatalogModel[] = [ + { + 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-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: "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 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 resolveDenchCloudModel( + models: DenchCloudCatalogModel[], + requestedId: string | undefined, +): DenchCloudCatalogModel | undefined { + const normalized = requestedId?.trim(); + if (!normalized) { + return 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"); + return parts.join(" ยท "); +} diff --git a/extensions/dench-ai-gateway/openclaw.plugin.json b/extensions/dench-ai-gateway/openclaw.plugin.json new file mode 100644 index 00000000000..13909f9784b --- /dev/null +++ b/extensions/dench-ai-gateway/openclaw.plugin.json @@ -0,0 +1,27 @@ +{ + "id": "dench-ai-gateway", + "name": "Dench AI Gateway", + "version": "1.0.0", + "description": "Registers Dench Cloud as an OpenClaw model provider with live gateway-backed model discovery.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "gatewayUrl": { + "type": "string", + "default": "https://gateway.merseoriginals.com" + }, + "enabled": { + "type": "boolean", + "default": true + } + }, + "required": [] + }, + "uiHints": { + "gatewayUrl": { + "label": "Dench Gateway URL", + "placeholder": "https://gateway.merseoriginals.com" + } + } +} diff --git a/extensions/dench-ai-gateway/package.json b/extensions/dench-ai-gateway/package.json new file mode 100644 index 00000000000..6dac5a62ed0 --- /dev/null +++ b/extensions/dench-ai-gateway/package.json @@ -0,0 +1,8 @@ +{ + "name": "@denchclaw/dench-ai-gateway", + "version": "1.0.0", + "private": true, + "openclaw": { + "extensions": ["./index.ts"] + } +}