fix(providers): complete custom openai-responses support (#436)

This commit is contained in:
Felix
2026-03-12 16:19:05 +08:00
committed by GitHub
Unverified
parent 272432783a
commit c0c8701cc3
13 changed files with 414 additions and 67 deletions

View File

@@ -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) };

View File

@@ -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,

View File

@@ -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':

View File

@@ -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>

View File

@@ -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": {
@@ -245,4 +247,4 @@
"docs": "Website", "docs": "Website",
"github": "GitHub" "github": "GitHub"
} }
} }

View File

@@ -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",
@@ -138,4 +144,4 @@
"description": "Shell command execution" "description": "Shell command execution"
} }
} }
} }

View File

@@ -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": "プロバイダーの追加に失敗しました",
@@ -237,4 +244,4 @@
"docs": "公式サイト", "docs": "公式サイト",
"github": "GitHub" "github": "GitHub"
} }
} }

View File

@@ -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": "検証して保存",
@@ -138,4 +144,4 @@
"description": "シェルコマンドの実行" "description": "シェルコマンドの実行"
} }
} }
} }

View File

@@ -79,6 +79,8 @@
}, },
"protocols": { "protocols": {
"openai": "OpenAI 兼容", "openai": "OpenAI 兼容",
"openaiCompletions": "OpenAI Completions",
"openaiResponses": "OpenAI Responses",
"anthropic": "Anthropic 兼容" "anthropic": "Anthropic 兼容"
}, },
"toast": { "toast": {
@@ -245,4 +247,4 @@
"docs": "官网", "docs": "官网",
"github": "GitHub" "github": "GitHub"
} }
} }

View File

@@ -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": "验证并保存",
@@ -138,4 +144,4 @@
"description": "Shell 命令执行" "description": "Shell 命令执行"
} }
} }
} }

View File

@@ -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">

View File

@@ -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>;
} }

View File

@@ -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(),
);
});
}); });