fix(providers): complete custom openai-responses support (#436)
This commit is contained in:
@@ -26,16 +26,35 @@ type RuntimeProviderSyncContext = {
|
||||
api: string;
|
||||
};
|
||||
|
||||
function normalizeProviderBaseUrl(config: ProviderConfig, baseUrl?: string): string | undefined {
|
||||
function normalizeProviderBaseUrl(
|
||||
config: ProviderConfig,
|
||||
baseUrl?: string,
|
||||
apiProtocol?: string,
|
||||
): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = baseUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
|
||||
return 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 {
|
||||
@@ -266,7 +285,7 @@ async function syncRuntimeProviderConfig(
|
||||
context: RuntimeProviderSyncContext,
|
||||
): Promise<void> {
|
||||
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,
|
||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||
headers: context.meta?.headers,
|
||||
@@ -289,7 +308,7 @@ async function syncCustomProviderAgentModel(
|
||||
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(runtimeProviderKey, {
|
||||
baseUrl: config.baseUrl,
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
@@ -346,7 +365,7 @@ export async function syncUpdatedProviderToRuntime(
|
||||
if (config.type !== 'custom') {
|
||||
if (shouldUseExplicitDefaultOverride(config, ock)) {
|
||||
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,
|
||||
apiKeyEnv: context.meta?.apiKeyEnv,
|
||||
headers: context.meta?.headers,
|
||||
@@ -356,7 +375,7 @@ export async function syncUpdatedProviderToRuntime(
|
||||
}
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: config.baseUrl,
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
@@ -423,12 +442,16 @@ export async function syncDefaultProviderToRuntime(
|
||||
|
||||
if (provider.type === 'custom') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: provider.baseUrl,
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else if (shouldUseExplicitDefaultOverride(provider, ock)) {
|
||||
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,
|
||||
apiKeyEnv: getProviderConfig(provider.type)?.apiKeyEnv,
|
||||
headers: getProviderConfig(provider.type)?.headers,
|
||||
@@ -518,7 +541,7 @@ export async function syncDefaultProviderToRuntime(
|
||||
) {
|
||||
const modelId = provider.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: provider.baseUrl,
|
||||
baseUrl: normalizeProviderBaseUrl(provider, provider.baseUrl, provider.apiProtocol || 'openai-completions'),
|
||||
api: provider.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: providerKey,
|
||||
|
||||
@@ -2,12 +2,15 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { getProviderConfig } from '../../utils/provider-registry';
|
||||
|
||||
type ValidationProfile =
|
||||
| 'openai-compatible'
|
||||
| 'openai-completions'
|
||||
| 'openai-responses'
|
||||
| 'google-query-key'
|
||||
| 'anthropic-header'
|
||||
| 'openrouter'
|
||||
| 'none';
|
||||
|
||||
type ValidationResult = { valid: boolean; error?: string; status?: number };
|
||||
|
||||
function logValidationStatus(provider: string, status: number): void {
|
||||
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
||||
}
|
||||
@@ -49,6 +52,28 @@ function buildOpenAiModelsUrl(baseUrl: string): string {
|
||||
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(
|
||||
provider: string,
|
||||
method: string,
|
||||
@@ -68,8 +93,11 @@ function getValidationProfile(
|
||||
if (providerApi === 'anthropic-messages') {
|
||||
return 'anthropic-header';
|
||||
}
|
||||
if (providerApi === 'openai-completions' || providerApi === 'openai-responses') {
|
||||
return 'openai-compatible';
|
||||
if (providerApi === 'openai-responses') {
|
||||
return 'openai-responses';
|
||||
}
|
||||
if (providerApi === 'openai-completions') {
|
||||
return 'openai-completions';
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
@@ -82,7 +110,7 @@ function getValidationProfile(
|
||||
case 'ollama':
|
||||
return 'none';
|
||||
default:
|
||||
return 'openai-compatible';
|
||||
return 'openai-completions';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +118,14 @@ async function performProviderValidationRequest(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return classifyAuthResponse(response.status, data);
|
||||
const result = classifyAuthResponse(response.status, data);
|
||||
return { ...result, status: response.status };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -121,34 +150,73 @@ function classifyAuthResponse(
|
||||
async function validateOpenAiCompatibleKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
const trimmedBaseUrl = baseUrl?.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
|
||||
const { modelsUrl, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
|
||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||
|
||||
if (modelsResult.error?.includes('API error: 404')) {
|
||||
if (modelsResult.status === 404) {
|
||||
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);
|
||||
const chatUrl = `${base}/chat/completions`;
|
||||
return await performChatCompletionsProbe(providerType, chatUrl, headers);
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
return await performResponsesProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
return await performChatCompletionsProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
|
||||
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(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
@@ -186,7 +254,7 @@ async function performAnthropicMessagesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
@@ -224,7 +292,7 @@ async function validateGoogleQueryKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
||||
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
||||
return await performProviderValidationRequest(providerType, url, {});
|
||||
@@ -234,7 +302,7 @@ async function validateAnthropicHeaderKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
|
||||
const url = `${base}/models?limit=1`;
|
||||
@@ -246,7 +314,12 @@ async function validateAnthropicHeaderKey(
|
||||
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
|
||||
|
||||
// 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(
|
||||
`[clawx-validate] ${providerType} /models returned error, falling back to /messages probe`,
|
||||
);
|
||||
@@ -260,7 +333,7 @@ async function validateAnthropicHeaderKey(
|
||||
async function validateOpenRouterKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
const url = 'https://openrouter.ai/api/v1/auth/key';
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
return await performProviderValidationRequest(providerType, url, headers);
|
||||
@@ -270,7 +343,7 @@ export async function validateApiKeyWithProvider(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string; apiProtocol?: string },
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
): Promise<ValidationResult> {
|
||||
const profile = getValidationProfile(providerType, options);
|
||||
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
||||
|
||||
@@ -285,8 +358,20 @@ export async function validateApiKeyWithProvider(
|
||||
|
||||
try {
|
||||
switch (profile) {
|
||||
case 'openai-compatible':
|
||||
return await validateOpenAiCompatibleKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
case 'openai-completions':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-completions',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'openai-responses':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-responses',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'google-query-key':
|
||||
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
case 'anthropic-header':
|
||||
|
||||
Reference in New Issue
Block a user