feat(agents) : Hugging Face Inference provider first-class support and Together API fix and Direct Injection Refactor Auths [AI-assisted] (#13472)

* initial commit

* removes assesment from docs

* resolves automated review comments

* resolves lint , type , tests , refactors , and submits

* solves : why do we have to lint the tests xD

* adds greptile fixes

* solves a type error

* solves a ci error

* refactors auths

* solves a failing test after i pulled from main lol

* solves a failing test after i pulled from main lol

* resolves token naming issue to comply with better practices when using hf / huggingface

* fixes curly lints !

* fixes failing tests for google api from main

* solve merge conflicts

* solve failing tests with a defensive check 'undefined' openrouterapi key

* fix: preserve Hugging Face auth-choice intent and token behavior (#13472) (thanks @Josephrp)

* test: resolve auth-choice cherry-pick conflict cleanup (#13472)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Tonic
2026-02-13 16:18:16 +01:00
committed by GitHub
parent e50ce897b0
commit 08b7932df0
27 changed files with 1617 additions and 355 deletions

View File

@@ -1,35 +1,13 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { AuthChoice } from "./onboard-types.js";
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
export type { AuthChoiceGroupId };
export type AuthChoiceOption = {
value: AuthChoice;
label: string;
hint?: string;
};
export type AuthChoiceGroupId =
| "openai"
| "anthropic"
| "vllm"
| "google"
| "copilot"
| "openrouter"
| "litellm"
| "ai-gateway"
| "cloudflare-ai-gateway"
| "moonshot"
| "zai"
| "xiaomi"
| "opencode-zen"
| "minimax"
| "synthetic"
| "venice"
| "qwen"
| "together"
| "qianfan"
| "xai"
| "custom";
export type AuthChoiceGroup = {
value: AuthChoiceGroupId;
label: string;
@@ -145,6 +123,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
hint: "API key",
choices: ["together-api-key"],
},
{
value: "huggingface",
label: "Hugging Face",
hint: "Inference API (HF token)",
choices: ["huggingface-api-key"],
},
{
value: "venice",
label: "Venice AI",
@@ -238,6 +222,11 @@ export function buildAuthChoiceOptions(params: {
label: "Together AI API key",
hint: "Access to Llama, DeepSeek, Qwen, and more open models",
});
options.push({
value: "huggingface-api-key",
label: "Hugging Face API key (HF token)",
hint: "Inference Providers — OpenAI-compatible chat",
});
options.push({
value: "github-copilot",
label: "GitHub Copilot (GitHub device login)",

View File

@@ -6,6 +6,8 @@ import {
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js";
import { applyAuthChoiceOpenRouter } from "./auth-choice.apply.openrouter.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyGoogleGeminiModelDefault,
@@ -27,8 +29,6 @@ import {
applyMoonshotProviderConfigCn,
applyOpencodeZenConfig,
applyOpencodeZenProviderConfig,
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
applySyntheticConfig,
applySyntheticProviderConfig,
applyTogetherConfig,
@@ -46,7 +46,6 @@ import {
QIANFAN_DEFAULT_MODEL_REF,
KIMI_CODING_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF,
TOGETHER_DEFAULT_MODEL_REF,
VENICE_DEFAULT_MODEL_REF,
@@ -59,7 +58,6 @@ import {
setKimiCodingApiKey,
setMoonshotApiKey,
setOpencodeZenApiKey,
setOpenrouterApiKey,
setSyntheticApiKey,
setTogetherApiKey,
setVeniceApiKey,
@@ -120,6 +118,8 @@ export async function applyAuthChoiceApiProviders(
authChoice = "venice-api-key";
} else if (params.opts.tokenProvider === "together") {
authChoice = "together-api-key";
} else if (params.opts.tokenProvider === "huggingface") {
authChoice = "huggingface-api-key";
} else if (params.opts.tokenProvider === "opencode") {
authChoice = "opencode-zen";
} else if (params.opts.tokenProvider === "qianfan") {
@@ -128,81 +128,7 @@ export async function applyAuthChoiceApiProviders(
}
if (authChoice === "openrouter-api-key") {
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profileOrder = resolveAuthProfileOrder({
cfg: nextConfig,
store,
provider: "openrouter",
});
const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId]));
const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined;
let profileId = "openrouter:default";
let mode: "api_key" | "oauth" | "token" = "api_key";
let hasCredential = false;
if (existingProfileId && existingCred?.type) {
profileId = existingProfileId;
mode =
existingCred.type === "oauth"
? "oauth"
: existingCred.type === "token"
? "token"
: "api_key";
hasCredential = true;
}
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("openrouter");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setOpenrouterApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter OpenRouter API key",
validate: validateApiKeyInput,
});
await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir);
hasCredential = true;
}
if (hasCredential) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "openrouter",
mode,
});
}
{
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
applyDefaultConfig: applyOpenrouterConfig,
applyProviderConfig: applyOpenrouterProviderConfig,
noteDefault: OPENROUTER_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
}
return { config: nextConfig, agentModelOverride };
return applyAuthChoiceOpenRouter(params);
}
if (authChoice === "litellm-api-key") {
@@ -993,6 +919,10 @@ export async function applyAuthChoiceApiProviders(
return { config: nextConfig, agentModelOverride };
}
if (authChoice === "huggingface-api-key") {
return applyAuthChoiceHuggingface({ ...params, authChoice });
}
if (authChoice === "qianfan-api-key") {
let hasCredential = false;
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "qianfan") {

View File

@@ -0,0 +1,163 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js";
const noopAsync = async () => {};
const noop = () => {};
const authProfilePathFor = (agentDir: string) => path.join(agentDir, "auth-profiles.json");
describe("applyAuthChoiceHuggingface", () => {
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
const previousHfToken = process.env.HF_TOKEN;
const previousHubToken = process.env.HUGGINGFACE_HUB_TOKEN;
let tempStateDir: string | null = null;
afterEach(async () => {
if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true });
tempStateDir = null;
}
if (previousAgentDir === undefined) {
delete process.env.OPENCLAW_AGENT_DIR;
} else {
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
}
if (previousHfToken === undefined) {
delete process.env.HF_TOKEN;
} else {
process.env.HF_TOKEN = previousHfToken;
}
if (previousHubToken === undefined) {
delete process.env.HUGGINGFACE_HUB_TOKEN;
} else {
process.env.HUGGINGFACE_HUB_TOKEN = previousHubToken;
}
});
it("returns null when authChoice is not huggingface-api-key", async () => {
const result = await applyAuthChoiceHuggingface({
authChoice: "openrouter-api-key",
config: {},
prompter: {} as WizardPrompter,
runtime: {} as RuntimeEnv,
setDefaultModel: false,
});
expect(result).toBeNull();
});
it("prompts for key and model, then writes config and auth profile", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hf-"));
const agentDir = path.join(tempStateDir, "agent");
process.env.OPENCLAW_AGENT_DIR = agentDir;
await fs.mkdir(agentDir, { recursive: true });
const text = vi.fn().mockResolvedValue("hf-test-token");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options?.[0]?.value as never,
);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect: vi.fn(async () => []),
text,
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoiceHuggingface({
authChoice: "huggingface-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(result).not.toBeNull();
expect(result?.config.auth?.profiles?.["huggingface:default"]).toMatchObject({
provider: "huggingface",
mode: "api_key",
});
expect(result?.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/);
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Hugging Face") }),
);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "Default Hugging Face model" }),
);
const authProfilePath = authProfilePathFor(agentDir);
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-test-token");
});
it("does not prompt to reuse env token when opts.token already provided", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hf-"));
const agentDir = path.join(tempStateDir, "agent");
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.HF_TOKEN = "hf-env-token";
delete process.env.HUGGINGFACE_HUB_TOKEN;
await fs.mkdir(agentDir, { recursive: true });
const text = vi.fn().mockResolvedValue("hf-text-token");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options?.[0]?.value as never,
);
const confirm = vi.fn(async () => true);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect: vi.fn(async () => []),
text,
confirm,
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoiceHuggingface({
authChoice: "huggingface-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "huggingface",
token: "hf-opts-token",
},
});
expect(result).not.toBeNull();
expect(confirm).not.toHaveBeenCalled();
expect(text).not.toHaveBeenCalled();
const authProfilePath = authProfilePathFor(agentDir);
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-opts-token");
});
});

