Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -2,8 +2,14 @@
|
||||
* OpenClaw Auth Profiles Utility
|
||||
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||
* so the OpenClaw Gateway can load them for AI provider calls.
|
||||
*
|
||||
* All file I/O is asynchronous (fs/promises) to avoid blocking the
|
||||
* Electron main thread. On Windows + NTFS + Defender the synchronous
|
||||
* equivalents could stall for 500 ms – 2 s+ per call, causing "Not
|
||||
* Responding" hangs.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { access, mkdir, readFile, writeFile, readdir } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
@@ -15,18 +21,50 @@ import {
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
|
||||
|
||||
/**
|
||||
* Auth profile entry for an API key
|
||||
*/
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Non-throwing async existence check (replaces existsSync). */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a directory exists (replaces mkdirSync). */
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a JSON file, returning `null` on any error. */
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
if (!(await fileExists(filePath))) return null;
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a JSON file, creating parent directories if needed. */
|
||||
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
||||
await ensureDir(join(filePath, '..'));
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
interface AuthProfileEntry {
|
||||
type: 'api_key';
|
||||
provider: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
|
||||
*/
|
||||
interface OAuthProfileEntry {
|
||||
type: 'oauth';
|
||||
provider: string;
|
||||
@@ -35,9 +73,6 @@ interface OAuthProfileEntry {
|
||||
expires: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profiles store format
|
||||
*/
|
||||
interface AuthProfilesStore {
|
||||
version: number;
|
||||
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
||||
@@ -45,90 +80,78 @@ interface AuthProfilesStore {
|
||||
lastGood?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the auth-profiles.json for a given agent
|
||||
*/
|
||||
// ── Auth Profiles I/O ────────────────────────────────────────────
|
||||
|
||||
function getAuthProfilesPath(agentId = 'main'): string {
|
||||
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing auth profiles store, or create an empty one
|
||||
*/
|
||||
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
||||
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
const filePath = getAuthProfilesPath(agentId);
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as AuthProfilesStore;
|
||||
// Validate basic structure
|
||||
if (data.version && data.profiles && typeof data.profiles === 'object') {
|
||||
return data;
|
||||
}
|
||||
const data = await readJsonFile<AuthProfilesStore>(filePath);
|
||||
if (data?.version && data.profiles && typeof data.profiles === 'object') {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write auth profiles store to disk
|
||||
*/
|
||||
function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void {
|
||||
const filePath = getAuthProfilesPath(agentId);
|
||||
const dir = join(filePath, '..');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8');
|
||||
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all agent IDs that have an agent/ subdirectory.
|
||||
*/
|
||||
function discoverAgentIds(): string[] {
|
||||
// ── Agent Discovery ──────────────────────────────────────────────
|
||||
|
||||
async function discoverAgentIds(): Promise<string[]> {
|
||||
const agentsDir = join(homedir(), '.openclaw', 'agents');
|
||||
try {
|
||||
if (!existsSync(agentsDir)) return ['main'];
|
||||
return readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent')))
|
||||
.map((d) => d.name);
|
||||
if (!(await fileExists(agentsDir))) return ['main'];
|
||||
const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true });
|
||||
const ids: string[] = [];
|
||||
for (const d of entries) {
|
||||
if (d.isDirectory() && await fileExists(join(agentsDir, d.name, 'agent'))) {
|
||||
ids.push(d.name);
|
||||
}
|
||||
}
|
||||
return ids.length > 0 ? ids : ['main'];
|
||||
} catch {
|
||||
return ['main'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
// ── Exported Functions (all async) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Save an OAuth token to OpenClaw's auth-profiles.json.
|
||||
* Writes in OpenClaw's native OAuth credential format (type: 'oauth'),
|
||||
* matching exactly what `openclaw models auth login` (upsertAuthProfile) writes.
|
||||
*
|
||||
* @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal')
|
||||
* @param token - OAuth token from the provider's login function
|
||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||
*/
|
||||
export function saveOAuthTokenToOpenClaw(
|
||||
export async function saveOAuthTokenToOpenClaw(
|
||||
provider: string,
|
||||
token: { access: string; refresh: string; expires: number },
|
||||
agentId?: string
|
||||
): void {
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
): Promise<void> {
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
const entry: OAuthProfileEntry = {
|
||||
store.profiles[profileId] = {
|
||||
type: 'oauth',
|
||||
provider,
|
||||
access: token.access,
|
||||
@@ -136,8 +159,6 @@ export function saveOAuthTokenToOpenClaw(
|
||||
expires: token.expires,
|
||||
};
|
||||
|
||||
store.profiles[profileId] = entry;
|
||||
|
||||
if (!store.order) store.order = {};
|
||||
if (!store.order[provider]) store.order[provider] = [];
|
||||
if (!store.order[provider].includes(profileId)) {
|
||||
@@ -147,9 +168,8 @@ export function saveOAuthTokenToOpenClaw(
|
||||
if (!store.lastGood) store.lastGood = {};
|
||||
store.lastGood[provider] = profileId;
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
|
||||
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -161,12 +181,12 @@ export function saveOAuthTokenToOpenClaw(
|
||||
* @param agentId - Optional single agent ID to read from, defaults to 'main'
|
||||
* @returns The OAuth token access string or null if not found
|
||||
*/
|
||||
export function getOAuthTokenFromOpenClaw(
|
||||
export async function getOAuthTokenFromOpenClaw(
|
||||
provider: string,
|
||||
agentId = 'main'
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const store = readAuthProfiles(agentId);
|
||||
const store = await readAuthProfiles(agentId);
|
||||
const profileId = `${provider}:default`;
|
||||
const profile = store.profiles[profileId];
|
||||
|
||||
@@ -181,65 +201,36 @@ export function getOAuthTokenFromOpenClaw(
|
||||
|
||||
/**
|
||||
* Save a provider API key to OpenClaw's auth-profiles.json
|
||||
* This writes the key in the format OpenClaw expects so the gateway
|
||||
* can use it for AI provider calls.
|
||||
*
|
||||
* Writes to ALL discovered agent directories so every agent
|
||||
* (including non-"main" agents like "dev") stays in sync.
|
||||
*
|
||||
* @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google')
|
||||
* @param apiKey - The API key to store
|
||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||
*/
|
||||
export function saveProviderKeyToOpenClaw(
|
||||
|
||||
export async function saveProviderKeyToOpenClaw(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
agentId?: string
|
||||
): void {
|
||||
// OAuth providers (qwen-portal, minimax-portal) typically have their credentials
|
||||
// managed by OpenClaw plugins via `openclaw models auth login`.
|
||||
// Skip only if there's no explicit API key — meaning the user is using OAuth.
|
||||
// If the user provided an actual API key, write it normally.
|
||||
): Promise<void> {
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
|
||||
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
|
||||
return;
|
||||
}
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
|
||||
// Profile ID follows OpenClaw convention: <provider>:default
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
// Upsert the profile entry
|
||||
store.profiles[profileId] = {
|
||||
type: 'api_key',
|
||||
provider,
|
||||
key: apiKey,
|
||||
};
|
||||
store.profiles[profileId] = { type: 'api_key', provider, key: apiKey };
|
||||
|
||||
// Update order to include this profile
|
||||
if (!store.order) {
|
||||
store.order = {};
|
||||
}
|
||||
if (!store.order[provider]) {
|
||||
store.order[provider] = [];
|
||||
}
|
||||
if (!store.order) store.order = {};
|
||||
if (!store.order[provider]) store.order[provider] = [];
|
||||
if (!store.order[provider].includes(profileId)) {
|
||||
store.order[provider].push(profileId);
|
||||
}
|
||||
|
||||
// Set as last good
|
||||
if (!store.lastGood) {
|
||||
store.lastGood = {};
|
||||
}
|
||||
if (!store.lastGood) store.lastGood = {};
|
||||
store.lastGood[provider] = profileId;
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
@@ -247,38 +238,31 @@ export function saveProviderKeyToOpenClaw(
|
||||
/**
|
||||
* Remove a provider API key from OpenClaw auth-profiles.json
|
||||
*/
|
||||
export function removeProviderKeyFromOpenClaw(
|
||||
export async function removeProviderKeyFromOpenClaw(
|
||||
provider: string,
|
||||
agentId?: string
|
||||
): void {
|
||||
// OAuth providers have their credentials managed by OpenClaw plugins.
|
||||
// Do NOT delete their auth-profiles entries.
|
||||
): Promise<void> {
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
if (OAUTH_PROVIDERS.includes(provider)) {
|
||||
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
|
||||
return;
|
||||
}
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
delete store.profiles[profileId];
|
||||
|
||||
if (store.order?.[provider]) {
|
||||
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
|
||||
if (store.order[provider].length === 0) {
|
||||
delete store.order[provider];
|
||||
}
|
||||
if (store.order[provider].length === 0) delete store.order[provider];
|
||||
}
|
||||
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||
|
||||
if (store.lastGood?.[provider] === profileId) {
|
||||
delete store.lastGood[provider];
|
||||
}
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
@@ -286,12 +270,12 @@ export function removeProviderKeyFromOpenClaw(
|
||||
/**
|
||||
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
|
||||
*/
|
||||
export function removeProviderFromOpenClaw(provider: string): void {
|
||||
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
|
||||
// 1. Remove from auth-profiles.json
|
||||
const agentIds = discoverAgentIds();
|
||||
const agentIds = await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) {
|
||||
delete store.profiles[profileId];
|
||||
@@ -300,55 +284,55 @@ export function removeProviderFromOpenClaw(provider: string): void {
|
||||
if (store.order[provider].length === 0) delete store.order[provider];
|
||||
}
|
||||
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
||||
for (const agentId of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
try {
|
||||
if (existsSync(modelsPath)) {
|
||||
const data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
||||
if (await fileExists(modelsPath)) {
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${agentId}")`);
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${agentId}"):`, err);
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove from openclaw.json
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
let modified = false;
|
||||
const config = await readOpenClawJson();
|
||||
let modified = false;
|
||||
|
||||
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||
if (config.plugins?.entries) {
|
||||
const pluginName = `${provider}-auth`;
|
||||
if (config.plugins.entries[pluginName]) {
|
||||
config.plugins.entries[pluginName].enabled = false;
|
||||
modified = true;
|
||||
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||
}
|
||||
}
|
||||
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||
const plugins = config.plugins as Record<string, unknown> | undefined;
|
||||
const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const pluginName = `${provider}-auth`;
|
||||
if (entries[pluginName]) {
|
||||
entries[pluginName].enabled = false;
|
||||
modified = true;
|
||||
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
// Remove from models.providers
|
||||
if (config.models?.providers?.[provider]) {
|
||||
delete config.models.providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
// Remove from models.providers
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
const providers = (models?.providers ?? {}) as Record<string, unknown>;
|
||||
if (providers[provider]) {
|
||||
delete providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||
@@ -361,37 +345,21 @@ export function removeProviderFromOpenClaw(provider: string): void {
|
||||
*/
|
||||
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const { type, apiKey } of providers) {
|
||||
const envVar = getProviderEnvVar(type);
|
||||
if (envVar && apiKey) {
|
||||
env[envVar] = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the OpenClaw config to use the given provider and model
|
||||
* Writes to ~/.openclaw/openclaw.json
|
||||
*
|
||||
* @param provider - Provider type (e.g. 'anthropic', 'siliconflow')
|
||||
* @param modelOverride - Optional model string to use instead of the registry default.
|
||||
* For siliconflow this is the user-supplied model ID prefixed with "siliconflow/".
|
||||
*/
|
||||
export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const model = modelOverride || getProviderDefaultModel(provider);
|
||||
if (!model) {
|
||||
@@ -404,7 +372,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
: model;
|
||||
|
||||
// Set the default model for the agents
|
||||
// model must be an object: { primary: "provider/model", fallbacks?: [] }
|
||||
const agents = (config.agents || {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: model };
|
||||
@@ -412,8 +379,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
config.agents = agents;
|
||||
|
||||
// Configure models.providers for providers that need explicit registration.
|
||||
// Built-in providers (anthropic, google) are part of OpenClaw's pi-ai catalog
|
||||
// and must NOT have a models.providers entry — it would override the built-in.
|
||||
const providerCfg = getProviderConfig(provider);
|
||||
if (providerCfg) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
@@ -456,9 +421,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
} else {
|
||||
// Built-in provider: remove any stale models.providers entry that may
|
||||
// have been written by an earlier version. Leaving it in place would
|
||||
// override the native pi-ai catalog and can break streaming/auth.
|
||||
// Built-in provider: remove any stale models.providers entry
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
if (providers[provider]) {
|
||||
@@ -471,18 +434,10 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
|
||||
// Ensure gateway mode is set
|
||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||
if (!gateway.mode) {
|
||||
gateway.mode = 'local';
|
||||
}
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||
}
|
||||
|
||||
@@ -498,39 +453,26 @@ interface RuntimeProviderConfigOverride {
|
||||
* Register or update a provider's configuration in openclaw.json
|
||||
* without changing the current default model.
|
||||
*/
|
||||
export function syncProviderConfigToOpenClaw(
|
||||
export async function syncProviderConfigToOpenClaw(
|
||||
provider: string,
|
||||
modelId: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
if (override.baseUrl && override.api) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
|
||||
const nextModels: Array<Record<string, unknown>> = [];
|
||||
if (modelId) {
|
||||
nextModels.push({ id: modelId, name: modelId });
|
||||
}
|
||||
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||
|
||||
const nextProvider: Record<string, unknown> = {
|
||||
baseUrl: override.baseUrl,
|
||||
api: override.api,
|
||||
models: nextModels,
|
||||
};
|
||||
if (override.apiKeyEnv) {
|
||||
nextProvider.apiKey = override.apiKeyEnv;
|
||||
}
|
||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||
nextProvider.headers = override.headers;
|
||||
}
|
||||
@@ -543,40 +485,24 @@ export function syncProviderConfigToOpenClaw(
|
||||
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
||||
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
entries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = entries;
|
||||
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
pEntries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = pEntries;
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OpenClaw model + provider config using runtime config values.
|
||||
* Useful for user-configurable providers (custom/ollama-like) where
|
||||
* baseUrl/model are not in the static registry.
|
||||
*/
|
||||
export function setOpenClawDefaultModelWithOverride(
|
||||
export async function setOpenClawDefaultModelWithOverride(
|
||||
provider: string,
|
||||
modelOverride: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const model = modelOverride || getProviderDefaultModel(provider);
|
||||
if (!model) {
|
||||
@@ -598,23 +524,15 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
|
||||
// Replace the provider entry entirely rather than merging.
|
||||
// Different custom/ollama provider instances have different baseUrls,
|
||||
// so merging models from a previous instance creates an inconsistent
|
||||
// config (models pointing at the wrong endpoint).
|
||||
const nextModels: Array<Record<string, unknown>> = [];
|
||||
if (modelId) {
|
||||
nextModels.push({ id: modelId, name: modelId });
|
||||
}
|
||||
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||
|
||||
const nextProvider: Record<string, unknown> = {
|
||||
baseUrl: override.baseUrl,
|
||||
api: override.api,
|
||||
models: nextModels,
|
||||
};
|
||||
if (override.apiKeyEnv) {
|
||||
nextProvider.apiKey = override.apiKeyEnv;
|
||||
}
|
||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||
nextProvider.headers = override.headers;
|
||||
}
|
||||
@@ -628,48 +546,48 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
}
|
||||
|
||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||
if (!gateway.mode) {
|
||||
gateway.mode = 'local';
|
||||
}
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
// Ensure the extension plugin is marked as enabled in openclaw.json
|
||||
// Without this, the OpenClaw Gateway will silently wipe the provider config on startup
|
||||
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
entries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = entries;
|
||||
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
pEntries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = pEntries;
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log(
|
||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
|
||||
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
|
||||
* Get a set of all active provider IDs configured in openclaw.json.
|
||||
* Reads the file ONCE and extracts both models.providers and plugins.entries.
|
||||
*/
|
||||
export function getActiveOpenClawProviders(): Set<string> {
|
||||
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
// 1. Read openclaw.json models.providers
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const providers = config.models?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers)) {
|
||||
activeProviders.add(key);
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
// 1. models.providers
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
activeProviders.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. plugins.entries for OAuth providers
|
||||
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
activeProviders.add(pluginId.replace(/-auth$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,48 +595,14 @@ export function getActiveOpenClawProviders(): Set<string> {
|
||||
console.warn('Failed to read openclaw.json for active providers:', err);
|
||||
}
|
||||
|
||||
// 2. Read openclaw.json plugins.entries for OAuth providers
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const plugins = config.plugins?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins)) {
|
||||
// If the plugin ends with -auth and is enabled, it's an OAuth provider
|
||||
// e.g. 'qwen-portal-auth' implies provider 'qwen-portal'
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
const providerId = pluginId.replace(/-auth$/, '');
|
||||
activeProviders.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json for active plugins:', err);
|
||||
}
|
||||
|
||||
return activeProviders;
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
/**
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
|
||||
* gateway process reads the same token we use for the WebSocket handshake.
|
||||
*
|
||||
* Without this, openclaw.json may contain a stale token written by the
|
||||
* system-managed gateway service (launchctl), causing a "token mismatch"
|
||||
* auth failure when ClawX connects to the process it just spawned.
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export function syncGatewayTokenToConfig(token: string): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// start from a blank config if the file is corrupt
|
||||
}
|
||||
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const gateway = (
|
||||
config.gateway && typeof config.gateway === 'object'
|
||||
@@ -738,31 +622,15 @@ export function syncGatewayTokenToConfig(token: string): void {
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the
|
||||
* "openclaw" managed profile as the default.
|
||||
*
|
||||
* Only sets values that are not already present so existing user
|
||||
* customisation (e.g. switching to a remote CDP profile) is preserved.
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export function syncBrowserConfigToOpenClaw(): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// start from a blank config if the file is corrupt
|
||||
}
|
||||
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const browser = (
|
||||
config.browser && typeof config.browser === 'object'
|
||||
@@ -785,29 +653,14 @@ export function syncBrowserConfigToOpenClaw(): void {
|
||||
if (!changed) return;
|
||||
|
||||
config.browser = browser;
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced browser config to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a provider entry in every discovered agent's models.json.
|
||||
*
|
||||
* The gateway caches resolved provider configs in
|
||||
* ~/.openclaw/agents/<id>/agent/models.json and serves requests from
|
||||
* that file (not from openclaw.json directly). We must update it
|
||||
* whenever the active provider changes so the gateway immediately picks
|
||||
* up the new baseUrl / apiKey without requiring a full restart.
|
||||
*
|
||||
* Existing model-level metadata (contextWindow, cost, etc.) is preserved
|
||||
* when the model ID matches; only the top-level provider fields and the
|
||||
* models list are updated.
|
||||
*/
|
||||
export function updateAgentModelProvider(
|
||||
export async function updateAgentModelProvider(
|
||||
providerType: string,
|
||||
entry: {
|
||||
baseUrl?: string;
|
||||
@@ -817,15 +670,13 @@ export function updateAgentModelProvider(
|
||||
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
||||
authHeader?: boolean;
|
||||
}
|
||||
): void {
|
||||
const agentIds = discoverAgentIds();
|
||||
): Promise<void> {
|
||||
const agentIds = await discoverAgentIds();
|
||||
for (const agentId of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(modelsPath)) {
|
||||
data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
data = (await readJsonFile<Record<string, unknown>>(modelsPath)) ?? {};
|
||||
} catch {
|
||||
// corrupt / missing – start with an empty object
|
||||
}
|
||||
@@ -839,8 +690,6 @@ export function updateAgentModelProvider(
|
||||
? { ...providers[providerType] }
|
||||
: {};
|
||||
|
||||
// Preserve per-model metadata (reasoning, cost, contextWindow…) for
|
||||
// models that already exist; use a minimal stub for new models.
|
||||
const existingModels = Array.isArray(existing.models)
|
||||
? (existing.models as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
@@ -860,7 +709,7 @@ export function updateAgentModelProvider(
|
||||
data.providers = providers;
|
||||
|
||||
try {
|
||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
await writeJsonFile(modelsPath, data);
|
||||
console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to update models.json for agent "${agentId}":`, err);
|
||||
|
||||
Reference in New Issue
Block a user