Files
openclaw/extensions/migrate-hermes/config-provider-contract.ts
2026-08-11 10:22:01 -07:00

439 lines
14 KiB
TypeScript

// Hermes provider config contract parsing and normalization.
import { asPositiveFiniteNumber as readPositiveNumber } from "openclaw/plugin-sdk/number-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
MCP_ENV_REFERENCE_RE,
mcpValueHasEnvReferences,
resolveMcpEnvReferences,
} from "./config-env.js";
import { childRecord, readStringArray } from "./helpers.js";
import { normalizeHermesCustomProviderId, normalizeHermesProviderId } from "./model.js";
type OpenClawModelApi =
| "anthropic-messages"
| "openai-completions"
| "openai-responses"
| "openai-chatgpt-responses";
type HermesModelConfig = {
id: string;
contextWindow?: number;
maxTokens?: number;
supportsVision?: boolean;
};
export type HermesProviderConfig = {
id: string;
baseUrl: string;
api: OpenClawModelApi;
apiKeyEnv?: string;
headers?: Record<string, unknown>;
models: HermesModelConfig[];
sensitive?: boolean;
};
export const HERMES_TRANSPORTS: Record<string, OpenClawModelApi> = {
anthropic_messages: "anthropic-messages",
chat_completions: "openai-completions",
codex_responses: "openai-responses",
openai_chat: "openai-completions",
};
const HERMES_MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
const HERMES_MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/anthropic";
const HERMES_ALIBABA_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
const HERMES_SPECIAL_BASE_URL_ENV_VARS: Record<string, readonly string[]> = {
// Hermes plain `custom` still reads OPENAI_BASE_URL in its runtime provider resolver.
custom: ["CUSTOM_BASE_URL", "OPENAI_BASE_URL"],
"openai-api": ["OPENAI_BASE_URL"],
"xai-oauth": ["HERMES_XAI_BASE_URL", "XAI_BASE_URL"],
"qwen-oauth": ["HERMES_QWEN_BASE_URL"],
"qwen-cli": ["HERMES_QWEN_BASE_URL"],
"qwen-portal": ["HERMES_QWEN_BASE_URL"],
"minimax-cn": ["MINIMAX_CN_BASE_URL"],
"alibaba-coding-plan": ["ALIBABA_CODING_PLAN_BASE_URL"],
};
const HERMES_BASE_URL_ENV_VARS: Record<string, readonly string[]> = {
anthropic: ["ANTHROPIC_BASE_URL"],
arcee: ["ARCEE_BASE_URL"],
"azure-foundry": ["AZURE_FOUNDRY_BASE_URL"],
deepseek: ["DEEPSEEK_BASE_URL"],
gmi: ["GMI_BASE_URL"],
google: ["GEMINI_BASE_URL"],
huggingface: ["HF_BASE_URL"],
kilocode: ["KILOCODE_BASE_URL"],
kimi: ["KIMI_BASE_URL"],
lmstudio: ["LM_BASE_URL"],
minimax: ["MINIMAX_BASE_URL"],
novita: ["NOVITA_BASE_URL"],
nvidia: ["NVIDIA_BASE_URL"],
"ollama-cloud": ["OLLAMA_BASE_URL"],
opencode: ["OPENCODE_ZEN_BASE_URL"],
"opencode-go": ["OPENCODE_GO_BASE_URL"],
openrouter: ["OPENROUTER_BASE_URL"],
qwen: ["DASHSCOPE_BASE_URL"],
stepfun: ["STEPFUN_BASE_URL"],
"tencent-tokenhub": ["TOKENHUB_BASE_URL"],
xai: ["XAI_BASE_URL"],
xiaomi: ["XIAOMI_BASE_URL"],
zai: ["GLM_BASE_URL"],
};
const HERMES_SPECIAL_API_KEY_ENV_VARS: Record<string, string> = {
custom: "OPENAI_API_KEY",
"openai-api": "OPENAI_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
"alibaba-coding-plan": "ALIBABA_CODING_PLAN_API_KEY",
};
const HERMES_API_KEY_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
arcee: "ARCEEAI_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GOOGLE_API_KEY",
huggingface: "HF_TOKEN",
kilocode: "KILOCODE_API_KEY",
kimi: "KIMI_API_KEY",
lmstudio: "LM_API_KEY",
minimax: "MINIMAX_API_KEY",
nvidia: "NVIDIA_API_KEY",
opencode: "OPENCODE_ZEN_API_KEY",
"opencode-go": "OPENCODE_GO_API_KEY",
openrouter: "OPENROUTER_API_KEY",
qwen: "DASHSCOPE_API_KEY",
stepfun: "STEPFUN_API_KEY",
xai: "XAI_API_KEY",
xiaomi: "XIAOMI_API_KEY",
zai: "ZAI_API_KEY",
};
function resolveHermesProviderEnvValue(
providerId: string | undefined,
env: Record<string, string>,
special: Record<string, readonly string[]>,
canonical: Record<string, readonly string[]>,
): string | undefined {
if (!providerId) {
return undefined;
}
const sourceProvider = normalizeHermesCustomProviderId(providerId);
const provider = normalizeHermesProviderId(sourceProvider);
const names = special[sourceProvider] ?? canonical[provider] ?? [];
for (const name of names) {
const value = env[name]?.trim();
if (value) {
return value;
}
}
return undefined;
}
export function resolveHermesProviderBaseUrlEnv(
providerId: string | undefined,
env: Record<string, string>,
): string | undefined {
return resolveHermesProviderEnvValue(
providerId,
env,
HERMES_SPECIAL_BASE_URL_ENV_VARS,
HERMES_BASE_URL_ENV_VARS,
);
}
export function resolveHermesProviderApiKeyEnv(providerId: string | undefined): string | undefined {
if (!providerId) {
return undefined;
}
const sourceProvider = normalizeHermesCustomProviderId(providerId);
const provider = normalizeHermesProviderId(sourceProvider);
return HERMES_SPECIAL_API_KEY_ENV_VARS[sourceProvider] ?? HERMES_API_KEY_ENV_VARS[provider];
}
export function resolveHermesImplicitBaseUrl(providerId: string | undefined): string | undefined {
const provider = providerId?.trim().toLowerCase();
if (provider && ["alibaba", "alibaba-cloud", "aliyun", "dashscope"].includes(provider)) {
return HERMES_ALIBABA_BASE_URL;
}
// OpenClaw's qwen default is already Hermes' coding-plan endpoint; no override needed.
if (provider && ["kimi-coding-cn", "kimi-cn", "moonshot-cn"].includes(provider)) {
return HERMES_MOONSHOT_CN_BASE_URL;
}
return provider && ["minimax-cn", "minimax-china", "minimax_cn"].includes(provider)
? HERMES_MINIMAX_CN_BASE_URL
: undefined;
}
export { readPositiveNumber };
export function resolveProviderApi(
raw: Record<string, unknown>,
providerId?: string,
): OpenClawModelApi | undefined {
const transport = normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.api_mode);
const sourceProvider = providerId?.trim().toLowerCase() ?? "";
if (sourceProvider === "openai-codex") {
return "openai-chatgpt-responses";
}
if (transport && transport !== "codex_responses") {
return HERMES_TRANSPORTS[transport];
}
const provider = sourceProvider ? normalizeHermesProviderId(sourceProvider) : "";
const baseUrl =
normalizeOptionalString(raw.base_url) ??
normalizeOptionalString(raw.baseUrl) ??
normalizeOptionalString(raw.url) ??
normalizeOptionalString(raw.api);
let hostname = "";
let pathname = "";
try {
const parsed = baseUrl ? new URL(baseUrl) : undefined;
hostname = parsed?.hostname.toLowerCase() ?? "";
pathname = parsed?.pathname.toLowerCase().replace(/\/+$/u, "") ?? "";
} catch {
// Provider identity still supplies the protocol for templated endpoints.
}
// Hermes honors an explicit Responses mode for named providers. Plain
// `custom` is the exception: endpoint detection rejects stale Responses state.
if (transport === "codex_responses" && sourceProvider !== "custom") {
return "openai-responses";
}
if (
["anthropic", "minimax", "minimax-cn", "minimax-oauth"].includes(provider) ||
hostname === "api.anthropic.com" ||
(hostname === "api.kimi.com" && (pathname === "/coding" || pathname.startsWith("/coding/"))) ||
pathname.endsWith("/anthropic") ||
pathname.endsWith("/anthropic/v1")
) {
return "anthropic-messages";
}
if (hostname === "chatgpt.com" && pathname.includes("/backend-api/codex")) {
return "openai-chatgpt-responses";
}
if (sourceProvider === "openai-api") {
return "openai-responses";
}
if (transport === "codex_responses") {
return "openai-responses";
}
if (provider === "xai" || hostname === "api.x.ai" || hostname === "api.openai.com") {
return "openai-responses";
}
return "openai-completions";
}
function normalizeProviderBaseUrl(baseUrl: string, api: OpenClawModelApi): string {
if (api !== "anthropic-messages") {
return baseUrl;
}
try {
const parsed = new URL(baseUrl);
// The Anthropic SDK appends /v1/messages. Store the canonical base so
// imported proxy paths do not repeat the version segment.
parsed.pathname = parsed.pathname.replace(/\/v1\/?$/u, "");
return parsed.toString().replace(/\/$/u, "");
} catch {
return baseUrl;
}
}
export function readEnvReference(value: unknown): string | undefined {
const raw = normalizeOptionalString(value);
const match = raw?.match(/^\$\{([^}]+)\}$/u);
return match ? normalizeHermesEnvReferenceName(match[1] ?? "") : undefined;
}
function normalizeHermesEnvReferenceName(value: string): string | undefined {
const trimmed = value.trim();
const name = trimmed.startsWith("env:") ? trimmed.slice("env:".length).trim() : trimmed;
return name || undefined;
}
export function readProviderApiKeyEnv(raw: Record<string, unknown>): string | undefined {
return (
normalizeOptionalString(raw.key_env) ??
normalizeOptionalString(raw.api_key_env) ??
normalizeOptionalString(raw.apiKeyEnv) ??
normalizeOptionalString(raw.env) ??
readEnvReference(raw.api_key)
);
}
export function resolveHermesEndpointApiKeyEnv(baseUrl: string): string | undefined {
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
return hostname === "openai.com" ||
hostname.endsWith(".openai.com") ||
hostname === "openai.azure.com" ||
hostname.endsWith(".openai.azure.com")
? "OPENAI_API_KEY"
: undefined;
} catch {
return undefined;
}
}
function readModelMetadata(raw: Record<string, unknown>): Omit<HermesModelConfig, "id"> {
const contextWindow =
readPositiveNumber(raw.context_length) ?? readPositiveNumber(raw.contextWindow);
const maxTokens =
readPositiveNumber(raw.max_tokens) ??
readPositiveNumber(raw.max_output_tokens) ??
readPositiveNumber(raw.maxTokens);
const supportsVision = raw.supports_vision ?? raw.supportsVision;
return {
...(contextWindow ? { contextWindow } : {}),
...(maxTokens ? { maxTokens } : {}),
...(typeof supportsVision === "boolean" ? { supportsVision } : {}),
};
}
export function collectProviderModels(raw: Record<string, unknown>): HermesModelConfig[] {
const models = new Map<string, HermesModelConfig>();
const rootMetadata = readModelMetadata(raw);
for (const modelId of readStringArray(raw.models)) {
models.set(modelId, { id: modelId, ...rootMetadata });
}
for (const [modelId, metadata] of Object.entries(childRecord(raw, "models"))) {
models.set(modelId, {
id: modelId,
...rootMetadata,
...(isRecord(metadata) ? readModelMetadata(metadata) : {}),
});
}
for (const modelId of [
normalizeOptionalString(raw.default_model),
normalizeOptionalString(raw.default),
normalizeOptionalString(raw.model),
]) {
if (modelId && !models.has(modelId)) {
models.set(modelId, { id: modelId, ...rootMetadata });
}
}
return [...models.values()];
}
function modelDefinition(
model: HermesModelConfig,
entry: HermesProviderConfig,
): Record<string, unknown> {
const baseUrl = normalizeProviderBaseUrl(entry.baseUrl, entry.api);
return {
id: model.id,
name: model.id,
api: entry.api,
reasoning: false,
input: model.supportsVision ? ["text", "image"] : ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: model.contextWindow ?? 128_000,
maxTokens: model.maxTokens ?? 8192,
baseUrl,
metadataSource: "models-add",
};
}
export function providerConfig(entry: HermesProviderConfig): Record<string, unknown> {
const models = entry.models.length > 0 ? entry.models : [{ id: "default" }];
return {
baseUrl: normalizeProviderBaseUrl(entry.baseUrl, entry.api),
api: entry.api,
...(entry.headers ? { headers: entry.headers } : {}),
models: models.map((model) => modelDefinition(model, entry)),
};
}
export function readProviderBaseUrl(
raw: Record<string, unknown>,
env: Record<string, string>,
): { baseUrl?: string; sensitive: boolean; unresolved: boolean } {
const value =
normalizeOptionalString(raw.base_url) ??
normalizeOptionalString(raw.baseUrl) ??
normalizeOptionalString(raw.url) ??
normalizeOptionalString(raw.api);
if (!value) {
return { sensitive: false, unresolved: false };
}
const sensitive = MCP_ENV_REFERENCE_RE.test(value);
MCP_ENV_REFERENCE_RE.lastIndex = 0;
if (!sensitive) {
return { baseUrl: value, sensitive: false, unresolved: false };
}
const resolved = resolveMcpEnvReferences(value, env);
return {
baseUrl:
!resolved.unresolved && typeof resolved.value === "string" ? resolved.value : undefined,
sensitive: true,
unresolved: resolved.unresolved,
};
}
export function readProviderHeaders(
raw: Record<string, unknown>,
env: Record<string, string>,
includeSecrets: boolean,
): {
blocked: boolean;
headers?: Record<string, unknown>;
invalid: boolean;
sensitive: boolean;
unresolved: boolean;
} {
const source = isRecord(raw.extra_headers) ? raw.extra_headers : undefined;
if (!source || Object.keys(source).length === 0) {
return { blocked: false, invalid: false, sensitive: false, unresolved: false };
}
const headers: Record<string, unknown> = {};
let blocked = false;
let invalid = false;
let sensitive = false;
let unresolved = false;
for (const [name, rawValue] of Object.entries(source)) {
if (rawValue === null || rawValue === undefined) {
continue;
}
if (
typeof rawValue !== "string" &&
typeof rawValue !== "number" &&
typeof rawValue !== "boolean"
) {
invalid = true;
continue;
}
const value = String(rawValue);
const envName = readEnvReference(value);
const hasReference = mcpValueHasEnvReferences(value);
if (!includeSecrets) {
blocked = true;
continue;
}
sensitive = true;
if (envName) {
const resolved = env[envName];
if (resolved === undefined) {
unresolved = true;
continue;
}
headers[name] = resolved;
continue;
}
if (hasReference) {
const resolved = resolveMcpEnvReferences(value, env);
if (resolved.unresolved || typeof resolved.value !== "string") {
unresolved = true;
continue;
}
headers[name] = resolved.value;
continue;
}
headers[name] = value;
}
return {
blocked,
headers: Object.keys(headers).length > 0 ? headers : undefined,
invalid,
sensitive,
unresolved,
};
}