View File

@@ -0,0 +1,165 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import {
discoverHuggingfaceModels,
isHuggingfacePolicyLocked,
} from "../agents/huggingface-models.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import { ensureModelAllowlistEntry } from "./model-allowlist.js";
import {
applyAuthProfileConfig,
applyHuggingfaceProviderConfig,
setHuggingfaceApiKey,
HUGGINGFACE_DEFAULT_MODEL_REF,
} from "./onboard-auth.js";
export async function applyAuthChoiceHuggingface(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice !== "huggingface-api-key") {
return null;
}
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const noteAgentModel = async (model: string) => {
if (!params.agentId) {
return;
}
await params.prompter.note(
`Default model set to ${model} for agent "${params.agentId}".`,
"Model configured",
);
};
let hasCredential = false;
let hfKey = "";
if (!hasCredential && params.opts?.token && params.opts.tokenProvider === "huggingface") {
hfKey = normalizeApiKeyInput(params.opts.token);
await setHuggingfaceApiKey(hfKey, params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
await params.prompter.note(
[
"Hugging Face Inference Providers offer OpenAI-compatible chat completions.",
"Create a token at: https://huggingface.co/settings/tokens (fine-grained, 'Make calls to Inference Providers').",
].join("\n"),
"Hugging Face",
);
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("huggingface");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing Hugging Face token (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
hfKey = envKey.apiKey;
await setHuggingfaceApiKey(hfKey, params.agentDir);
hasCredential = true;
}
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter Hugging Face API key (HF token)",
validate: validateApiKeyInput,
});
hfKey = normalizeApiKeyInput(String(key ?? ""));
await setHuggingfaceApiKey(hfKey, params.agentDir);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "huggingface:default",
provider: "huggingface",
mode: "api_key",
});
const models = await discoverHuggingfaceModels(hfKey);
const modelRefPrefix = "huggingface/";
const options: { value: string; label: string }[] = [];
for (const m of models) {
const baseRef = `${modelRefPrefix}${m.id}`;
const label = m.name ?? m.id;
options.push({ value: baseRef, label });
options.push({ value: `${baseRef}:cheapest`, label: `${label} (cheapest)` });
options.push({ value: `${baseRef}:fastest`, label: `${label} (fastest)` });
}
const defaultRef = HUGGINGFACE_DEFAULT_MODEL_REF;
options.sort((a, b) => {
if (a.value === defaultRef) {
return -1;
}
if (b.value === defaultRef) {
return 1;
}
return a.label.localeCompare(b.label, undefined, { sensitivity: "base" });
});
const selectedModelRef =
options.length === 0
? defaultRef
: options.length === 1
? options[0].value
: await params.prompter.select({
message: "Default Hugging Face model",
options,
initialValue: options.some((o) => o.value === defaultRef)
? defaultRef
: options[0].value,
});
if (isHuggingfacePolicyLocked(selectedModelRef)) {
await params.prompter.note(
"Provider locked — router will choose backend by cost or speed.",
"Hugging Face",
);
}
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: selectedModelRef,
applyDefaultConfig: (config) => {
const withProvider = applyHuggingfaceProviderConfig(config);
const existingModel = withProvider.agents?.defaults?.model;
const withPrimary = {
...withProvider,
agents: {
...withProvider.agents,
defaults: {
...withProvider.agents?.defaults,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: {}),
primary: selectedModelRef,
},
},
},
};
return ensureModelAllowlistEntry({
cfg: withPrimary,
modelRef: selectedModelRef,
});
},
applyProviderConfig: applyHuggingfaceProviderConfig,
noteDefault: selectedModelRef,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
return { config: nextConfig, agentModelOverride };
}

