snapshot
This commit is contained in:
@@ -14,185 +14,22 @@ import {
|
||||
} from '../../utils/secure-storage';
|
||||
import {
|
||||
getProviderConfig,
|
||||
getProviderDefaultModel,
|
||||
} from '../../utils/provider-registry';
|
||||
import {
|
||||
removeProviderFromOpenClaw,
|
||||
saveProviderKeyToOpenClaw,
|
||||
setOpenClawDefaultModel,
|
||||
setOpenClawDefaultModelWithOverride,
|
||||
syncProviderConfigToOpenClaw,
|
||||
updateAgentModelProvider,
|
||||
} from '../../utils/openclaw-auth';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
|
||||
function getOpenClawProviderKey(type: string, providerId: string): string {
|
||||
if (type === 'custom' || type === 'ollama') {
|
||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||
return `${type}-${suffix}`;
|
||||
}
|
||||
if (type === 'minimax-portal-cn') {
|
||||
return 'minimax-portal';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
return getProviderDefaultModel(config.type);
|
||||
}
|
||||
|
||||
async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
|
||||
const allProviders = await getAllProvidersWithKeyInfo();
|
||||
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 ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'openrouter' | 'none';
|
||||
|
||||
function getValidationProfile(providerType: string): ValidationProfile {
|
||||
switch (providerType) {
|
||||
case 'anthropic':
|
||||
return 'anthropic-header';
|
||||
case 'google':
|
||||
return 'google-query-key';
|
||||
case 'openrouter':
|
||||
return 'openrouter';
|
||||
case 'ollama':
|
||||
return 'none';
|
||||
default:
|
||||
return 'openai-compatible';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function classifyAuthResponse(status: number, data: unknown): { valid: boolean; error?: string } {
|
||||
if (status >= 200 && status < 300) return { valid: true };
|
||||
if (status === 429) return { valid: true };
|
||||
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
|
||||
const obj = data as { error?: { message?: string }; message?: string } | null;
|
||||
return { valid: false, error: obj?.error?.message || obj?.message || `API error: ${status}` };
|
||||
}
|
||||
|
||||
async function performProviderValidationRequest(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function performChatCompletionsProbe(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if ((response.status >= 200 && response.status < 300) || response.status === 400 || response.status === 429) {
|
||||
return { valid: true };
|
||||
}
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return { valid: false, error: `Connection error: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function validateApiKeyWithProvider(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string },
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const profile = getValidationProfile(providerType);
|
||||
if (profile === 'none') return { valid: true };
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) return { valid: false, error: 'API key is required' };
|
||||
|
||||
switch (profile) {
|
||||
case 'openai-compatible': {
|
||||
const trimmedBaseUrl = options?.baseUrl?.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||
}
|
||||
const headers = { Authorization: `Bearer ${trimmedKey}` };
|
||||
const modelsUrl = `${normalizeBaseUrl(trimmedBaseUrl)}/models?limit=1`;
|
||||
const modelsResult = await performProviderValidationRequest(modelsUrl, headers);
|
||||
if (modelsResult.error?.includes('API error: 404')) {
|
||||
return performChatCompletionsProbe(`${normalizeBaseUrl(trimmedBaseUrl)}/chat/completions`, headers);
|
||||
}
|
||||
return modelsResult;
|
||||
}
|
||||
case 'google-query-key': {
|
||||
const base = normalizeBaseUrl(options?.baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
||||
return performProviderValidationRequest(`${base}/models?pageSize=1&key=${encodeURIComponent(trimmedKey)}`, {});
|
||||
}
|
||||
case 'anthropic-header': {
|
||||
const base = normalizeBaseUrl(options?.baseUrl || 'https://api.anthropic.com/v1');
|
||||
return performProviderValidationRequest(`${base}/models?limit=1`, {
|
||||
'x-api-key': trimmedKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
});
|
||||
}
|
||||
case 'openrouter':
|
||||
return performProviderValidationRequest('https://openrouter.ai/api/v1/auth/key', {
|
||||
Authorization: `Bearer ${trimmedKey}`,
|
||||
});
|
||||
default:
|
||||
return { valid: false, error: `Unsupported provider validation profile: ${providerType}` };
|
||||
}
|
||||
}
|
||||
import {
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderApiKeyToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncProviderApiKeyToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
syncUpdatedProviderToRuntime,
|
||||
} from '../../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { providerAccountToConfig } from '../../services/providers/provider-store';
|
||||
import type { ProviderAccount } from '../../shared/providers/types';
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
@@ -200,6 +37,90 @@ export async function handleProviderRoutes(
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const providerService = getProviderService();
|
||||
|
||||
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listVendors());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listAccounts());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
|
||||
const account = await providerService.createAccount(body.account, body.apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
|
||||
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await providerService.setDefaultAccount(body.accountId);
|
||||
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
sendJson(res, 200, await providerService.getAccount(accountId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
||||
return true;
|
||||
}
|
||||
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account: nextAccount });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing ? providerAccountToConfig(existing) : null, accountId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteAccount(accountId);
|
||||
await syncDeletedProviderToRuntime(existing ? providerAccountToConfig(existing) : null, accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllProvidersWithKeyInfo());
|
||||
return true;
|
||||
@@ -214,50 +135,7 @@ export async function handleProviderRoutes(
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
await setDefaultProvider(body.providerId);
|
||||
const provider = await getProvider(body.providerId);
|
||||
if (provider) {
|
||||
const ock = getOpenClawProviderKey(provider.type, body.providerId);
|
||||
const providerKey = await getApiKey(body.providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const isOAuthProvider = oauthTypes.includes(provider.type) && !providerKey;
|
||||
if (!isOAuthProvider) {
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
if (providerKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||
}
|
||||
} else {
|
||||
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');
|
||||
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: targetProviderKey === 'minimax-portal' ? 'anthropic-messages' : 'openai-completions',
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
}, fallbackModels);
|
||||
}
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
}
|
||||
}
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -281,8 +159,16 @@ export async function handleProviderRoutes(
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ provider: OAuthProviderType; region?: 'global' | 'cn' }>(req);
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region);
|
||||
const body = await parseJsonBody<{
|
||||
provider: OAuthProviderType;
|
||||
region?: 'global' | 'cn';
|
||||
accountId?: string;
|
||||
label?: string;
|
||||
}>(req);
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -305,37 +191,14 @@ export async function handleProviderRoutes(
|
||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||
const config = body.config;
|
||||
await saveProvider(config);
|
||||
const ock = getOpenClawProviderKey(config.type, config.id);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(config.id, trimmedKey);
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const resolvedKey = body.apiKey !== undefined ? (body.apiKey.trim() || null) : await getApiKey(config.id);
|
||||
if (resolvedKey && config.baseUrl) {
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -369,42 +232,18 @@ export async function handleProviderRoutes(
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
|
||||
await saveProvider(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(providerId, trimmedKey);
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await deleteApiKey(providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
}
|
||||
}
|
||||
const fallbackModels = await getProviderFallbackModelRefs(nextConfig);
|
||||
const meta = getProviderConfig(nextConfig.type);
|
||||
const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, nextConfig.model, {
|
||||
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId === providerId) {
|
||||
const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined;
|
||||
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: nextConfig.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
}
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -418,17 +257,12 @@ export async function handleProviderRoutes(
|
||||
const existing = await getProvider(providerId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await deleteApiKey(providerId);
|
||||
if (existing?.type) {
|
||||
await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId));
|
||||
}
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await deleteProvider(providerId);
|
||||
if (existing?.type) {
|
||||
await removeProviderFromOpenClaw(getOpenClawProviderKey(existing.type, providerId));
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
}
|
||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
|
||||
Reference in New Issue
Block a user