feat: support OAuth & API key for Qwen/MiniMax providers (#177)
This commit is contained in:
committed by
GitHub
Unverified
parent
e1ae68ce7e
commit
7b16b6af14
322
electron/utils/device-oauth.ts
Normal file
322
electron/utils/device-oauth.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Device OAuth Manager
|
||||
*
|
||||
* Delegates MiniMax and Qwen OAuth to the OpenClaw extension oauth.ts functions
|
||||
* imported directly from the bundled openclaw package at build time.
|
||||
*
|
||||
* This approach:
|
||||
* - Avoids hardcoding client_id (lives in openclaw extension)
|
||||
* - Avoids duplicating HTTP OAuth logic
|
||||
* - Avoids spawning CLI process (which requires interactive TTY)
|
||||
* - Works identically on macOS, Windows, and Linux
|
||||
*
|
||||
* The extension oauth.ts files only use `node:crypto` and global `fetch` —
|
||||
* they are pure Node.js HTTP functions, no TTY, no prompter needed.
|
||||
*
|
||||
* We provide our own callbacks (openUrl/note/progress) that hook into
|
||||
* the Electron IPC system to display UI in the ClawX frontend.
|
||||
*/
|
||||
import { EventEmitter } from 'events';
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { logger } from './logger';
|
||||
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
|
||||
import { getProviderDefaultModel } from './provider-registry';
|
||||
import { isOpenClawPresent } from './paths';
|
||||
import {
|
||||
loginMiniMaxPortalOAuth,
|
||||
type MiniMaxOAuthToken,
|
||||
type MiniMaxRegion,
|
||||
} from '../../node_modules/openclaw/extensions/minimax-portal-auth/oauth';
|
||||
import {
|
||||
loginQwenPortalOAuth,
|
||||
type QwenOAuthToken,
|
||||
} from '../../node_modules/openclaw/extensions/qwen-portal-auth/oauth';
|
||||
import { saveOAuthTokenToOpenClaw, setOpenClawDefaultModelWithOverride } from './openclaw-auth';
|
||||
|
||||
export type OAuthProviderType = 'minimax-portal' | 'qwen-portal';
|
||||
export type { MiniMaxRegion };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// DeviceOAuthManager
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
class DeviceOAuthManager extends EventEmitter {
|
||||
private activeProvider: OAuthProviderType | null = null;
|
||||
private active: boolean = false;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
|
||||
setWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise<boolean> {
|
||||
if (this.active) {
|
||||
await this.stopFlow();
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this.activeProvider = provider;
|
||||
|
||||
try {
|
||||
if (provider === 'minimax-portal') {
|
||||
await this.runMiniMaxFlow(region);
|
||||
} else if (provider === 'qwen-portal') {
|
||||
await this.runQwenFlow();
|
||||
} else {
|
||||
throw new Error(`Unsupported OAuth provider: ${provider}`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.active) {
|
||||
// Flow was cancelled — not an error
|
||||
return false;
|
||||
}
|
||||
logger.error(`[DeviceOAuth] Flow error for ${provider}:`, error);
|
||||
this.emitError(error instanceof Error ? error.message : String(error));
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopFlow(): Promise<void> {
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
logger.info('[DeviceOAuth] Flow explicitly stopped');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// MiniMax flow
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async runMiniMaxFlow(region: MiniMaxRegion): Promise<void> {
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error('OpenClaw package not found');
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
|
||||
region,
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
|
||||
// Open the authorization URL in the system browser
|
||||
shell.openExternal(url).catch((err) =>
|
||||
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||
);
|
||||
},
|
||||
note: async (message, _title) => {
|
||||
if (!this.active) return;
|
||||
// The extension calls note() with a message containing
|
||||
// the user_code and verification_uri — parse them for the UI
|
||||
const { verificationUri, userCode } = this.parseNote(message);
|
||||
if (verificationUri && userCode) {
|
||||
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||
} else {
|
||||
logger.info(`[DeviceOAuth] MiniMax note: ${message}`);
|
||||
}
|
||||
},
|
||||
progress: {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
await this.onSuccess('minimax-portal', {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
// MiniMax returns a per-account resourceUrl as the API base URL
|
||||
resourceUrl: token.resourceUrl,
|
||||
// MiniMax uses Anthropic Messages API format
|
||||
api: 'anthropic-messages',
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Qwen flow
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async runQwenFlow(): Promise<void> {
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error('OpenClaw package not found');
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: QwenOAuthToken = await loginQwenPortalOAuth({
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
|
||||
shell.openExternal(url).catch((err) =>
|
||||
logger.warn(`[DeviceOAuth] Failed to open browser:`, err)
|
||||
);
|
||||
},
|
||||
note: async (message, _title) => {
|
||||
if (!this.active) return;
|
||||
const { verificationUri, userCode } = this.parseNote(message);
|
||||
if (verificationUri && userCode) {
|
||||
this.emitCode({ provider, verificationUri, userCode, expiresIn: 300 });
|
||||
} else {
|
||||
logger.info(`[DeviceOAuth] Qwen note: ${message}`);
|
||||
}
|
||||
},
|
||||
progress: {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
await this.onSuccess('qwen-portal', {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
// Qwen returns a per-account resourceUrl as the API base URL
|
||||
resourceUrl: token.resourceUrl,
|
||||
// Qwen uses OpenAI Completions API format
|
||||
api: 'openai-completions',
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Success handler
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
private async onSuccess(providerType: OAuthProviderType, token: {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
resourceUrl?: string;
|
||||
api: 'anthropic-messages' | 'openai-completions';
|
||||
}) {
|
||||
this.active = false;
|
||||
this.activeProvider = null;
|
||||
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
|
||||
|
||||
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format
|
||||
// (matches what `openclaw models auth login` → upsertAuthProfile writes)
|
||||
try {
|
||||
saveOAuthTokenToOpenClaw(providerType, {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[DeviceOAuth] Failed to save OAuth token to OpenClaw:`, err);
|
||||
}
|
||||
|
||||
// 2. Write openclaw.json: set default model + provider config (baseUrl/api/models)
|
||||
// This mirrors what the OpenClaw plugin's configPatch does after CLI login.
|
||||
// The baseUrl comes from token.resourceUrl (per-account URL from the OAuth server)
|
||||
// or falls back to the provider's default public endpoint.
|
||||
// Note: MiniMax Anthropic-compatible API requires the /anthropic suffix.
|
||||
const defaultBaseUrl = providerType === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: 'https://portal.qwen.ai/v1';
|
||||
|
||||
let baseUrl = token.resourceUrl || defaultBaseUrl;
|
||||
|
||||
// If MiniMax returned a resourceUrl (e.g. https://api.minimax.io) but no /anthropic suffix,
|
||||
// we must append it because we use the 'anthropic-messages' API mode
|
||||
if (providerType === 'minimax-portal' && baseUrl && !baseUrl.endsWith('/anthropic')) {
|
||||
baseUrl = baseUrl.replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
try {
|
||||
setOpenClawDefaultModelWithOverride(providerType, undefined, {
|
||||
baseUrl,
|
||||
api: token.api,
|
||||
// OAuth placeholder — tells Gateway to resolve credentials
|
||||
// from auth-profiles.json (type: 'oauth') instead of a static API key.
|
||||
// This matches what the OpenClaw plugin's configPatch writes:
|
||||
// minimax-portal → apiKey: 'minimax-oauth'
|
||||
// qwen-portal → apiKey: 'qwen-oauth'
|
||||
apiKeyEnv: providerType === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[DeviceOAuth] Failed to configure openclaw models:`, err);
|
||||
}
|
||||
|
||||
// 3. Save provider record in ClawX's own store so UI shows it as configured
|
||||
const existing = await getProvider(providerType);
|
||||
const providerConfig: ProviderConfig = {
|
||||
id: providerType,
|
||||
name: providerType === 'minimax-portal' ? 'MiniMax' : 'Qwen',
|
||||
type: providerType,
|
||||
enabled: existing?.enabled ?? true,
|
||||
baseUrl: existing?.baseUrl,
|
||||
model: existing?.model || getProviderDefaultModel(providerType),
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await saveProvider(providerConfig);
|
||||
|
||||
// 4. Emit success to frontend
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse user_code and verification_uri from the note message sent by
|
||||
* the OpenClaw extension's loginXxxPortalOAuth function.
|
||||
*
|
||||
* Note format (minimax-portal-auth/oauth.ts):
|
||||
* "Open https://platform.minimax.io/oauth-authorize?user_code=dyMj_wOhpK&client=... to approve access.\n"
|
||||
* "If prompted, enter the code dyMj_wOhpK.\n"
|
||||
* ...
|
||||
*
|
||||
* user_code format: mixed-case alphanumeric with underscore, e.g. "dyMj_wOhpK"
|
||||
*/
|
||||
private parseNote(message: string): { verificationUri?: string; userCode?: string } {
|
||||
// Primary: extract URL (everything between "Open " and " to")
|
||||
const urlMatch = message.match(/Open\s+(https?:\/\/\S+?)\s+to/i);
|
||||
const verificationUri = urlMatch?.[1];
|
||||
|
||||
let userCode: string | undefined;
|
||||
|
||||
// Method 1: extract user_code from URL query param (most reliable)
|
||||
if (verificationUri) {
|
||||
try {
|
||||
const parsed = new URL(verificationUri);
|
||||
const qp = parsed.searchParams.get('user_code');
|
||||
if (qp) userCode = qp;
|
||||
} catch {
|
||||
// fall through to text-based extraction
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: text-based extraction — matches mixed-case alnum + underscore/hyphen codes
|
||||
if (!userCode) {
|
||||
const codeMatch = message.match(/enter.*?code\s+([A-Za-z0-9][A-Za-z0-9_-]{3,})/i);
|
||||
if (codeMatch?.[1]) userCode = codeMatch[1].replace(/\.$/, ''); // strip trailing period
|
||||
}
|
||||
|
||||
return { verificationUri, userCode };
|
||||
}
|
||||
|
||||
private emitCode(data: {
|
||||
provider: string;
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
expiresIn: number;
|
||||
}) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:code', data);
|
||||
}
|
||||
}
|
||||
|
||||
private emitError(message: string) {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('oauth:error', { message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceOAuthManager = new DeviceOAuthManager();
|
||||
@@ -24,12 +24,23 @@ interface AuthProfileEntry {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
|
||||
*/
|
||||
interface OAuthProfileEntry {
|
||||
type: 'oauth';
|
||||
provider: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profiles store format
|
||||
*/
|
||||
interface AuthProfilesStore {
|
||||
version: number;
|
||||
profiles: Record<string, AuthProfileEntry>;
|
||||
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
||||
order?: Record<string, string[]>;
|
||||
lastGood?: Record<string, string>;
|
||||
}
|
||||
@@ -46,7 +57,7 @@ function getAuthProfilesPath(agentId = 'main'): string {
|
||||
*/
|
||||
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
||||
const filePath = getAuthProfilesPath(agentId);
|
||||
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
@@ -59,7 +70,7 @@ function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
||||
} catch (error) {
|
||||
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
@@ -72,12 +83,12 @@ function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -96,6 +107,52 @@ function discoverAgentIds(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
provider: string,
|
||||
token: { access: string; refresh: string; expires: number },
|
||||
agentId?: string
|
||||
): void {
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
const entry: OAuthProfileEntry = {
|
||||
type: 'oauth',
|
||||
provider,
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
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)) {
|
||||
store.order[provider].push(profileId);
|
||||
}
|
||||
|
||||
if (!store.lastGood) store.lastGood = {};
|
||||
store.lastGood[provider] = profileId;
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
}
|
||||
|
||||
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a provider API key to OpenClaw's auth-profiles.json
|
||||
* This writes the key in the format OpenClaw expects so the gateway
|
||||
@@ -109,10 +166,20 @@ function discoverAgentIds(): string[] {
|
||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||
*/
|
||||
export 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.
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal'];
|
||||
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();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
@@ -158,6 +225,13 @@ export function removeProviderKeyFromOpenClaw(
|
||||
provider: string,
|
||||
agentId?: string
|
||||
): void {
|
||||
// OAuth providers have their credentials managed by OpenClaw plugins.
|
||||
// Do NOT delete their auth-profiles entries.
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal'];
|
||||
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();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
@@ -183,20 +257,74 @@ export function removeProviderKeyFromOpenClaw(
|
||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
|
||||
*/
|
||||
export function removeProviderFromOpenClaw(provider: string): void {
|
||||
// 1. Remove from auth-profiles.json
|
||||
const agentIds = discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) {
|
||||
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.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||
writeAuthProfiles(store, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 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;
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from models.providers
|
||||
if (config.models?.providers?.[provider]) {
|
||||
delete config.models.providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables object with all stored API keys
|
||||
* for passing to the Gateway process
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -210,9 +338,9 @@ export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: st
|
||||
*/
|
||||
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'));
|
||||
@@ -220,7 +348,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
|
||||
|
||||
const model = modelOverride || getProviderDefaultModel(provider);
|
||||
if (!model) {
|
||||
console.warn(`No default model mapping for provider "${provider}"`);
|
||||
@@ -230,7 +358,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
const modelId = model.startsWith(`${provider}/`)
|
||||
? model.slice(provider.length + 1)
|
||||
: 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>;
|
||||
@@ -238,7 +366,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
defaults.model = { primary: model };
|
||||
agents.defaults = defaults;
|
||||
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.
|
||||
@@ -276,7 +404,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
models: mergedModels,
|
||||
};
|
||||
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
|
||||
|
||||
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
} else {
|
||||
@@ -292,20 +420,20 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
config.models = models;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Ensure gateway mode is set
|
||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||
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');
|
||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||
}
|
||||
@@ -386,6 +514,16 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
}
|
||||
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;
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -397,6 +535,52 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function getActiveOpenClawProviders(): 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
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
|
||||
|
||||
@@ -12,6 +12,8 @@ export const BUILTIN_PROVIDER_TYPES = [
|
||||
'openrouter',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
] as const;
|
||||
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
||||
@@ -94,6 +96,12 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
|
||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||
},
|
||||
},
|
||||
'minimax-portal': {
|
||||
defaultModel: 'minimax-portal/MiniMax-M2.1',
|
||||
},
|
||||
'qwen-portal': {
|
||||
defaultModel: 'qwen-portal/coder-model',
|
||||
},
|
||||
custom: {
|
||||
envVar: 'CUSTOM_API_KEY',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Keys are stored in plain text alongside provider configs in a single electron-store.
|
||||
*/
|
||||
|
||||
import type { ProviderType } from './provider-registry';
|
||||
import { getActiveOpenClawProviders } from './openclaw-auth';
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let providerStore: any = null;
|
||||
@@ -29,7 +32,7 @@ async function getProviderStore() {
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'anthropic' | 'openai' | 'google' | 'openrouter' | 'moonshot' | 'siliconflow' | 'ollama' | 'custom';
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
@@ -204,14 +207,32 @@ export async function getProviderWithKeyInfo(
|
||||
|
||||
/**
|
||||
* Get all providers with key info (for UI display)
|
||||
* Also synchronizes ClawX local provider list with OpenClaw's actual config.
|
||||
*/
|
||||
export async function getAllProvidersWithKeyInfo(): Promise<
|
||||
Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>
|
||||
> {
|
||||
const providers = await getAllProviders();
|
||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
||||
const activeOpenClawProviders = getActiveOpenClawProviders();
|
||||
|
||||
// We need to avoid deleting native ones like 'anthropic' or 'google'
|
||||
// that don't need to exist in openclaw.json models.providers
|
||||
const OpenClawBuiltinList = [
|
||||
'anthropic', 'openai', 'google', 'moonshot', 'siliconflow', 'ollama'
|
||||
];
|
||||
|
||||
for (const provider of providers) {
|
||||
// Sync check: If it's a custom/OAuth provider and it no longer exists in OpenClaw config
|
||||
// (e.g. wiped by Gateway due to missing plugin, or manually deleted by user)
|
||||
// we should remove it from ClawX UI to stay consistent.
|
||||
const isBuiltin = OpenClawBuiltinList.includes(provider.type);
|
||||
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id)) {
|
||||
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
|
||||
await deleteProvider(provider.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const apiKey = await getApiKey(provider.id);
|
||||
let keyMasked: string | null = null;
|
||||
|
||||
@@ -232,3 +253,4 @@ export async function getAllProvidersWithKeyInfo(): Promise<
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user