View File

@@ -0,0 +1,102 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
validateApiKeyInput,
} from "./auth-choice.api-key.js";
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import {
applyAuthProfileConfig,
applyOpenrouterConfig,
applyOpenrouterProviderConfig,
setOpenrouterApiKey,
OPENROUTER_DEFAULT_MODEL_REF,
} from "./onboard-auth.js";
export async function applyAuthChoiceOpenRouter(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult> {
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const noteAgentModel = async (model: string) => {
if (!params.agentId) {
return;
}
await params.prompter.note(
`Default model set to ${model} for agent "${params.agentId}".`,
"Model configured",
);
};
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const profileOrder = resolveAuthProfileOrder({
cfg: nextConfig,
store,
provider: "openrouter",
});
const existingProfileId = profileOrder.find((profileId) => Boolean(store.profiles[profileId]));
const existingCred = existingProfileId ? store.profiles[existingProfileId] : undefined;
let profileId = "openrouter:default";
let mode: "api_key" | "oauth" | "token" = "api_key";
let hasCredential = false;
if (existingProfileId && existingCred?.type) {
profileId = existingProfileId;
mode =
existingCred.type === "oauth" ? "oauth" : existingCred.type === "token" ? "token" : "api_key";
hasCredential = true;
}
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "openrouter") {
await setOpenrouterApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
hasCredential = true;
}
if (!hasCredential) {
const envKey = resolveEnvApiKey("openrouter");
if (envKey) {
const useExisting = await params.prompter.confirm({
message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
initialValue: true,
});
if (useExisting) {
await setOpenrouterApiKey(envKey.apiKey, params.agentDir);
hasCredential = true;
}
}
}
if (!hasCredential) {
const key = await params.prompter.text({
message: "Enter OpenRouter API key",
validate: validateApiKeyInput,
});
await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir);
hasCredential = true;
}
if (hasCredential) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId,
provider: "openrouter",
mode,
});
}
const applied = await applyDefaultModelChoice({
config: nextConfig,
setDefaultModel: params.setDefaultModel,
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
applyDefaultConfig: applyOpenrouterConfig,
applyProviderConfig: applyOpenrouterProviderConfig,
noteDefault: OPENROUTER_DEFAULT_MODEL_REF,
noteAgentModel,
prompter: params.prompter,
});
nextConfig = applied.config;
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
return { config: nextConfig, agentModelOverride };
}

