Files
DeskClaw/electron/services/providers/provider-runtime-sync.ts

710 lines
22 KiB
TypeScript

import type { GatewayManager } from '../../gateway/manager';
import { getProviderAccount, listProviderAccounts } from './provider-store';
import { getProviderSecret } from '../secrets/secret-store';
import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
removeProviderFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
} from '../../utils/openclaw-auth';
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.3-codex`;
type RuntimeProviderSyncContext = {
runtimeProviderKey: string;
meta: ReturnType<typeof getProviderConfig>;
api: string;
};
function normalizeProviderBaseUrl(
config: ProviderConfig,
baseUrl?: string,
apiProtocol?: string,
): string | undefined {
if (!baseUrl) {
return undefined;
}
const normalized = baseUrl.trim().replace(/\/+$/, '');
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
if (config.type === 'custom' || config.type === 'ollama') {
const protocol = apiProtocol || config.apiProtocol || 'openai-completions';
if (protocol === 'openai-responses') {
return normalized.replace(/\/responses?$/i, '');
}
if (protocol === 'openai-completions') {
return normalized.replace(/\/chat\/completions$/i, '');
}
if (protocol === 'anthropic-messages') {
return normalized.replace(/\/v1\/messages$/i, '').replace(/\/messages$/i, '');
}
}
return normalized;
}
function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean {
return Boolean(config.baseUrl || config.apiProtocol || runtimeProviderKey !== config.type);
}
export function getOpenClawProviderKey(type: string, providerId: string): string {
if (type === 'custom' || type === 'ollama') {
// If the providerId is already a runtime key (e.g. re-seeded from openclaw.json
// as "custom-XXXXXXXX"), return it directly to avoid double-hashing.
const prefix = `${type}-`;
if (providerId.startsWith(prefix)) {
const tail = providerId.slice(prefix.length);
if (tail.length === 8 && !tail.includes('-')) {
return providerId;
}
}
const suffix = providerId.replace(/-/g, '').slice(0, 8);
return `${type}-${suffix}`;
}
if (type === 'minimax-portal-cn') {
return 'minimax-portal';
}
return type;
}
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (account?.authMode === 'oauth_browser') {
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
}
return getOpenClawProviderKey(config.type, config.id);
}
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
const account = await getProviderAccount(config.id);
if (account?.authMode !== 'oauth_browser') {
return null;
}
const secret = await getProviderSecret(config.id);
if (secret?.type !== 'oauth') {
return null;
}
if (config.type === 'google') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
if (config.type === 'openai') {
return OPENAI_OAUTH_RUNTIME_PROVIDER;
}
return null;
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
const providerKey = getOpenClawProviderKey(config.type, config.id);
if (config.model) {
return config.model.startsWith(`${providerKey}/`)
? config.model
: `${providerKey}/${config.model}`;
}
const defaultModel = getProviderDefaultModel(config.type);
if (!defaultModel) {
return undefined;
}
return defaultModel.startsWith(`${providerKey}/`)
? defaultModel
: `${providerKey}/${defaultModel}`;
}
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
const allProviders = await getAllProviders();
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
const seen = new Set<string>();
const results: string[] = [];
const providerKey = getOpenClawProviderKey(config.type, config.id);
for (const fallbackModel of config.fallbackModels ?? []) {
const normalizedModel = fallbackModel.trim();
if (!normalizedModel) continue;
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
? normalizedModel
: `${providerKey}/${normalizedModel}`;
if (seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
for (const fallbackId of config.fallbackProviderIds ?? []) {
if (!fallbackId || fallbackId === config.id) continue;
const fallbackProvider = providerMap.get(fallbackId);
if (!fallbackProvider) continue;
const modelRef = getProviderModelRef(fallbackProvider);
if (!modelRef || seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
return results;
}
type GatewayRefreshMode = 'reload' | 'restart';
function scheduleGatewayRefresh(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
): void {
if (!gatewayManager) {
return;
}
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
return;
}
logger.info(message);
if (options?.mode === 'restart') {
gatewayManager.debouncedRestart(options?.delayMs);
return;
}
gatewayManager.debouncedReload(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
providerType: string,
providerId: string,
apiKey: string,
): Promise<void> {
const ock = getOpenClawProviderKey(providerType, providerId);
await saveProviderKeyToOpenClaw(ock, apiKey);
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
id: account.id,
name: account.label,
type: account.vendorId,
baseUrl: account.baseUrl,
model: account.model,
fallbackModels: account.fallbackModels,
fallbackProviderIds: account.fallbackAccountIds,
enabled: account.enabled,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
});
const secret = await getProviderSecret(account.id);
if (!secret) {
continue;
}
if (secret.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
}
}
async function syncProviderSecretToRuntime(
config: ProviderConfig,
runtimeProviderKey: string,
apiKey: string | undefined,
): Promise<void> {
const secret = await getProviderSecret(config.id);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
}
return;
}
if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
return;
}
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
return;
}
if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
}
}
async function resolveRuntimeSyncContext(config: ProviderConfig): Promise<RuntimeProviderSyncContext | null> {
const runtimeProviderKey = await resolveRuntimeProviderKey(config);
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
if (!api) {
return null;
}
return {
runtimeProviderKey,
meta,
api,
};
}
async function syncRuntimeProviderConfig(
config: ProviderConfig,
context: RuntimeProviderSyncContext,
): Promise<void> {
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: config.headers ?? context.meta?.headers,
});
}
async function syncCustomProviderAgentModel(
config: ProviderConfig,
runtimeProviderKey: string,
apiKey: string | undefined,
): Promise<void> {
if (config.type !== 'custom') {
return;
}
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (!resolvedKey || !config.baseUrl) {
return;
}
const modelId = config.model;
await updateAgentModelProvider(runtimeProviderKey, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
async function syncProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
): Promise<RuntimeProviderSyncContext | null> {
const context = await resolveRuntimeSyncContext(config);
if (!context) {
return null;
}
await syncProviderSecretToRuntime(config, context.runtimeProviderKey, apiKey);
await syncRuntimeProviderConfig(config, context);
await syncCustomProviderAgentModel(config, context.runtimeProviderKey, apiKey);
return context;
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
const trimmed = modelRef.trim();
const separatorIndex = trimmed.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return null;
}
return {
providerKey: trimmed.slice(0, separatorIndex),
modelId: trimmed.slice(separatorIndex + 1),
};
}
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
const configs = await getAllProviders();
const runtimeMap = new Map<string, ProviderConfig>();
for (const config of configs) {
const runtimeKey = await resolveRuntimeProviderKey(config);
runtimeMap.set(runtimeKey, config);
}
return runtimeMap;
}
async function buildAgentModelProviderEntry(
config: ProviderConfig,
modelId: string,
): Promise<{
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
authHeader?: boolean;
} | null> {
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
if (!api || !baseUrl) {
return null;
}
let apiKey: string | undefined;
let authHeader: boolean | undefined;
if (config.type === 'custom') {
apiKey = (await getApiKey(config.id)) || undefined;
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
authHeader = true;
apiKey = 'minimax-oauth';
}
} else if (config.type === 'qwen-portal') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
apiKey = 'qwen-oauth';
}
}
return {
baseUrl,
api,
models: [{ id: modelId, name: modelId }],
apiKey,
authHeader,
};
}
async function syncAgentModelsToRuntime(agentIds?: Set<string>): Promise<void> {
const snapshot = await listAgentsSnapshot();
const runtimeProviderConfigs = await buildRuntimeProviderConfigMap();
const targets = snapshot.agents.filter((agent) => {
if (!agent.modelRef) return false;
if (!agentIds) return true;
return agentIds.has(agent.id);
});
for (const agent of targets) {
const parsed = parseModelRef(agent.modelRef || '');
if (!parsed) {
continue;
}
const providerConfig = runtimeProviderConfigs.get(parsed.providerKey);
if (!providerConfig) {
logger.warn(
`[provider-runtime] No provider account mapped to runtime key "${parsed.providerKey}" for agent "${agent.id}"`,
);
continue;
}
const entry = await buildAgentModelProviderEntry(providerConfig, parsed.modelId);
if (!entry) {
continue;
}
await updateSingleAgentModelProvider(agent.id, parsed.providerKey, entry);
}
}
export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<void> {
await syncAgentModelsToRuntime(new Set([agentId]));
}
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
return;
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
);
}
export async function syncUpdatedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const context = await syncProviderToRuntime(config, apiKey);
if (!context) {
return;
}
const ock = context.runtimeProviderKey;
const fallbackModels = await getProviderFallbackModelRefs(config);
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom') {
if (shouldUseExplicitDefaultOverride(config, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
api: context.api,
apiKeyEnv: context.meta?.apiKeyEnv,
headers: config.headers ?? context.meta?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
} else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
api: config.apiProtocol || 'openai-completions',
headers: config.headers,
}, fallbackModels);
}
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after updating provider "${ock}" config`,
);
}
export async function syncDeletedProviderToRuntime(
provider: ProviderConfig | null,
providerId: string,
gatewayManager?: GatewayManager,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
{ mode: 'restart' },
);
}
export async function syncDeletedProviderApiKeyToRuntime(
provider: ProviderConfig | null,
providerId: string,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
}
export async function syncDefaultProviderToRuntime(
providerId: string,
gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
return;
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
if (!isOAuthProvider) {
const modelOverride = provider.model
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
headers: provider.headers,
}, fallbackModels);
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: normalizeProviderBaseUrl(
provider,
provider.baseUrl || getProviderConfig(provider.type)?.baseUrl,
provider.apiProtocol || getProviderConfig(provider.type)?.api,
),
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
headers: provider.headers ?? getProviderConfig(provider.type)?.headers,
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
if (providerKey) {
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (browserOAuthRuntimeProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
const modelOverride = provider.model
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
? provider.model
: `${browserOAuthRuntimeProvider}/${provider.model}`)
: defaultModelRef;
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
);
return;
}
const defaultBaseUrl = provider.type === 'minimax-portal'
? 'https://api.minimax.io/anthropic'
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
const api: 'anthropic-messages' | 'openai-completions' =
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'anthropic-messages'
: 'openai-completions';
let baseUrl = provider.baseUrl || defaultBaseUrl;
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'minimax-portal'
: provider.type;
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
}, fallbackModels);
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
try {
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
}
}
if (
provider.type === 'custom' &&
providerKey &&
provider.baseUrl
) {
const modelId = provider.model;
await updateAgentModelProvider(ock, {
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
api: provider.apiProtocol || 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}