feat: enhance API version handling and cache functionality

- Updated reasoning cache to use tool-specific keys for better organization.
- Added methods to list cache keys and entries.
- Improved API version regex patterns for more accurate matching.
- Refactored API host formatting to handle leading/trailing whitespace and slashes.
- Added functions to extract and remove trailing API version segments from URLs.
This commit is contained in:
suyao 2025-11-30 07:10:10 +08:00
parent c6c7c240a3
commit 3989229f61
No known key found for this signature in database
4 changed files with 92 additions and 27 deletions

View File

@ -186,7 +186,7 @@ export class AiSdkToAnthropicSSE {
// === Tool Events ===
case 'tool-call':
if (this.reasoningCache && chunk.providerMetadata?.google?.thoughtSignature) {
this.reasoningCache.set('google', chunk.providerMetadata?.google?.thoughtSignature)
this.reasoningCache.set(`google-${chunk.toolName}`, chunk.providerMetadata?.google?.thoughtSignature)
}
// FIXME: 按toolcall id绑定
if (
@ -555,7 +555,6 @@ export class AiSdkToAnthropicSSE {
}
this.onEvent(messageStopEvent)
this.reasoningCache?.destroy?.()
}
/**

View File

@ -27,18 +27,35 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
}
/**
* Checks if the host path contains a version string (e.g., /v1, /v2beta).
* Matches a version segment in a path that starts with `/v<number>` and optionally
* continues with `alpha` or `beta`. The segment may be followed by `/` or the end
* of the string (useful for cases like `/v3alpha/resources`).
*/
const VERSION_REGEX_PATTERN = '\\/v\\d+(?:alpha|beta)?(?=\\/|$)'
/**
* Matches an API version at the end of a URL (with optional trailing slash).
* Used to detect and extract versions only from the trailing position.
*/
const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i
/**
* host path /v1/v2beta
*
* @param host - host path
* @returns path true false
*/
export function hasAPIVersion(host?: string): boolean {
if (!host) return false
const versionRegex = /\/v\d+(?:alpha|beta)?(?=\/|$)/i
const regex = new RegExp(VERSION_REGEX_PATTERN, 'i')
try {
const url = new URL(host)
return versionRegex.test(url.pathname)
return regex.test(url.pathname)
} catch {
return versionRegex.test(host)
// 若无法作为完整 URL 解析,则当作路径直接检测
return regex.test(host)
}
}
@ -71,22 +88,26 @@ export function formatVertexApiHost(
/**
* Formats an API host URL by normalizing it and optionally appending an API version.
*
* @param host - The API host URL to format
* @param isSupportedAPIVersion - Whether the API version is supported. Defaults to `true`.
* @param host - The API host URL to format. Leading/trailing whitespace will be trimmed and trailing slashes removed.
* @param supportApiVersion - Whether the API version is supported. Defaults to `true`.
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
*
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is.
* Otherwise, returns the host with the API version appended.
*
* @example
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#'
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
*/
export function formatApiHost(host?: string, isSupportedAPIVersion: boolean = true, apiVersion: string = 'v1'): string {
const normalizedHost = withoutTrailingSlash((host || '').trim())
export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
const normalizedHost = withoutTrailingSlash(trim(host))
if (!normalizedHost) {
return ''
}
if (normalizedHost.endsWith('#') || !isSupportedAPIVersion || hasAPIVersion(normalizedHost)) {
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) {
return normalizedHost
}
return `${normalizedHost}/${apiVersion}`
@ -175,3 +196,50 @@ export function validateApiHost(apiHost: string): boolean {
return false
}
}
/**
* Extracts the trailing API version segment from a URL path.
*
* This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL.
* Only versions at the end of the path are extracted, not versions in the middle.
* The returned version string does not include leading or trailing slashes.
*
* @param {string} url - The URL string to parse.
* @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found.
*
* @example
* getTrailingApiVersion('https://api.example.com/v1') // 'v1'
* getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta'
* getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end)
* getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta'
* getTrailingApiVersion('https://api.example.com') // undefined
*/
export function getTrailingApiVersion(url: string): string | undefined {
const match = url.match(TRAILING_VERSION_REGEX)
if (match) {
// Extract version without leading slash and trailing slash
return match[0].replace(/^\//, '').replace(/\/$/, '')
}
return undefined
}
/**
* Removes the trailing API version segment from a URL path.
*
* This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL.
* Only versions at the end of the path are removed, not versions in the middle.
*
* @param {string} url - The URL string to process.
* @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found.
*
* @example
* withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com'
* withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change)
* withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com'
*/
export function withoutTrailingApiVersion(url: string): string {
return url.replace(TRAILING_VERSION_REGEX, '')
}

View File

@ -54,6 +54,18 @@ export class ReasoningCache<T> {
return entry.details
}
listKeys(): string[] {
return Array.from(this.cache.keys())
}
listEntries(): Array<{ key: string; entry: CacheEntry<T> }> {
const entries: Array<{ key: string; entry: CacheEntry<T> }> = []
for (const [key, entry] of this.cache.entries()) {
entries.push({ key, entry })
}
return entries
}
/**
* Clear expired entries
*/

View File

@ -237,7 +237,6 @@ function convertAnthropicToolsToAiSdk(tools: MessageCreateParams['tools']): Reco
inputSchema: zodSchema(schema)
})
logger.debug('Converted Anthropic tool to AI SDK tool', aiTool)
aiSdkTools[toolDef.name] = aiTool
}
return Object.keys(aiSdkTools).length > 0 ? aiSdkTools : undefined
@ -302,8 +301,9 @@ function convertAnthropicToAiMessages(params: MessageCreateParams): ModelMessage
}
} else if (block.type === 'tool_use') {
const options: ProviderOptions = {}
if (isGemini3ModelId(params.model)) {
if (reasoningCache.get('google')) {
if (reasoningCache.get(`google-${block.name}`)) {
options.google = {
thoughtSignature: MAGIC_STRING
}
@ -394,11 +394,6 @@ async function createAiSdkProvider(config: AiSdkConfig): Promise<AiSdkProvider>
const provider = await createProviderCore(providerId, config.options)
logger.debug('AI SDK provider created', {
providerId,
hasOptions: !!config.options
})
return provider
}
@ -424,7 +419,6 @@ async function prepareSpecialProviderConfig(provider: Provider, config: AiSdkCon
...headers,
...existingHeaders
}
logger.debug('Copilot token retrieved successfully')
} catch (error) {
logger.error('Failed to get Copilot token', error as Error)
throw new Error('Failed to get Copilot token. Please re-authorize Copilot.')
@ -450,7 +444,6 @@ async function prepareSpecialProviderConfig(provider: Provider, config: AiSdkCon
baseURL: 'https://api.anthropic.com/v1',
apiKey: ''
}
logger.debug('Anthropic OAuth token retrieved successfully')
} catch (error) {
logger.error('Failed to get Anthropic OAuth token', error as Error)
throw new Error('Failed to get Anthropic OAuth token. Please re-authorize.')
@ -479,7 +472,6 @@ async function prepareSpecialProviderConfig(provider: Provider, config: AiSdkCon
}
})
}
logger.debug('CherryAI signed fetch configured')
break
}
}
@ -498,12 +490,6 @@ async function executeStream(config: ExecuteStreamConfig): Promise<AiSdkToAnthro
// Prepare special provider config (Copilot, Anthropic OAuth, etc.)
sdkConfig = await prepareSpecialProviderConfig(provider, sdkConfig)
logger.debug('Created AI SDK config', {
providerId: sdkConfig.providerId,
hasOptions: !!sdkConfig.options,
message: params.messages
})
// Create provider instance and get language model
const aiSdkProvider = await createAiSdkProvider(sdkConfig)
const baseModel = aiSdkProvider.languageModel(modelId)