View File

@@ -34,6 +34,8 @@ describe("applyAuthChoice", () => {
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
const previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
const previousOpenrouterKey = process.env.OPENROUTER_API_KEY;
const previousHfToken = process.env.HF_TOKEN;
const previousHfHubToken = process.env.HUGGINGFACE_HUB_TOKEN;
const previousLitellmKey = process.env.LITELLM_API_KEY;
const previousAiGatewayKey = process.env.AI_GATEWAY_API_KEY;
const previousCloudflareGatewayKey = process.env.CLOUDFLARE_AI_GATEWAY_API_KEY;
@@ -73,6 +75,16 @@ describe("applyAuthChoice", () => {
} else {
process.env.OPENROUTER_API_KEY = previousOpenrouterKey;
}
if (previousHfToken === undefined) {
delete process.env.HF_TOKEN;
} else {
process.env.HF_TOKEN = previousHfToken;
}
if (previousHfHubToken === undefined) {
delete process.env.HUGGINGFACE_HUB_TOKEN;
} else {
process.env.HUGGINGFACE_HUB_TOKEN = previousHfHubToken;
}
if (previousLitellmKey === undefined) {
delete process.env.LITELLM_API_KEY;
} else {
@@ -206,6 +218,60 @@ describe("applyAuthChoice", () => {
expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test");
});
it("prompts and writes Hugging Face API key when selecting huggingface-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
const text = vi.fn().mockResolvedValue("hf-test-token");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options[0]?.value as never,
);
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect,
text,
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoice({
authChoice: "huggingface-api-key",
config: {},
prompter,
runtime,
setDefaultModel: true,
});
expect(text).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Hugging Face") }),
);
expect(result.config.auth?.profiles?.["huggingface:default"]).toMatchObject({
provider: "huggingface",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/);
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-test-token");
});
it("prompts for Z.AI endpoint when selecting zai-api-key", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
@@ -301,6 +367,64 @@ describe("applyAuthChoice", () => {
expect(result.config.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_GLOBAL_BASE_URL);
});
it("maps apiKey + tokenProvider=huggingface to huggingface-api-key flow", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
delete process.env.HF_TOKEN;
delete process.env.HUGGINGFACE_HUB_TOKEN;
const text = vi.fn().mockResolvedValue("should-not-be-used");
const select: WizardPrompter["select"] = vi.fn(
async (params) => params.options[0]?.value as never,
);
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const confirm = vi.fn(async () => false);
const prompter: WizardPrompter = {
intro: vi.fn(noopAsync),
outro: vi.fn(noopAsync),
note: vi.fn(noopAsync),
select,
multiselect,
text,
confirm,
progress: vi.fn(() => ({ update: noop, stop: noop })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
const result = await applyAuthChoice({
authChoice: "apiKey",
config: {},
prompter,
runtime,
setDefaultModel: true,
opts: {
tokenProvider: "huggingface",
token: "hf-token-provider-test",
},
});
expect(result.config.auth?.profiles?.["huggingface:default"]).toMatchObject({
provider: "huggingface",
mode: "api_key",
});
expect(result.config.agents?.defaults?.model?.primary).toMatch(/^huggingface\/.+/);
expect(text).not.toHaveBeenCalled();
const authProfilePath = authProfilePathFor(requireAgentDir());
const raw = await fs.readFile(authProfilePath, "utf8");
const parsed = JSON.parse(raw) as {
profiles?: Record<string, { key?: string }>;
};
expect(parsed.profiles?.["huggingface:default"]?.key).toBe("hf-token-provider-test");
});
it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;

View File

@@ -29,6 +29,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"synthetic-api-key": "synthetic",
"venice-api-key": "venice",
"together-api-key": "together",
"huggingface-api-key": "huggingface",
"github-copilot": "github-copilot",
"copilot-proxy": "copilot-proxy",
"minimax-cloud": "minimax",

View File

@@ -1,9 +1,10 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelApi } from "../config/types.models.js";
import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "../agents/cloudflare-ai-gateway.js";
buildHuggingfaceModelDefinition,
HUGGINGFACE_BASE_URL,
HUGGINGFACE_MODEL_CATALOG,
} from "../agents/huggingface-models.js";
import {
buildQianfanProvider,
buildXiaomiProvider,
@@ -28,15 +29,25 @@ import {
VENICE_MODEL_CATALOG,
} from "../agents/venice-models.js";
import {
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
LITELLM_DEFAULT_MODEL_REF,
HUGGINGFACE_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF,
TOGETHER_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
ZAI_DEFAULT_MODEL_REF,
XAI_DEFAULT_MODEL_REF,
} from "./onboard-auth.credentials.js";
export {
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyVercelAiGatewayConfig,
applyVercelAiGatewayProviderConfig,
} from "./onboard-auth.config-gateways.js";
export {
applyLitellmConfig,
applyLitellmProviderConfig,
LITELLM_BASE_URL,
LITELLM_DEFAULT_MODEL_ID,
} from "./onboard-auth.config-litellm.js";
import {
buildZaiModelDefinition,
buildMoonshotModelDefinition,
@@ -170,139 +181,6 @@ export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConf
};
}
export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyCloudflareAiGatewayProviderConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers["cloudflare-ai-gateway"];
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildCloudflareAiGatewayModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === defaultModel.id);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const baseUrl =
params?.accountId && params?.gatewayId
? resolveCloudflareAiGatewayBaseUrl({
accountId: params.accountId,
gatewayId: params.gatewayId,
})
: existingProvider?.baseUrl;
if (!baseUrl) {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers["cloudflare-ai-gateway"] = {
...existingProviderRest,
baseUrl,
api: "anthropic-messages",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyVercelAiGatewayProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyCloudflareAiGatewayConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const next = applyCloudflareAiGatewayProviderConfig(cfg, params);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyOpenrouterProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
@@ -325,105 +203,6 @@ export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
};
}
export const LITELLM_BASE_URL = "http://localhost:4000";
export const LITELLM_DEFAULT_MODEL_ID = "claude-opus-4-6";
const LITELLM_DEFAULT_CONTEXT_WINDOW = 128_000;
const LITELLM_DEFAULT_MAX_TOKENS = 8_192;
const LITELLM_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function buildLitellmModelDefinition(): {
id: string;
name: string;
reasoning: boolean;
input: Array<"text" | "image">;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
} {
return {
id: LITELLM_DEFAULT_MODEL_ID,
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
// LiteLLM routes to many upstreams; keep neutral placeholders.
cost: LITELLM_DEFAULT_COST,
contextWindow: LITELLM_DEFAULT_CONTEXT_WINDOW,
maxTokens: LITELLM_DEFAULT_MAX_TOKENS,
};
}
export function applyLitellmProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[LITELLM_DEFAULT_MODEL_REF] = {
...models[LITELLM_DEFAULT_MODEL_REF],
alias: models[LITELLM_DEFAULT_MODEL_REF]?.alias ?? "LiteLLM",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers.litellm;
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildLitellmModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === LITELLM_DEFAULT_MODEL_ID);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers.litellm = {
...existingProviderRest,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
api: "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyLitellmConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyLitellmProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: LITELLM_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL);
}
@@ -855,6 +634,79 @@ export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig {
};
}
/**
* Apply Hugging Face (Inference Providers) provider configuration without changing the default model.
*/
export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[HUGGINGFACE_DEFAULT_MODEL_REF] = {
...models[HUGGINGFACE_DEFAULT_MODEL_REF],
alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers.huggingface;
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const hfModels = HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
const mergedModels = [
...existingModels,
...hfModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)),
];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers.huggingface = {
...existingProviderRest,
baseUrl: HUGGINGFACE_BASE_URL,
api: "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : hfModels,
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
/**
* Apply Hugging Face provider configuration AND set Hugging Face as the default model.
*/
export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyHuggingfaceProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: HUGGINGFACE_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[XAI_DEFAULT_MODEL_REF] = {

View File

@@ -0,0 +1,142 @@
import type { OpenClawConfig } from "../config/config.js";
import {
buildCloudflareAiGatewayModelDefinition,
resolveCloudflareAiGatewayBaseUrl,
} from "../agents/cloudflare-ai-gateway.js";
import {
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
} from "./onboard-auth.credentials.js";
export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyCloudflareAiGatewayProviderConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers["cloudflare-ai-gateway"];
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildCloudflareAiGatewayModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === defaultModel.id);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const baseUrl =
params?.accountId && params?.gatewayId
? resolveCloudflareAiGatewayBaseUrl({
accountId: params.accountId,
gatewayId: params.gatewayId,
})
: existingProvider?.baseUrl;
if (!baseUrl) {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers["cloudflare-ai-gateway"] = {
...existingProviderRest,
baseUrl,
api: "anthropic-messages",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyVercelAiGatewayProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}
export function applyCloudflareAiGatewayConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const next = applyCloudflareAiGatewayProviderConfig(cfg, params);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
},
},
},
};
}

