mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
* feat: Add Kilo Gateway provider Add support for Kilo Gateway as a model provider, similar to OpenRouter. Kilo Gateway provides a unified API that routes requests to many models behind a single endpoint and API key. Changes: - Add kilocode provider option to auth-choice and onboarding flows - Add KILOCODE_API_KEY environment variable support - Add kilocode/ model prefix handling in model-auth and extra-params - Add provider documentation in docs/providers/kilocode.md - Update model-providers.md with Kilo Gateway section - Add design doc for the integration * kilocode: add provider tests and normalize onboard auth-choice registration * kilocode: register in resolveImplicitProviders so models appear in provider filter * kilocode: update base URL from /api/openrouter/ to /api/gateway/ * docs: fix formatting in kilocode docs * fix: address PR review — remove kilocode from cacheRetention, fix stale model refs and CLI name in docs, fix TS2742 * docs: fix stale refs in design doc — Moltbot to OpenClaw, MoltbotConfig to OpenClawConfig, remove extra-params section, fix doc path * fix: use resolveAgentModelPrimaryValue for AgentModelConfig union type --------- Co-authored-by: Mark IJbema <mark@kilocode.ai>
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
import path from "node:path";
|
|
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
|
import { formatCliCommand } from "../cli/command-format.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
|
|
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
|
import {
|
|
normalizeOptionalSecretInput,
|
|
normalizeSecretInput,
|
|
} from "../utils/normalize-secret-input.js";
|
|
import {
|
|
type AuthProfileStore,
|
|
ensureAuthProfileStore,
|
|
listProfilesForProvider,
|
|
resolveApiKeyForProfile,
|
|
resolveAuthProfileOrder,
|
|
resolveAuthStorePathForDisplay,
|
|
} from "./auth-profiles.js";
|
|
import { normalizeProviderId } from "./model-selection.js";
|
|
|
|
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
|
|
|
|
const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK";
|
|
const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID";
|
|
const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY";
|
|
const AWS_PROFILE_ENV = "AWS_PROFILE";
|
|
|
|
function resolveProviderConfig(
|
|
cfg: OpenClawConfig | undefined,
|
|
provider: string,
|
|
): ModelProviderConfig | undefined {
|
|
const providers = cfg?.models?.providers ?? {};
|
|
const direct = providers[provider] as ModelProviderConfig | undefined;
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
const normalized = normalizeProviderId(provider);
|
|
if (normalized === provider) {
|
|
const matched = Object.entries(providers).find(
|
|
([key]) => normalizeProviderId(key) === normalized,
|
|
);
|
|
return matched?.[1];
|
|
}
|
|
return (
|
|
(providers[normalized] as ModelProviderConfig | undefined) ??
|
|
Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1]
|
|
);
|
|
}
|
|
|
|
export function getCustomProviderApiKey(
|
|
cfg: OpenClawConfig | undefined,
|
|
provider: string,
|
|
): string | undefined {
|
|
const entry = resolveProviderConfig(cfg, provider);
|
|
return normalizeOptionalSecretInput(entry?.apiKey);
|
|
}
|
|
|
|
function resolveProviderAuthOverride(
|
|
cfg: OpenClawConfig | undefined,
|
|
provider: string,
|
|
): ModelProviderAuthMode | undefined {
|
|
const entry = resolveProviderConfig(cfg, provider);
|
|
const auth = entry?.auth;
|
|
if (auth === "api-key" || auth === "aws-sdk" || auth === "oauth" || auth === "token") {
|
|
return auth;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveEnvSourceLabel(params: {
|
|
applied: Set<string>;
|
|
envVars: string[];
|
|
label: string;
|
|
}): string {
|
|
const shellApplied = params.envVars.some((envVar) => params.applied.has(envVar));
|
|
const prefix = shellApplied ? "shell env: " : "env: ";
|
|
return `${prefix}${params.label}`;
|
|
}
|
|
|
|
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
if (env[AWS_BEARER_ENV]?.trim()) {
|
|
return AWS_BEARER_ENV;
|
|
}
|
|
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
|
|
return AWS_ACCESS_KEY_ENV;
|
|
}
|
|
if (env[AWS_PROFILE_ENV]?.trim()) {
|
|
return AWS_PROFILE_ENV;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveAwsSdkAuthInfo(): { mode: "aws-sdk"; source: string } {
|
|
const applied = new Set(getShellEnvAppliedKeys());
|
|
if (process.env[AWS_BEARER_ENV]?.trim()) {
|
|
return {
|
|
mode: "aws-sdk",
|
|
source: resolveEnvSourceLabel({
|
|
applied,
|
|
envVars: [AWS_BEARER_ENV],
|
|
label: AWS_BEARER_ENV,
|
|
}),
|
|
};
|
|
}
|
|
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
|
|
return {
|
|
mode: "aws-sdk",
|
|
source: resolveEnvSourceLabel({
|
|
applied,
|
|
envVars: [AWS_ACCESS_KEY_ENV, AWS_SECRET_KEY_ENV],
|
|
label: `${AWS_ACCESS_KEY_ENV} + ${AWS_SECRET_KEY_ENV}`,
|
|
}),
|
|
};
|
|
}
|
|
if (process.env[AWS_PROFILE_ENV]?.trim()) {
|
|
return {
|
|
mode: "aws-sdk",
|
|
source: resolveEnvSourceLabel({
|
|
applied,
|
|
envVars: [AWS_PROFILE_ENV],
|
|
label: AWS_PROFILE_ENV,
|
|
}),
|
|
};
|
|
}
|
|
return { mode: "aws-sdk", source: "aws-sdk default chain" };
|
|
}
|
|
|
|
export type ResolvedProviderAuth = {
|
|
apiKey?: string;
|
|
profileId?: string;
|
|
source: string;
|
|
mode: "api-key" | "oauth" | "token" | "aws-sdk";
|
|
};
|
|
|
|
export async function resolveApiKeyForProvider(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
profileId?: string;
|
|
preferredProfile?: string;
|
|
store?: AuthProfileStore;
|
|
agentDir?: string;
|
|
}): Promise<ResolvedProviderAuth> {
|
|
const { provider, cfg, profileId, preferredProfile } = params;
|
|
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
|
|
|
if (profileId) {
|
|
const resolved = await resolveApiKeyForProfile({
|
|
cfg,
|
|
store,
|
|
profileId,
|
|
agentDir: params.agentDir,
|
|
});
|
|
if (!resolved) {
|
|
throw new Error(`No credentials found for profile "${profileId}".`);
|
|
}
|
|
const mode = store.profiles[profileId]?.type;
|
|
return {
|
|
apiKey: resolved.apiKey,
|
|
profileId,
|
|
source: `profile:${profileId}`,
|
|
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
|
|
};
|
|
}
|
|
|
|
const authOverride = resolveProviderAuthOverride(cfg, provider);
|
|
if (authOverride === "aws-sdk") {
|
|
return resolveAwsSdkAuthInfo();
|
|
}
|
|
|
|
const order = resolveAuthProfileOrder({
|
|
cfg,
|
|
store,
|
|
provider,
|
|
preferredProfile,
|
|
});
|
|
for (const candidate of order) {
|
|
try {
|
|
const resolved = await resolveApiKeyForProfile({
|
|
cfg,
|
|
store,
|
|
profileId: candidate,
|
|
agentDir: params.agentDir,
|
|
});
|
|
if (resolved) {
|
|
const mode = store.profiles[candidate]?.type;
|
|
return {
|
|
apiKey: resolved.apiKey,
|
|
profileId: candidate,
|
|
source: `profile:${candidate}`,
|
|
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
|
|
};
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const envResolved = resolveEnvApiKey(provider);
|
|
if (envResolved) {
|
|
return {
|
|
apiKey: envResolved.apiKey,
|
|
source: envResolved.source,
|
|
mode: envResolved.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key",
|
|
};
|
|
}
|
|
|
|
const customKey = getCustomProviderApiKey(cfg, provider);
|
|
if (customKey) {
|
|
return { apiKey: customKey, source: "models.json", mode: "api-key" };
|
|
}
|
|
|
|
const normalized = normalizeProviderId(provider);
|
|
if (authOverride === undefined && normalized === "amazon-bedrock") {
|
|
return resolveAwsSdkAuthInfo();
|
|
}
|
|
|
|
if (provider === "openai") {
|
|
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
|
|
if (hasCodex) {
|
|
throw new Error(
|
|
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.3-codex (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.1-codex.',
|
|
);
|
|
}
|
|
}
|
|
|
|
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
|
|
const resolvedAgentDir = path.dirname(authStorePath);
|
|
throw new Error(
|
|
[
|
|
`No API key found for provider "${provider}".`,
|
|
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
|
|
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
|
|
].join(" "),
|
|
);
|
|
}
|
|
|
|
export type EnvApiKeyResult = { apiKey: string; source: string };
|
|
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown";
|
|
|
|
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
|
const normalized = normalizeProviderId(provider);
|
|
const applied = new Set(getShellEnvAppliedKeys());
|
|
const pick = (envVar: string): EnvApiKeyResult | null => {
|
|
const value = normalizeOptionalSecretInput(process.env[envVar]);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
|
|
return { apiKey: value, source };
|
|
};
|
|
|
|
if (normalized === "github-copilot") {
|
|
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
|
|
}
|
|
|
|
if (normalized === "anthropic") {
|
|
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
|
|
}
|
|
|
|
if (normalized === "chutes") {
|
|
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
|
|
}
|
|
|
|
if (normalized === "zai") {
|
|
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
|
|
}
|
|
|
|
if (normalized === "google-vertex") {
|
|
const envKey = getEnvApiKey(normalized);
|
|
if (!envKey) {
|
|
return null;
|
|
}
|
|
return { apiKey: envKey, source: "gcloud adc" };
|
|
}
|
|
|
|
if (normalized === "opencode") {
|
|
return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY");
|
|
}
|
|
|
|
if (normalized === "qwen-portal") {
|
|
return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY");
|
|
}
|
|
|
|
if (normalized === "volcengine" || normalized === "volcengine-plan") {
|
|
return pick("VOLCANO_ENGINE_API_KEY");
|
|
}
|
|
|
|
if (normalized === "byteplus" || normalized === "byteplus-plan") {
|
|
return pick("BYTEPLUS_API_KEY");
|
|
}
|
|
if (normalized === "minimax-portal") {
|
|
return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY");
|
|
}
|
|
|
|
if (normalized === "kimi-coding") {
|
|
return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY");
|
|
}
|
|
|
|
if (normalized === "huggingface") {
|
|
return pick("HUGGINGFACE_HUB_TOKEN") ?? pick("HF_TOKEN");
|
|
}
|
|
|
|
const envMap: Record<string, string> = {
|
|
openai: "OPENAI_API_KEY",
|
|
google: "GEMINI_API_KEY",
|
|
voyage: "VOYAGE_API_KEY",
|
|
groq: "GROQ_API_KEY",
|
|
deepgram: "DEEPGRAM_API_KEY",
|
|
cerebras: "CEREBRAS_API_KEY",
|
|
xai: "XAI_API_KEY",
|
|
openrouter: "OPENROUTER_API_KEY",
|
|
litellm: "LITELLM_API_KEY",
|
|
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
|
"cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
|
moonshot: "MOONSHOT_API_KEY",
|
|
minimax: "MINIMAX_API_KEY",
|
|
nvidia: "NVIDIA_API_KEY",
|
|
xiaomi: "XIAOMI_API_KEY",
|
|
synthetic: "SYNTHETIC_API_KEY",
|
|
venice: "VENICE_API_KEY",
|
|
mistral: "MISTRAL_API_KEY",
|
|
opencode: "OPENCODE_API_KEY",
|
|
together: "TOGETHER_API_KEY",
|
|
qianfan: "QIANFAN_API_KEY",
|
|
ollama: "OLLAMA_API_KEY",
|
|
vllm: "VLLM_API_KEY",
|
|
kilocode: "KILOCODE_API_KEY",
|
|
};
|
|
const envVar = envMap[normalized];
|
|
if (!envVar) {
|
|
return null;
|
|
}
|
|
return pick(envVar);
|
|
}
|
|
|
|
export function resolveModelAuthMode(
|
|
provider?: string,
|
|
cfg?: OpenClawConfig,
|
|
store?: AuthProfileStore,
|
|
): ModelAuthMode | undefined {
|
|
const resolved = provider?.trim();
|
|
if (!resolved) {
|
|
return undefined;
|
|
}
|
|
|
|
const authOverride = resolveProviderAuthOverride(cfg, resolved);
|
|
if (authOverride === "aws-sdk") {
|
|
return "aws-sdk";
|
|
}
|
|
|
|
const authStore = store ?? ensureAuthProfileStore();
|
|
const profiles = listProfilesForProvider(authStore, resolved);
|
|
if (profiles.length > 0) {
|
|
const modes = new Set(
|
|
profiles
|
|
.map((id) => authStore.profiles[id]?.type)
|
|
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
|
|
);
|
|
const distinct = ["oauth", "token", "api_key"].filter((k) =>
|
|
modes.has(k as "oauth" | "token" | "api_key"),
|
|
);
|
|
if (distinct.length >= 2) {
|
|
return "mixed";
|
|
}
|
|
if (modes.has("oauth")) {
|
|
return "oauth";
|
|
}
|
|
if (modes.has("token")) {
|
|
return "token";
|
|
}
|
|
if (modes.has("api_key")) {
|
|
return "api-key";
|
|
}
|
|
}
|
|
|
|
if (authOverride === undefined && normalizeProviderId(resolved) === "amazon-bedrock") {
|
|
return "aws-sdk";
|
|
}
|
|
|
|
const envKey = resolveEnvApiKey(resolved);
|
|
if (envKey?.apiKey) {
|
|
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
|
}
|
|
|
|
if (getCustomProviderApiKey(cfg, resolved)) {
|
|
return "api-key";
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
|
|
export async function getApiKeyForModel(params: {
|
|
model: Model<Api>;
|
|
cfg?: OpenClawConfig;
|
|
profileId?: string;
|
|
preferredProfile?: string;
|
|
store?: AuthProfileStore;
|
|
agentDir?: string;
|
|
}): Promise<ResolvedProviderAuth> {
|
|
return resolveApiKeyForProvider({
|
|
provider: params.model.provider,
|
|
cfg: params.cfg,
|
|
profileId: params.profileId,
|
|
preferredProfile: params.preferredProfile,
|
|
store: params.store,
|
|
agentDir: params.agentDir,
|
|
});
|
|
}
|
|
|
|
export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string {
|
|
const key = normalizeSecretInput(auth.apiKey);
|
|
if (key) {
|
|
return key;
|
|
}
|
|
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
|
|
}
|