diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index b9e9cb8846..bff143d118 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -88,11 +88,16 @@ export function getSdkClient( } }) } - const baseURL = + let baseURL = provider.type === 'anthropic' ? provider.apiHost : (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost + // Anthropic SDK automatically appends /v1 to all endpoints (like /v1/messages, /v1/models) + // We need to strip api version from baseURL to avoid duplication (e.g., /v3/v1/models) + // formatProviderApiHost adds /v1 for AI SDK compatibility, but Anthropic SDK needs it removed + baseURL = baseURL.replace(/\/v\d+(?:alpha|beta)?(?=\/|$)/i, '') + logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id }) if (provider.id === 'aihubmix') { diff --git a/src/renderer/src/aiCore/index_new.ts b/src/renderer/src/aiCore/index_new.ts index efc9ba992f..cf12a46aa6 100644 --- a/src/renderer/src/aiCore/index_new.ts +++ b/src/renderer/src/aiCore/index_new.ts @@ -50,7 +50,40 @@ export default class ModernAiProvider { private model?: Model private localProvider: Awaited | null = null - // 构造函数重载签名 + /** + * Constructor for ModernAiProvider + * + * @param modelOrProvider - Model or Provider object + * @param provider - Optional Provider object (only used when first param is Model) + * + * @remarks + * **Important behavior notes**: + * + * 1. When called with `(model)`: + * - Calls `getActualProvider(model)` to retrieve and format the provider + * - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1` + * + * 2. When called with `(model, provider)`: + * - **Directly uses the provided provider WITHOUT going through `getActualProvider`** + * - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added** + * - This is legacy behavior kept for backward compatibility + * + * 3. When called with `(provider)`: + * - Directly uses the provider without requiring a model + * - Used for operations that don't need a model (e.g., fetchModels) + * + * @example + * ```typescript + * // Recommended: Auto-format URL + * const ai = new ModernAiProvider(model) + * + * // Not recommended: Skip URL formatting (only for special cases) + * const ai = new ModernAiProvider(model, customProvider) + * + * // For operations that don't need a model + * const ai = new ModernAiProvider(provider) + * ``` + */ constructor(model: Model, provider?: Provider) constructor(provider: Provider) constructor(modelOrProvider: Model | Provider, provider?: Provider) @@ -322,10 +355,10 @@ export default class ModernAiProvider { } } - /** - * 使用现代化 AI SDK 的图像生成实现,支持流式输出 - * @deprecated 已改为使用 legacy 实现以支持图片编辑等高级功能 - */ + // /** + // * 使用现代化 AI SDK 的图像生成实现,支持流式输出 + // * @deprecated 已改为使用 legacy 实现以支持图片编辑等高级功能 + // */ /* private async modernImageGeneration( model: ImageModel, diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index ecc2cd6032..53194d3506 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -90,6 +90,7 @@ function formatProviderApiHost(provider: Provider): Provider { if (isAnthropicProvider(provider)) { const baseHost = formatted.anthropicApiHost || formatted.apiHost + // AI SDK needs /v1 in baseURL, Anthropic SDK will strip it in getSdkClient formatted.apiHost = formatApiHost(baseHost) if (!formatted.anthropicApiHost) { formatted.anthropicApiHost = formatted.apiHost diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index b1cbb9be6c..f341ac9229 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -298,7 +298,10 @@ const ProviderSetting: FC = ({ providerId }) => { } if (isAnthropicProvider(provider)) { - return formatApiHost(apiHost) + '/messages' + // AI SDK uses the baseURL with /v1, then appends /messages + // formatApiHost adds /v1 automatically if not present + const normalizedHost = formatApiHost(apiHost) + return normalizedHost + '/messages' } if (isGeminiProvider(provider)) { @@ -351,6 +354,7 @@ const ProviderSetting: FC = ({ providerId }) => { const anthropicHostPreview = useMemo(() => { const rawHost = anthropicApiHost ?? provider.anthropicApiHost + // AI SDK uses the baseURL with /v1, then appends /messages const normalizedHost = formatApiHost(rawHost) return `${normalizedHost}/messages` diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index f19c90b61f..1b2bf0433f 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -2,8 +2,6 @@ * 职责:提供原子化的、无状态的API调用函数 */ import { loggerService } from '@logger' -import AiProvider from '@renderer/aiCore' -import type { CompletionsParams } from '@renderer/aiCore/legacy/middleware/schemas' import type { AiSdkMiddlewareConfig } from '@renderer/aiCore/middleware/AiSdkMiddlewareBuilder' import { buildStreamTextParams } from '@renderer/aiCore/prepareParams' import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingModel } from '@renderer/config/models' @@ -463,76 +461,55 @@ export function checkApiProvider(provider: Provider): void { export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise { checkApiProvider(provider) + // Don't pass in provider parameter. We need auto-format URL const ai = new AiProviderNew(model) const assistant = getDefaultAssistant() assistant.model = model assistant.prompt = 'test' // 避免部分 provider 空系统提示词会报错 - try { - if (isEmbeddingModel(model)) { - // race 超时 15s - logger.silly("it's a embedding model") - const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)) - await Promise.race([ai.getEmbeddingDimensions(model), timerPromise]) - } else { - const abortId = uuid() - const signal = readyToAbort(abortId) - let chunkError - const params: StreamTextParams = { - system: assistant.prompt, - prompt: 'hi', - abortSignal: signal - } - const config: ModernAiProviderConfig = { - streamOutput: true, - enableReasoning: false, - isSupportedToolUse: false, - isImageGenerationEndpoint: false, - enableWebSearch: false, - enableGenerateImage: false, - isPromptToolUse: false, - enableUrlContext: false, - assistant, - callType: 'check', - onChunk: (chunk: Chunk) => { - if (chunk.type === ChunkType.ERROR) { - chunkError = chunk.error - } else { - abortCompletion(abortId) - } - } - } - // Try streaming check first - try { - await ai.completions(model.id, params, config) - } catch (e) { - if (!isAbortError(e) && !isAbortError(chunkError)) { - throw e + if (isEmbeddingModel(model)) { + // race 超时 15s + logger.silly("it's a embedding model") + const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)) + await Promise.race([ai.getEmbeddingDimensions(model), timerPromise]) + } else { + const abortId = uuid() + const signal = readyToAbort(abortId) + let chunkError + const params: StreamTextParams = { + system: assistant.prompt, + prompt: 'hi', + abortSignal: signal + } + const config: ModernAiProviderConfig = { + streamOutput: true, + enableReasoning: false, + isSupportedToolUse: false, + isImageGenerationEndpoint: false, + enableWebSearch: false, + enableGenerateImage: false, + isPromptToolUse: false, + enableUrlContext: false, + assistant, + callType: 'check', + onChunk: (chunk: Chunk) => { + if (chunk.type === ChunkType.ERROR) { + chunkError = chunk.error + } else { + abortCompletion(abortId) } } } - } catch (error: any) { - // 失败回退legacy - const legacyAi = new AiProvider(provider) - if (error.message.includes('stream')) { - const params: CompletionsParams = { - callType: 'check', - messages: 'hi', - assistant, - streamOutput: false, - shouldThrow: true + + // Try streaming check + try { + await ai.completions(model.id, params, config) + } catch (e) { + if (!isAbortError(e) && !isAbortError(chunkError)) { + throw e } - const result = await legacyAi.completions(params) - if (!result.getText()) { - throw new Error('No response received') - } - } else { - throw error } - // } finally { - // removeAbortController(taskId, abortFn) - // } } }