View File

@@ -0,0 +1,100 @@
import type { OpenClawConfig } from "../config/config.js";
import { LITELLM_DEFAULT_MODEL_REF } from "./onboard-auth.credentials.js";
export const LITELLM_BASE_URL = "http://localhost:4000";
export const LITELLM_DEFAULT_MODEL_ID = "claude-opus-4-6";
const LITELLM_DEFAULT_CONTEXT_WINDOW = 128_000;
const LITELLM_DEFAULT_MAX_TOKENS = 8_192;
const LITELLM_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
function buildLitellmModelDefinition(): {
id: string;
name: string;
reasoning: boolean;
input: Array<"text" | "image">;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
} {
return {
id: LITELLM_DEFAULT_MODEL_ID,
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
cost: LITELLM_DEFAULT_COST,
contextWindow: LITELLM_DEFAULT_CONTEXT_WINDOW,
maxTokens: LITELLM_DEFAULT_MAX_TOKENS,
};
}
export function applyLitellmProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[LITELLM_DEFAULT_MODEL_REF] = {
...models[LITELLM_DEFAULT_MODEL_REF],
alias: models[LITELLM_DEFAULT_MODEL_REF]?.alias ?? "LiteLLM",
};
const providers = { ...cfg.models?.providers };
const existingProvider = providers.litellm;
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
const defaultModel = buildLitellmModelDefinition();
const hasDefaultModel = existingModels.some((model) => model.id === LITELLM_DEFAULT_MODEL_ID);
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
unknown
> as { apiKey?: string };
const resolvedBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim();
providers.litellm = {
...existingProviderRest,
baseUrl: resolvedBaseUrl || LITELLM_BASE_URL,
api: "openai-completions",
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: {
mode: cfg.models?.mode ?? "merge",
providers,
},
};
}
export function applyLitellmConfig(cfg: OpenClawConfig): OpenClawConfig {
const next = applyLitellmProviderConfig(cfg);
const existingModel = next.agents?.defaults?.model;
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
? {
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
}
: undefined),
primary: LITELLM_DEFAULT_MODEL_REF,
},
},
},
};
}

