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:
parent
e490380d01
commit
320a611095
352
extensions/dench-ai-gateway/index.ts
Normal file
352
extensions/dench-ai-gateway/index.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
323
extensions/dench-ai-gateway/models.ts
Normal file
323
extensions/dench-ai-gateway/models.ts
Normal 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(" · ");
|
||||
}
|
||||
27
extensions/dench-ai-gateway/openclaw.plugin.json
Normal file
27
extensions/dench-ai-gateway/openclaw.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
extensions/dench-ai-gateway/package.json
Normal file
8
extensions/dench-ai-gateway/package.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@denchclaw/dench-ai-gateway",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user