chore(extensions): add dench-ai-gateway OpenClaw plugin

Registers Dench Cloud as an OpenClaw model provider with live gateway-backed catalog discovery.
This commit is contained in:
kumarabhirup 2026-03-15 04:14:45 -07:00
parent e490380d01
commit 320a611095
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 710 additions and 0 deletions

View File

@ -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<string, unknown>;
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<string> {
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<CatalogLoadResult> {
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.`);
}
async function promptForModelSelection(params: {
prompter: any;
models: DenchCloudCatalogModel[];
initialStableId?: string;
}): Promise<DenchCloudCatalogModel> {
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");
},
});
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
{
"name": "@denchclaw/dench-ai-gateway",
"version": "1.0.0",
"private": true,
"openclaw": {
"extensions": ["./index.ts"]
}
}