View File

@@ -118,6 +118,7 @@ export async function setVeniceApiKey(key: string, agentDir?: string) {
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-5";
export const XIAOMI_DEFAULT_MODEL_REF = "xiaomi/mimo-v2-flash";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1";
export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5";
export const LITELLM_DEFAULT_MODEL_REF = "litellm/claude-opus-4-6";
export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6";
@@ -148,12 +149,14 @@ export async function setXiaomiApiKey(key: string, agentDir?: string) {
}
export async function setOpenrouterApiKey(key: string, agentDir?: string) {
// Never persist the literal "undefined" (e.g. when prompt returns undefined and caller used String(key)).
const safeKey = key === "undefined" ? "" : key;
upsertAuthProfile({
profileId: "openrouter:default",
credential: {
type: "api_key",
provider: "openrouter",
key,
key: safeKey,
},
agentDir: resolveAuthAgentDir(agentDir),
});
@@ -231,6 +234,18 @@ export async function setTogetherApiKey(key: string, agentDir?: string) {
});
}
export async function setHuggingfaceApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "huggingface:default",
credential: {
type: "api_key",
provider: "huggingface",
key,
},
agentDir: resolveAuthAgentDir(agentDir),
});
}
export function setQianfanApiKey(key: string, agentDir?: string) {
upsertAuthProfile({
profileId: "qianfan:default",

View File

@@ -7,6 +7,8 @@ export {
applyAuthProfileConfig,
applyCloudflareAiGatewayConfig,
applyCloudflareAiGatewayProviderConfig,
applyHuggingfaceConfig,
applyHuggingfaceProviderConfig,
applyQianfanConfig,
applyQianfanProviderConfig,
applyKimiCodeConfig,
@@ -63,12 +65,14 @@ export {
setOpenrouterApiKey,
setSyntheticApiKey,
setTogetherApiKey,
setHuggingfaceApiKey,
setVeniceApiKey,
setVercelAiGatewayApiKey,
setXiaomiApiKey,
setZaiApiKey,
setXaiApiKey,
writeOAuthCredentials,
HUGGINGFACE_DEFAULT_MODEL_REF,
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
XIAOMI_DEFAULT_MODEL_REF,
ZAI_DEFAULT_MODEL_REF,

View File

@@ -450,6 +450,36 @@ describe("onboard (non-interactive): provider auth", () => {
});
}, 60_000);
it("infers Together auth choice from --together-api-key and sets default model", async () => {
await withOnboardEnv("openclaw-onboard-together-infer-", async ({ configPath, runtime }) => {
await runNonInteractive(
{
nonInteractive: true,
togetherApiKey: "together-test-key",
skipHealth: true,
skipChannels: true,
skipSkills: true,
json: true,
},
runtime,
);
const cfg = await readJsonFile<{
auth?: { profiles?: Record<string, { provider?: string; mode?: string }> };
agents?: { defaults?: { model?: { primary?: string } } };
}>(configPath);
expect(cfg.auth?.profiles?.["together:default"]?.provider).toBe("together");
expect(cfg.auth?.profiles?.["together:default"]?.mode).toBe("api_key");
expect(cfg.agents?.defaults?.model?.primary).toBe("together/moonshotai/Kimi-K2.5");
await expectApiKeyProfile({
profileId: "together:default",
provider: "together",
key: "together-test-key",
});
});
}, 60_000);
it("configures a custom provider from non-interactive flags", async () => {
await withOnboardEnv("openclaw-onboard-custom-provider-", async ({ configPath, runtime }) => {
await runNonInteractive(

View File

@@ -18,6 +18,8 @@ type AuthChoiceFlagOptions = Pick<
| "kimiCodeApiKey"
| "syntheticApiKey"
| "veniceApiKey"
| "togetherApiKey"
| "huggingfaceApiKey"
| "zaiApiKey"
| "xiaomiApiKey"
| "minimaxApiKey"
@@ -44,11 +46,13 @@ const AUTH_CHOICE_FLAG_MAP = [
{ flag: "kimiCodeApiKey", authChoice: "kimi-code-api-key", label: "--kimi-code-api-key" },
{ flag: "syntheticApiKey", authChoice: "synthetic-api-key", label: "--synthetic-api-key" },
{ flag: "veniceApiKey", authChoice: "venice-api-key", label: "--venice-api-key" },
{ flag: "togetherApiKey", authChoice: "together-api-key", label: "--together-api-key" },
{ flag: "zaiApiKey", authChoice: "zai-api-key", label: "--zai-api-key" },
{ flag: "xiaomiApiKey", authChoice: "xiaomi-api-key", label: "--xiaomi-api-key" },
{ flag: "xaiApiKey", authChoice: "xai-api-key", label: "--xai-api-key" },
{ flag: "minimaxApiKey", authChoice: "minimax-api", label: "--minimax-api-key" },
{ flag: "opencodeZenApiKey", authChoice: "opencode-zen", label: "--opencode-zen-api-key" },
{ flag: "huggingfaceApiKey", authChoice: "huggingface-api-key", label: "--huggingface-api-key" },
{ flag: "litellmApiKey", authChoice: "litellm-api-key", label: "--litellm-api-key" },
] satisfies ReadonlyArray<AuthChoiceFlag>;

View File

@@ -23,6 +23,7 @@ import {
applySyntheticConfig,
applyVeniceConfig,
applyTogetherConfig,
applyHuggingfaceConfig,
applyVercelAiGatewayConfig,
applyLitellmConfig,
applyXaiConfig,
@@ -42,6 +43,7 @@ import {
setXaiApiKey,
setVeniceApiKey,
setTogetherApiKey,
setHuggingfaceApiKey,
setVercelAiGatewayApiKey,
setXiaomiApiKey,
setZaiApiKey,
@@ -644,6 +646,29 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyTogetherConfig(nextConfig);
}
if (authChoice === "huggingface-api-key") {
const resolved = await resolveNonInteractiveApiKey({
provider: "huggingface",
cfg: baseConfig,
flagValue: opts.huggingfaceApiKey,
flagName: "--huggingface-api-key",
envVar: "HF_TOKEN",
runtime,
});
if (!resolved) {
return null;
}
if (resolved.source !== "profile") {
await setHuggingfaceApiKey(resolved.key);
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "huggingface:default",
provider: "huggingface",
mode: "api_key",
});
return applyHuggingfaceConfig(nextConfig);
}
if (authChoice === "custom-api-key") {
try {
const customAuth = parseNonInteractiveCustomApiFlags({

View File

@@ -22,6 +22,7 @@ export type AuthChoice =
| "synthetic-api-key"
| "venice-api-key"
| "together-api-key"
| "huggingface-api-key"
| "codex-cli"
| "apiKey"
| "gemini-api-key"
@@ -52,6 +53,7 @@ export type AuthChoiceGroupId =
| "google"
| "copilot"
| "openrouter"
| "litellm"
| "ai-gateway"
| "cloudflare-ai-gateway"
| "moonshot"
@@ -62,6 +64,8 @@ export type AuthChoiceGroupId =
| "synthetic"
| "venice"
| "qwen"
| "together"
| "huggingface"
| "qianfan"
| "xai"
| "custom";
@@ -109,6 +113,7 @@ export type OnboardOptions = {
syntheticApiKey?: string;
veniceApiKey?: string;
togetherApiKey?: string;
huggingfaceApiKey?: string;
opencodeZenApiKey?: string;
xaiApiKey?: string;
qianfanApiKey?: string;