fix(anthropic): prevent duplicate /v1 in API endpoints (#11467)
* fix(anthropic): prevent duplicate /v1 in API endpoints Anthropic SDK automatically appends /v1 to endpoints, so we should not add it in our formatting. This change ensures URLs are correctly formatted without duplicate path segments. * fix(anthropic): strip /v1 suffix in getSdkClient to prevent duplicate in models endpoint The issue was: - AI SDK (for chat) needs baseURL with /v1 suffix - Anthropic SDK (for listModels) automatically appends /v1 to all endpoints Solution: - Keep /v1 in formatProviderApiHost for AI SDK compatibility - Strip /v1 in getSdkClient before passing to Anthropic SDK - This ensures chat works correctly while preventing /v1/v1/models duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(anthropic): correct preview URL to match actual request behavior The preview now correctly shows: - Input: https://api.siliconflow.cn/v2 - Preview: https://api.siliconflow.cn/v2/messages (was incorrectly showing /v2/v1/messages) - Actual: https://api.siliconflow.cn/v2/messages This matches the actual behavior where getSdkClient strips /v1 suffix before passing to Anthropic SDK, which then appends /v1/messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(anthropic): strip all API version suffixes, not just /v1 The Anthropic SDK always appends /v1 to endpoints, regardless of the baseURL. Previously we only stripped /v1 suffix, causing issues with custom versions like /v2. Now we strip all version suffixes (/v1, /v2, /v1beta, etc.) before passing to Anthropic SDK. Examples: - Input: https://api.siliconflow.cn/v2/ - After strip: https://api.siliconflow.cn - Actual request: https://api.siliconflow.cn/v1/messages ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(anthropic): correct preview to show AI SDK behavior, not Anthropic SDK The preview was showing the wrong URL because it was reflecting Anthropic SDK behavior (which strips versions and uses /v1), but checkApi and chat use AI SDK which preserves the user's version path. Now preview correctly shows: - Input: https://api.siliconflow.cn/v2/ - AI SDK (checkApi/chat): https://api.siliconflow.cn/v2/messages ✅ - Preview: https://api.siliconflow.cn/v2/messages ✅ Note: Anthropic SDK (for listModels) still strips versions to use /v1/models, but this is not shown in preview since it's a different code path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(checkApi): remove unnecessary legacy fallback The legacy fallback logic in checkApi was: 1. Complex and hard to maintain 2. Never actually triggered in practice for Modern SDK supported providers 3. Could cause duplicate API requests Since Modern AI SDK now handles all major providers correctly, we can simplify by directly throwing errors instead of falling back. This also removes unused imports: AiProvider and CompletionsParams. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(anthropic): restore version stripping in getSdkClient for Anthropic SDK The Anthropic SDK (used for listModels) always appends /v1 to endpoints, so we need to strip version suffixes from baseURL to avoid duplication. This only affects Anthropic SDK operations (like listModels). AI SDK operations (chat/checkApi) use provider.apiHost directly via providerToAiSdkConfig, which preserves the user's version path. Examples: - AI SDK (chat): https://api.siliconflow.cn/v1 -> /v1/messages ✅ - Anthropic SDK (models): https://api.siliconflow.cn/v1 -> strip v1 -> /v1/models ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(anthropic): ensure AI SDK gets /v1 in baseURL, strip for Anthropic SDK The correct behavior is: 1. formatProviderApiHost: Add /v1 to apiHost (for AI SDK compatibility) 2. AI SDK (chat/checkApi): Use apiHost with /v1 -> /v1/messages ✅ 3. Anthropic SDK (listModels): Strip /v1 from baseURL -> SDK adds /v1/models ✅ 4. Preview: Show AI SDK behavior (main use case) -> /v1/messages ✅ Examples: - Input: https://api.siliconflow.cn - Formatted: https://api.siliconflow.cn/v1 (added by formatApiHost) - AI SDK: https://api.siliconflow.cn/v1/messages ✅ - Anthropic SDK: https://api.siliconflow.cn (stripped) + /v1/models ✅ - Preview: https://api.siliconflow.cn/v1/messages ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(ai): simplify AiProviderNew initialization and improve docs Update AiProviderNew constructor to automatically format URLs by default Add comprehensive documentation explaining constructor behavior and usage * chore: remove unused play.ts file * fix(anthropic): strip api version from baseURL to avoid endpoint duplication --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -50,7 +50,40 @@ export default class ModernAiProvider {
|
||||
private model?: Model
|
||||
private localProvider: Awaited<AiSdkProvider> | 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -298,7 +298,10 @@ const ProviderSetting: FC<Props> = ({ 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<Props> = ({ 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`
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user