fix(providers): complete custom openai-responses support (#436)
This commit is contained in:
@@ -299,8 +299,8 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
}
|
}
|
||||||
if (request.action === 'validateKey') {
|
if (request.action === 'validateKey') {
|
||||||
const payload = request.payload as
|
const payload = request.payload as
|
||||||
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string } }
|
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string; apiProtocol?: string } }
|
||||||
| [string, string, { baseUrl?: string }?]
|
| [string, string, { baseUrl?: string; apiProtocol?: string }?]
|
||||||
| undefined;
|
| undefined;
|
||||||
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
|
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
|
||||||
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
|
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
|
||||||
@@ -313,7 +313,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const providerType = provider?.type || providerId;
|
const providerType = provider?.type || providerId;
|
||||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||||
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||||
data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
|
const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol;
|
||||||
|
data = await validateApiKeyWithProvider(providerType, apiKey, {
|
||||||
|
baseUrl: resolvedBaseUrl,
|
||||||
|
apiProtocol: resolvedProtocol,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (request.action === 'save') {
|
if (request.action === 'save') {
|
||||||
@@ -2062,7 +2066,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
_,
|
_,
|
||||||
providerId: string,
|
providerId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string }
|
options?: { baseUrl?: string; apiProtocol?: string }
|
||||||
) => {
|
) => {
|
||||||
logLegacyProviderChannel('provider:validateKey');
|
logLegacyProviderChannel('provider:validateKey');
|
||||||
try {
|
try {
|
||||||
@@ -2076,9 +2080,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
// Prefer caller-supplied baseUrl (live form value) over persisted config.
|
// Prefer caller-supplied baseUrl (live form value) over persisted config.
|
||||||
// This ensures Setup/Settings validation reflects unsaved edits immediately.
|
// This ensures Setup/Settings validation reflects unsaved edits immediately.
|
||||||
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||||
|
const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol;
|
||||||
|
|
||||||
console.log(`[clawx-validate] validating provider type: ${providerType}`);
|
console.log(`[clawx-validate] validating provider type: ${providerType}`);
|
||||||
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
|
return await validateApiKeyWithProvider(providerType, apiKey, {
|
||||||
|
baseUrl: resolvedBaseUrl,
|
||||||
|
apiProtocol: resolvedProtocol,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation error:', error);
|
console.error('Validation error:', error);
|
||||||
return { valid: false, error: String(error) };
|
return { valid: false, error: String(error) };
|
||||||
|
|||||||
@@ -26,16 +26,35 @@ type RuntimeProviderSyncContext = {
|
|||||||
api: string;
|
api: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeProviderBaseUrl(config: ProviderConfig, baseUrl?: string): string | undefined {
|
function normalizeProviderBaseUrl(
|
||||||
|
config: ProviderConfig,
|
||||||
|
baseUrl?: string,
|
||||||
|
apiProtocol?: string,
|
||||||
|
): string | undefined {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalized = baseUrl.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
||||||
return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseUrl;
|
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 {
|
function shouldUseExplicitDefaultOverride(config: ProviderConfig, runtimeProviderKey: string): boolean {
|
||||||
@@ -266,7 +285,7 @@ async function syncRuntimeProviderConfig(
|
|||||||
context: RuntimeProviderSyncContext,
|
context: RuntimeProviderSyncContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
|
await syncProviderConfigToOpenClaw(context.runtimeProviderKey, config.model, {
|
||||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl),
|
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||||
api: context.api,
|
api: context.api,
|
||||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||||
headers: context.meta?.headers,
|
headers: context.meta?.headers,
|
||||||
@@ -289,7 +308,7 @@ async function syncCustomProviderAgentModel(
|
|||||||
|
|
||||||
const modelId = config.model;
|
const modelId = config.model;
|
||||||
await updateAgentModelProvider(runtimeProviderKey, {
|
await updateAgentModelProvider(runtimeProviderKey, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||||
api: config.apiProtocol || 'openai-completions',
|
api: config.apiProtocol || 'openai-completions',
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||||
apiKey: resolvedKey,
|
apiKey: resolvedKey,
|
||||||
@@ -346,7 +365,7 @@ export async function syncUpdatedProviderToRuntime(
|
|||||||
if (config.type !== 'custom') {
|
if (config.type !== 'custom') {
|
||||||
if (shouldUseExplicitDefaultOverride(config, ock)) {
|
if (shouldUseExplicitDefaultOverride(config, ock)) {
|
||||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl),
|
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl || context.meta?.baseUrl, context.api),
|
||||||
api: context.api,
|
api: context.api,
|
||||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||||
headers: context.meta?.headers,
|
headers: context.meta?.headers,
|
||||||
@@ -356,7 +375,7 @@ export async function syncUpdatedProviderToRuntime(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||||
api: config.apiProtocol || 'openai-completions',
|
api: config.apiProtocol || 'openai-completions',
|
||||||
}, fallbackModels);
|
}, fallbackModels);
|
||||||
}
|
}
|
||||||
@@ -423,12 +442,16 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
|
|
||||||
if (provider.type === 'custom') {
|
if (provider.type === 'custom') {
|
||||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||||
api: provider.apiProtocol || 'openai-completions',
|
api: provider.apiProtocol || 'openai-completions',
|
||||||
}, fallbackModels);
|
}, fallbackModels);
|
||||||
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
|
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
|
||||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl || getProviderConfig(provider.type)?.baseUrl),
|
baseUrl: normalizeProviderBaseUrl(
|
||||||
|
provider,
|
||||||
|
provider.baseUrl || getProviderConfig(provider.type)?.baseUrl,
|
||||||
|
provider.apiProtocol || getProviderConfig(provider.type)?.api,
|
||||||
|
),
|
||||||
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
|
api: provider.apiProtocol || getProviderConfig(provider.type)?.api,
|
||||||
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
|
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
|
||||||
headers: getProviderConfig(provider.type)?.headers,
|
headers: getProviderConfig(provider.type)?.headers,
|
||||||
@@ -518,7 +541,7 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
) {
|
) {
|
||||||
const modelId = provider.model;
|
const modelId = provider.model;
|
||||||
await updateAgentModelProvider(ock, {
|
await updateAgentModelProvider(ock, {
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||||
api: provider.apiProtocol || 'openai-completions',
|
api: provider.apiProtocol || 'openai-completions',
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||||
apiKey: providerKey,
|
apiKey: providerKey,
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
|||||||
import { getProviderConfig } from '../../utils/provider-registry';
|
import { getProviderConfig } from '../../utils/provider-registry';
|
||||||
|
|
||||||
type ValidationProfile =
|
type ValidationProfile =
|
||||||
| 'openai-compatible'
|
| 'openai-completions'
|
||||||
|
| 'openai-responses'
|
||||||
| 'google-query-key'
|
| 'google-query-key'
|
||||||
| 'anthropic-header'
|
| 'anthropic-header'
|
||||||
| 'openrouter'
|
| 'openrouter'
|
||||||
| 'none';
|
| 'none';
|
||||||
|
|
||||||
|
type ValidationResult = { valid: boolean; error?: string; status?: number };
|
||||||
|
|
||||||
function logValidationStatus(provider: string, status: number): void {
|
function logValidationStatus(provider: string, status: number): void {
|
||||||
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,28 @@ function buildOpenAiModelsUrl(baseUrl: string): string {
|
|||||||
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
|
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOpenAiProbeUrls(
|
||||||
|
baseUrl: string,
|
||||||
|
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||||
|
): { modelsUrl: string; probeUrl: string } {
|
||||||
|
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||||
|
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
|
||||||
|
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
|
||||||
|
const modelsUrl = buildOpenAiModelsUrl(rootBase);
|
||||||
|
|
||||||
|
if (apiProtocol === 'openai-responses') {
|
||||||
|
const probeUrl = /(\/responses?)$/.test(normalizedBase)
|
||||||
|
? normalizedBase
|
||||||
|
: `${rootBase}/responses`;
|
||||||
|
return { modelsUrl, probeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
|
||||||
|
? normalizedBase
|
||||||
|
: `${rootBase}/chat/completions`;
|
||||||
|
return { modelsUrl, probeUrl };
|
||||||
|
}
|
||||||
|
|
||||||
function logValidationRequest(
|
function logValidationRequest(
|
||||||
provider: string,
|
provider: string,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -68,8 +93,11 @@ function getValidationProfile(
|
|||||||
if (providerApi === 'anthropic-messages') {
|
if (providerApi === 'anthropic-messages') {
|
||||||
return 'anthropic-header';
|
return 'anthropic-header';
|
||||||
}
|
}
|
||||||
if (providerApi === 'openai-completions' || providerApi === 'openai-responses') {
|
if (providerApi === 'openai-responses') {
|
||||||
return 'openai-compatible';
|
return 'openai-responses';
|
||||||
|
}
|
||||||
|
if (providerApi === 'openai-completions') {
|
||||||
|
return 'openai-completions';
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (providerType) {
|
switch (providerType) {
|
||||||
@@ -82,7 +110,7 @@ function getValidationProfile(
|
|||||||
case 'ollama':
|
case 'ollama':
|
||||||
return 'none';
|
return 'none';
|
||||||
default:
|
default:
|
||||||
return 'openai-compatible';
|
return 'openai-completions';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +118,14 @@ async function performProviderValidationRequest(
|
|||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
try {
|
try {
|
||||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||||
const response = await proxyAwareFetch(url, { headers });
|
const response = await proxyAwareFetch(url, { headers });
|
||||||
logValidationStatus(providerLabel, response.status);
|
logValidationStatus(providerLabel, response.status);
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
return classifyAuthResponse(response.status, data);
|
const result = classifyAuthResponse(response.status, data);
|
||||||
|
return { ...result, status: response.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@@ -121,34 +150,73 @@ function classifyAuthResponse(
|
|||||||
async function validateOpenAiCompatibleKey(
|
async function validateOpenAiCompatibleKey(
|
||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
const trimmedBaseUrl = baseUrl?.trim();
|
const trimmedBaseUrl = baseUrl?.trim();
|
||||||
if (!trimmedBaseUrl) {
|
if (!trimmedBaseUrl) {
|
||||||
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||||
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
|
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
|
||||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||||
|
|
||||||
if (modelsResult.error?.includes('API error: 404')) {
|
if (modelsResult.status === 404) {
|
||||||
console.log(
|
console.log(
|
||||||
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
|
`[clawx-validate] ${providerType} /models returned 404, falling back to ${apiProtocol} probe`,
|
||||||
);
|
);
|
||||||
const base = normalizeBaseUrl(trimmedBaseUrl);
|
if (apiProtocol === 'openai-responses') {
|
||||||
const chatUrl = `${base}/chat/completions`;
|
return await performResponsesProbe(providerType, probeUrl, headers);
|
||||||
return await performChatCompletionsProbe(providerType, chatUrl, headers);
|
}
|
||||||
|
return await performChatCompletionsProbe(providerType, probeUrl, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return modelsResult;
|
return modelsResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function performResponsesProbe(
|
||||||
|
providerLabel: string,
|
||||||
|
url: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
try {
|
||||||
|
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||||
|
const response = await proxyAwareFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'validation-probe',
|
||||||
|
input: 'hi',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
logValidationStatus(providerLabel, response.status);
|
||||||
|
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 performChatCompletionsProbe(
|
async function performChatCompletionsProbe(
|
||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
try {
|
try {
|
||||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||||
const response = await proxyAwareFetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
@@ -186,7 +254,7 @@ async function performAnthropicMessagesProbe(
|
|||||||
providerLabel: string,
|
providerLabel: string,
|
||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
try {
|
try {
|
||||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||||
const response = await proxyAwareFetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
@@ -224,7 +292,7 @@ async function validateGoogleQueryKey(
|
|||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
||||||
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
||||||
return await performProviderValidationRequest(providerType, url, {});
|
return await performProviderValidationRequest(providerType, url, {});
|
||||||
@@ -234,7 +302,7 @@ async function validateAnthropicHeaderKey(
|
|||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||||
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
|
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
|
||||||
const url = `${base}/models?limit=1`;
|
const url = `${base}/models?limit=1`;
|
||||||
@@ -246,7 +314,12 @@ async function validateAnthropicHeaderKey(
|
|||||||
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
|
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
|
||||||
|
|
||||||
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
|
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
|
||||||
if (modelsResult.error?.includes('API error: 404') || modelsResult.error?.includes('API error: 400')) {
|
if (
|
||||||
|
modelsResult.status === 404 ||
|
||||||
|
modelsResult.status === 400 ||
|
||||||
|
modelsResult.error?.includes('API error: 404') ||
|
||||||
|
modelsResult.error?.includes('API error: 400')
|
||||||
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
|
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
|
||||||
);
|
);
|
||||||
@@ -260,7 +333,7 @@ async function validateAnthropicHeaderKey(
|
|||||||
async function validateOpenRouterKey(
|
async function validateOpenRouterKey(
|
||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
const url = 'https://openrouter.ai/api/v1/auth/key';
|
const url = 'https://openrouter.ai/api/v1/auth/key';
|
||||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||||
return await performProviderValidationRequest(providerType, url, headers);
|
return await performProviderValidationRequest(providerType, url, headers);
|
||||||
@@ -270,7 +343,7 @@ export async function validateApiKeyWithProvider(
|
|||||||
providerType: string,
|
providerType: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string; apiProtocol?: string },
|
options?: { baseUrl?: string; apiProtocol?: string },
|
||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<ValidationResult> {
|
||||||
const profile = getValidationProfile(providerType, options);
|
const profile = getValidationProfile(providerType, options);
|
||||||
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
||||||
|
|
||||||
@@ -285,8 +358,20 @@ export async function validateApiKeyWithProvider(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (profile) {
|
switch (profile) {
|
||||||
case 'openai-compatible':
|
case 'openai-completions':
|
||||||
return await validateOpenAiCompatibleKey(providerType, trimmedKey, resolvedBaseUrl);
|
return await validateOpenAiCompatibleKey(
|
||||||
|
providerType,
|
||||||
|
trimmedKey,
|
||||||
|
'openai-completions',
|
||||||
|
resolvedBaseUrl,
|
||||||
|
);
|
||||||
|
case 'openai-responses':
|
||||||
|
return await validateOpenAiCompatibleKey(
|
||||||
|
providerType,
|
||||||
|
trimmedKey,
|
||||||
|
'openai-responses',
|
||||||
|
resolvedBaseUrl,
|
||||||
|
);
|
||||||
case 'google-query-key':
|
case 'google-query-key':
|
||||||
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||||
case 'anthropic-header':
|
case 'anthropic-header':
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ function normalizeFallbackProviderIds(ids?: string[]): string[] {
|
|||||||
return Array.from(new Set((ids ?? []).filter(Boolean)));
|
return Array.from(new Set((ids ?? []).filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProtocolBaseUrlPlaceholder(
|
||||||
|
apiProtocol: ProviderAccount['apiProtocol'],
|
||||||
|
): string {
|
||||||
|
if (apiProtocol === 'anthropic-messages') {
|
||||||
|
return 'https://api.example.com/anthropic';
|
||||||
|
}
|
||||||
|
return 'https://api.example.com/v1';
|
||||||
|
}
|
||||||
|
|
||||||
function fallbackProviderIdsEqual(a?: string[], b?: string[]): boolean {
|
function fallbackProviderIdsEqual(a?: string[], b?: string[]): boolean {
|
||||||
const left = normalizeFallbackProviderIds(a).sort();
|
const left = normalizeFallbackProviderIds(a).sort();
|
||||||
const right = normalizeFallbackProviderIds(b).sort();
|
const right = normalizeFallbackProviderIds(b).sort();
|
||||||
@@ -271,7 +280,7 @@ interface ProviderCardProps {
|
|||||||
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>;
|
onSaveEdits: (payload: { newApiKey?: string; updates?: Partial<ProviderConfig> }) => Promise<void>;
|
||||||
onValidateKey: (
|
onValidateKey: (
|
||||||
key: string,
|
key: string,
|
||||||
options?: { baseUrl?: string; apiProtocol?: string }
|
options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] }
|
||||||
) => Promise<{ valid: boolean; error?: string }>;
|
) => Promise<{ valid: boolean; error?: string }>;
|
||||||
devModeUnlocked: boolean;
|
devModeUnlocked: boolean;
|
||||||
}
|
}
|
||||||
@@ -537,7 +546,7 @@ function ProviderCard({
|
|||||||
<Input
|
<Input
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
|
placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)}
|
||||||
className={currentInputClasses}
|
className={currentInputClasses}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -562,7 +571,14 @@ function ProviderCard({
|
|||||||
onClick={() => setApiProtocol('openai-completions')}
|
onClick={() => setApiProtocol('openai-completions')}
|
||||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||||
>
|
>
|
||||||
{t('aiProviders.protocols.openai', 'OpenAI')}
|
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiProtocol('openai-responses')}
|
||||||
|
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||||
|
>
|
||||||
|
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -740,7 +756,7 @@ interface AddProviderDialogProps {
|
|||||||
onValidateKey: (
|
onValidateKey: (
|
||||||
type: string,
|
type: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string; apiProtocol?: string }
|
options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] }
|
||||||
) => Promise<{ valid: boolean; error?: string }>;
|
) => Promise<{ valid: boolean; error?: string }>;
|
||||||
devModeUnlocked: boolean;
|
devModeUnlocked: boolean;
|
||||||
}
|
}
|
||||||
@@ -1182,7 +1198,7 @@ function AddProviderDialog({
|
|||||||
<Label htmlFor="baseUrl" className={labelClasses}>{t('aiProviders.dialog.baseUrl')}</Label>
|
<Label htmlFor="baseUrl" className={labelClasses}>{t('aiProviders.dialog.baseUrl')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="baseUrl"
|
id="baseUrl"
|
||||||
placeholder={apiProtocol === 'anthropic-messages' ? "https://api.example.com/anthropic" : "https://api.example.com/v1"}
|
placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)}
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => setBaseUrl(e.target.value)}
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
@@ -1206,20 +1222,27 @@ function AddProviderDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedType === 'custom' && (
|
{selectedType === 'custom' && (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Label className={labelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
<Label className={labelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
||||||
<div className="flex gap-2 text-[13px]">
|
<div className="flex gap-2 text-[13px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setApiProtocol('openai-completions')}
|
onClick={() => setApiProtocol('openai-completions')}
|
||||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||||
>
|
>
|
||||||
{t('aiProviders.protocols.openai', 'OpenAI')}
|
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setApiProtocol('anthropic-messages')}
|
onClick={() => setApiProtocol('openai-responses')}
|
||||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||||
|
>
|
||||||
|
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setApiProtocol('anthropic-messages')}
|
||||||
|
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-white dark:bg-card border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||||
>
|
>
|
||||||
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
},
|
},
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"openai": "OpenAI Compatible",
|
"openai": "OpenAI Compatible",
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
"anthropic": "Anthropic Compatible"
|
"anthropic": "Anthropic Compatible"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@
|
|||||||
"baseUrl": "Base URL",
|
"baseUrl": "Base URL",
|
||||||
"modelId": "Model ID",
|
"modelId": "Model ID",
|
||||||
"modelIdDesc": "The model identifier from your provider (e.g. deepseek-ai/DeepSeek-V3)",
|
"modelIdDesc": "The model identifier from your provider (e.g. deepseek-ai/DeepSeek-V3)",
|
||||||
|
"protocol": "Protocol",
|
||||||
|
"protocols": {
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
|
"anthropic": "Anthropic Compatible"
|
||||||
|
},
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"validateSave": "Validate & Save",
|
"validateSave": "Validate & Save",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"replaceApiKeyHelp": "現在保存されている API キーをそのまま使う場合は、この欄を空のままにしてください。",
|
"replaceApiKeyHelp": "現在保存されている API キーをそのまま使う場合は、この欄を空のままにしてください。",
|
||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"modelId": "モデル ID",
|
"modelId": "モデル ID",
|
||||||
|
"protocol": "プロトコル",
|
||||||
"fallbackModels": "フォールバックモデル",
|
"fallbackModels": "フォールバックモデル",
|
||||||
"fallbackProviders": "別プロバイダーへのフォールバック",
|
"fallbackProviders": "別プロバイダーへのフォールバック",
|
||||||
"fallbackModelIds": "同一プロバイダーのフォールバックモデル ID",
|
"fallbackModelIds": "同一プロバイダーのフォールバックモデル ID",
|
||||||
@@ -76,6 +77,12 @@
|
|||||||
"editKey": "API キーを編集",
|
"editKey": "API キーを編集",
|
||||||
"delete": "プロバイダーを削除"
|
"delete": "プロバイダーを削除"
|
||||||
},
|
},
|
||||||
|
"protocols": {
|
||||||
|
"openai": "OpenAI 互換",
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
|
"anthropic": "Anthropic 互換"
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"added": "プロバイダーが正常に追加されました",
|
"added": "プロバイダーが正常に追加されました",
|
||||||
"failedAdd": "プロバイダーの追加に失敗しました",
|
"failedAdd": "プロバイダーの追加に失敗しました",
|
||||||
|
|||||||
@@ -67,6 +67,12 @@
|
|||||||
"baseUrl": "ベース URL",
|
"baseUrl": "ベース URL",
|
||||||
"modelId": "モデル ID",
|
"modelId": "モデル ID",
|
||||||
"modelIdDesc": "プロバイダーのモデル識別子(例:deepseek-ai/DeepSeek-V3)",
|
"modelIdDesc": "プロバイダーのモデル識別子(例:deepseek-ai/DeepSeek-V3)",
|
||||||
|
"protocol": "プロトコル",
|
||||||
|
"protocols": {
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
|
"anthropic": "Anthropic 互換"
|
||||||
|
},
|
||||||
"apiKey": "API キー",
|
"apiKey": "API キー",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"validateSave": "検証して保存",
|
"validateSave": "検証して保存",
|
||||||
|
|||||||
@@ -79,6 +79,8 @@
|
|||||||
},
|
},
|
||||||
"protocols": {
|
"protocols": {
|
||||||
"openai": "OpenAI 兼容",
|
"openai": "OpenAI 兼容",
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
"anthropic": "Anthropic 兼容"
|
"anthropic": "Anthropic 兼容"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@
|
|||||||
"baseUrl": "基础 URL",
|
"baseUrl": "基础 URL",
|
||||||
"modelId": "模型 ID",
|
"modelId": "模型 ID",
|
||||||
"modelIdDesc": "提供商的模型标识符(例如 deepseek-ai/DeepSeek-V3)",
|
"modelIdDesc": "提供商的模型标识符(例如 deepseek-ai/DeepSeek-V3)",
|
||||||
|
"protocol": "协议",
|
||||||
|
"protocols": {
|
||||||
|
"openaiCompletions": "OpenAI Completions",
|
||||||
|
"openaiResponses": "OpenAI Responses",
|
||||||
|
"anthropic": "Anthropic 兼容"
|
||||||
|
},
|
||||||
"apiKey": "API 密钥",
|
"apiKey": "API 密钥",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"validateSave": "验证并保存",
|
"validateSave": "验证并保存",
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ import clawxIcon from '@/assets/logo.svg';
|
|||||||
// Use the shared provider registry for setup providers
|
// Use the shared provider registry for setup providers
|
||||||
const providers = SETUP_PROVIDERS;
|
const providers = SETUP_PROVIDERS;
|
||||||
|
|
||||||
|
function getProtocolBaseUrlPlaceholder(
|
||||||
|
apiProtocol: ProviderAccount['apiProtocol'],
|
||||||
|
): string {
|
||||||
|
if (apiProtocol === 'anthropic-messages') {
|
||||||
|
return 'https://api.example.com/anthropic';
|
||||||
|
}
|
||||||
|
return 'https://api.example.com/v1';
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Channel types moved to Settings > Channels page
|
// NOTE: Channel types moved to Settings > Channels page
|
||||||
// NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup
|
// NOTE: Skill bundles moved to Settings > Skills page - auto-install essential skills during setup
|
||||||
|
|
||||||
@@ -712,6 +721,7 @@ function ProviderContent({
|
|||||||
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
|
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [modelId, setModelId] = useState('');
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [apiProtocol, setApiProtocol] = useState<ProviderAccount['apiProtocol']>('openai-completions');
|
||||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||||
const providerMenuRef = useRef<HTMLDivElement | null>(null);
|
const providerMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -905,6 +915,7 @@ function ProviderContent({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!selectedProvider) return;
|
if (!selectedProvider) return;
|
||||||
|
setApiProtocol('openai-completions');
|
||||||
try {
|
try {
|
||||||
const snapshot = await fetchProviderSnapshot();
|
const snapshot = await fetchProviderSnapshot();
|
||||||
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
|
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
|
||||||
@@ -917,7 +928,7 @@ function ProviderContent({
|
|||||||
const accountIdForLoad = preferredAccount?.id || selectedProvider;
|
const accountIdForLoad = preferredAccount?.id || selectedProvider;
|
||||||
setSelectedAccountId(preferredAccount?.id || null);
|
setSelectedAccountId(preferredAccount?.id || null);
|
||||||
|
|
||||||
const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string } | null>(
|
const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string; apiProtocol?: ProviderAccount['apiProtocol'] } | null>(
|
||||||
`/api/providers/${encodeURIComponent(accountIdForLoad)}`,
|
`/api/providers/${encodeURIComponent(accountIdForLoad)}`,
|
||||||
);
|
);
|
||||||
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
|
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
|
||||||
@@ -929,6 +940,7 @@ function ProviderContent({
|
|||||||
const info = providers.find((p) => p.id === selectedProvider);
|
const info = providers.find((p) => p.id === selectedProvider);
|
||||||
setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || '');
|
setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || '');
|
||||||
setModelId(savedProvider?.model || info?.defaultModelId || '');
|
setModelId(savedProvider?.model || info?.defaultModelId || '');
|
||||||
|
setApiProtocol(savedProvider?.apiProtocol || 'openai-completions');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -1002,7 +1014,12 @@ function ProviderContent({
|
|||||||
'provider:validateKey',
|
'provider:validateKey',
|
||||||
selectedAccountId || selectedProvider,
|
selectedAccountId || selectedProvider,
|
||||||
apiKey,
|
apiKey,
|
||||||
{ baseUrl: baseUrl.trim() || undefined }
|
{
|
||||||
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama')
|
||||||
|
? apiProtocol
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
) as { valid: boolean; error?: string };
|
) as { valid: boolean; error?: string };
|
||||||
|
|
||||||
setKeyValid(result.valid);
|
setKeyValid(result.valid);
|
||||||
@@ -1039,6 +1056,9 @@ function ProviderContent({
|
|||||||
? 'local'
|
? 'local'
|
||||||
: 'api_key',
|
: 'api_key',
|
||||||
baseUrl: baseUrl.trim() || undefined,
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
apiProtocol: (selectedProvider === 'custom' || selectedProvider === 'ollama')
|
||||||
|
? apiProtocol
|
||||||
|
: undefined,
|
||||||
model: effectiveModelId,
|
model: effectiveModelId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
@@ -1056,6 +1076,7 @@ function ProviderContent({
|
|||||||
label: accountPayload.label,
|
label: accountPayload.label,
|
||||||
authMode: accountPayload.authMode,
|
authMode: accountPayload.authMode,
|
||||||
baseUrl: accountPayload.baseUrl,
|
baseUrl: accountPayload.baseUrl,
|
||||||
|
apiProtocol: accountPayload.apiProtocol,
|
||||||
model: accountPayload.model,
|
model: accountPayload.model,
|
||||||
enabled: accountPayload.enabled,
|
enabled: accountPayload.enabled,
|
||||||
},
|
},
|
||||||
@@ -1212,7 +1233,7 @@ function ProviderContent({
|
|||||||
<Input
|
<Input
|
||||||
id="baseUrl"
|
id="baseUrl"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="https://api.example.com/v1"
|
placeholder={getProtocolBaseUrlPlaceholder(apiProtocol)}
|
||||||
value={baseUrl}
|
value={baseUrl}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBaseUrl(e.target.value);
|
setBaseUrl(e.target.value);
|
||||||
@@ -1246,6 +1267,59 @@ function ProviderContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedProvider === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('provider.protocol')}</Label>
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setApiProtocol('openai-completions');
|
||||||
|
onConfiguredChange(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-2 px-3 rounded-lg border transition-colors',
|
||||||
|
apiProtocol === 'openai-completions'
|
||||||
|
? 'bg-primary/10 border-primary/30 font-medium'
|
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('provider.protocols.openaiCompletions')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setApiProtocol('openai-responses');
|
||||||
|
onConfiguredChange(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-2 px-3 rounded-lg border transition-colors',
|
||||||
|
apiProtocol === 'openai-responses'
|
||||||
|
? 'bg-primary/10 border-primary/30 font-medium'
|
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('provider.protocols.openaiResponses')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setApiProtocol('anthropic-messages');
|
||||||
|
onConfiguredChange(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 py-2 px-3 rounded-lg border transition-colors',
|
||||||
|
apiProtocol === 'anthropic-messages'
|
||||||
|
? 'bg-primary/10 border-primary/30 font-medium'
|
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('provider.protocols.anthropic')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Auth mode toggle for providers supporting both */}
|
{/* Auth mode toggle for providers supporting both */}
|
||||||
{isOAuth && supportsApiKey && (
|
{isOAuth && supportsApiKey && (
|
||||||
<div className="flex rounded-lg border overflow-hidden text-sm">
|
<div className="flex rounded-lg border overflow-hidden text-sm">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface ProviderState {
|
|||||||
validateAccountApiKey: (
|
validateAccountApiKey: (
|
||||||
accountId: string,
|
accountId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string }
|
options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] }
|
||||||
) => Promise<{ valid: boolean; error?: string }>;
|
) => Promise<{ valid: boolean; error?: string }>;
|
||||||
getAccountApiKey: (accountId: string) => Promise<string | null>;
|
getAccountApiKey: (accountId: string) => Promise<string | null>;
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ interface ProviderState {
|
|||||||
validateApiKey: (
|
validateApiKey: (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options?: { baseUrl?: string }
|
options?: { baseUrl?: string; apiProtocol?: ProviderAccount['apiProtocol'] }
|
||||||
) => Promise<{ valid: boolean; error?: string }>;
|
) => Promise<{ valid: boolean; error?: string }>;
|
||||||
getApiKey: (providerId: string) => Promise<string | null>;
|
getApiKey: (providerId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('validateApiKeyWithProvider', () => {
|
|||||||
|
|
||||||
const result = await validateApiKeyWithProvider('minimax-portal-cn', 'sk-cn-test');
|
const result = await validateApiKeyWithProvider('minimax-portal-cn', 'sk-cn-test');
|
||||||
|
|
||||||
expect(result).toEqual({ valid: true });
|
expect(result).toMatchObject({ valid: true });
|
||||||
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
||||||
'https://api.minimaxi.com/anthropic/v1/models?limit=1',
|
'https://api.minimaxi.com/anthropic/v1/models?limit=1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -39,7 +39,7 @@ describe('validateApiKeyWithProvider', () => {
|
|||||||
|
|
||||||
const result = await validateApiKeyWithProvider('openai', 'sk-openai-test');
|
const result = await validateApiKeyWithProvider('openai', 'sk-openai-test');
|
||||||
|
|
||||||
expect(result).toEqual({ valid: true });
|
expect(result).toMatchObject({ valid: true });
|
||||||
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
expect(proxyAwareFetch).toHaveBeenCalledWith(
|
||||||
'https://api.openai.com/v1/models?limit=1',
|
'https://api.openai.com/v1/models?limit=1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -49,4 +49,109 @@ describe('validateApiKeyWithProvider', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to /responses for openai-responses when /models is unavailable', async () => {
|
||||||
|
proxyAwareFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Not Found' } }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Unknown model' } }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation');
|
||||||
|
const result = await validateApiKeyWithProvider('custom', 'sk-response-test', {
|
||||||
|
baseUrl: 'https://responses.example.com/v1',
|
||||||
|
apiProtocol: 'openai-responses',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ valid: true });
|
||||||
|
expect(proxyAwareFetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'https://responses.example.com/v1/models?limit=1',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer sk-response-test',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(proxyAwareFetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'https://responses.example.com/v1/responses',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to /chat/completions for openai-completions when /models is unavailable', async () => {
|
||||||
|
proxyAwareFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Not Found' } }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Unknown model' } }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation');
|
||||||
|
const result = await validateApiKeyWithProvider('custom', 'sk-chat-test', {
|
||||||
|
baseUrl: 'https://chat.example.com/v1',
|
||||||
|
apiProtocol: 'openai-completions',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ valid: true });
|
||||||
|
expect(proxyAwareFetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'https://chat.example.com/v1/chat/completions',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate endpoint suffix when baseUrl already points to /responses', async () => {
|
||||||
|
proxyAwareFetch
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Not Found' } }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ error: { message: 'Unknown model' } }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { validateApiKeyWithProvider } = await import('@electron/services/providers/provider-validation');
|
||||||
|
const result = await validateApiKeyWithProvider('custom', 'sk-endpoint-test', {
|
||||||
|
baseUrl: 'https://openrouter.ai/api/v1/responses',
|
||||||
|
apiProtocol: 'openai-responses',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ valid: true });
|
||||||
|
expect(proxyAwareFetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'https://openrouter.ai/api/v1/models?limit=1',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(proxyAwareFetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'https://openrouter.ai/api/v1/responses',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user