diff --git a/packages/shared/adapters/AiSdkToAnthropicSSE.ts b/src/main/apiServer/adapters/AiSdkToAnthropicSSE.ts similarity index 95% rename from packages/shared/adapters/AiSdkToAnthropicSSE.ts rename to src/main/apiServer/adapters/AiSdkToAnthropicSSE.ts index c4f2355c0a..b5b52c4e03 100644 --- a/packages/shared/adapters/AiSdkToAnthropicSSE.ts +++ b/src/main/apiServer/adapters/AiSdkToAnthropicSSE.ts @@ -36,9 +36,10 @@ import type { Usage } from '@anthropic-ai/sdk/resources/messages' import { loggerService } from '@logger' -import type { JSONValue } from 'ai' import { type FinishReason, type LanguageModelUsage, type TextStreamPart, type ToolSet } from 'ai' +import { googleReasoningCache, openRouterReasoningCache } from '../../services/CacheService' + const logger = loggerService.withContext('AiSdkToAnthropicSSE') interface ContentBlockState { @@ -71,20 +72,11 @@ interface AdapterState { export type SSEEventCallback = (event: RawMessageStreamEvent) => void -/** - * Interface for a simple cache that stores reasoning details - */ -export interface ReasoningCacheInterface { - set(signature: string, details: JSONValue): void - destroy?(): void -} - export interface AiSdkToAnthropicSSEOptions { model: string messageId?: string inputTokens?: number onEvent: SSEEventCallback - reasoningCache?: ReasoningCacheInterface } /** @@ -93,11 +85,9 @@ export interface AiSdkToAnthropicSSEOptions { export class AiSdkToAnthropicSSE { private state: AdapterState private onEvent: SSEEventCallback - private reasoningCache?: ReasoningCacheInterface constructor(options: AiSdkToAnthropicSSEOptions) { this.onEvent = options.onEvent - this.reasoningCache = options.reasoningCache this.state = { messageId: options.messageId || `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, model: options.model, @@ -185,16 +175,22 @@ export class AiSdkToAnthropicSSE { // === Tool Events === case 'tool-call': - if (this.reasoningCache && chunk.providerMetadata?.google?.thoughtSignature) { - this.reasoningCache.set(`google-${chunk.toolName}`, chunk.providerMetadata?.google?.thoughtSignature) + if (googleReasoningCache && chunk.providerMetadata?.google?.thoughtSignature) { + googleReasoningCache.set( + `google-${chunk.toolName}`, + chunk.providerMetadata?.google?.thoughtSignature as string + ) } // FIXME: 按toolcall id绑定 if ( - this.reasoningCache && + openRouterReasoningCache && chunk.providerMetadata?.openrouter?.reasoning_details && Array.isArray(chunk.providerMetadata.openrouter.reasoning_details) ) { - this.reasoningCache.set('openrouter', chunk.providerMetadata.openrouter.reasoning_details) + openRouterReasoningCache.set( + 'openrouter', + JSON.parse(JSON.stringify(chunk.providerMetadata.openrouter.reasoning_details)) + ) } this.handleToolCall({ type: 'tool-call', diff --git a/packages/shared/adapters/index.ts b/src/main/apiServer/adapters/index.ts similarity index 100% rename from packages/shared/adapters/index.ts rename to src/main/apiServer/adapters/index.ts diff --git a/src/main/apiServer/adapters/openrouter.ts b/src/main/apiServer/adapters/openrouter.ts new file mode 100644 index 0000000000..3b63191781 --- /dev/null +++ b/src/main/apiServer/adapters/openrouter.ts @@ -0,0 +1,95 @@ +import * as z from 'zod/v4' + +enum ReasoningFormat { + Unknown = 'unknown', + OpenAIResponsesV1 = 'openai-responses-v1', + XAIResponsesV1 = 'xai-responses-v1', + AnthropicClaudeV1 = 'anthropic-claude-v1', + GoogleGeminiV1 = 'google-gemini-v1' +} + +// Anthropic Claude was the first reasoning that we're +// passing back and forth +export const DEFAULT_REASONING_FORMAT = ReasoningFormat.AnthropicClaudeV1 + +function isDefinedOrNotNull(value: T | null | undefined): value is T { + return value !== null && value !== undefined +} + +export enum ReasoningDetailType { + Summary = 'reasoning.summary', + Encrypted = 'reasoning.encrypted', + Text = 'reasoning.text' +} + +export const CommonReasoningDetailSchema = z + .object({ + id: z.string().nullish(), + format: z.enum(ReasoningFormat).nullish(), + index: z.number().optional() + }) + .loose() + +export const ReasoningDetailSummarySchema = z + .object({ + type: z.literal(ReasoningDetailType.Summary), + summary: z.string() + }) + .extend(CommonReasoningDetailSchema.shape) +export type ReasoningDetailSummary = z.infer + +export const ReasoningDetailEncryptedSchema = z + .object({ + type: z.literal(ReasoningDetailType.Encrypted), + data: z.string() + }) + .extend(CommonReasoningDetailSchema.shape) + +export type ReasoningDetailEncrypted = z.infer + +export const ReasoningDetailTextSchema = z + .object({ + type: z.literal(ReasoningDetailType.Text), + text: z.string().nullish(), + signature: z.string().nullish() + }) + .extend(CommonReasoningDetailSchema.shape) + +export type ReasoningDetailText = z.infer + +export const ReasoningDetailUnionSchema = z.union([ + ReasoningDetailSummarySchema, + ReasoningDetailEncryptedSchema, + ReasoningDetailTextSchema +]) + +export type ReasoningDetailUnion = z.infer + +const ReasoningDetailsWithUnknownSchema = z.union([ReasoningDetailUnionSchema, z.unknown().transform(() => null)]) + +export const ReasoningDetailArraySchema = z + .array(ReasoningDetailsWithUnknownSchema) + .transform((d) => d.filter((d): d is ReasoningDetailUnion => !!d)) + +export const OutputUnionToReasoningDetailsSchema = z.union([ + z + .object({ + delta: z.object({ + reasoning_details: z.array(ReasoningDetailsWithUnknownSchema) + }) + }) + .transform((data) => data.delta.reasoning_details.filter(isDefinedOrNotNull)), + z + .object({ + message: z.object({ + reasoning_details: z.array(ReasoningDetailsWithUnknownSchema) + }) + }) + .transform((data) => data.message.reasoning_details.filter(isDefinedOrNotNull)), + z + .object({ + text: z.string(), + reasoning_details: z.array(ReasoningDetailsWithUnknownSchema) + }) + .transform((data) => data.reasoning_details.filter(isDefinedOrNotNull)) +]) diff --git a/src/main/apiServer/services/cache.ts b/src/main/apiServer/services/cache.ts deleted file mode 100644 index 9515778e16..0000000000 --- a/src/main/apiServer/services/cache.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { loggerService } from '@logger' -import type { JSONValue } from 'ai' - -const logger = loggerService.withContext('Cache') -/** - * Cache entry with TTL support - */ -interface CacheEntry { - details: T - timestamp: number -} - -/** - * In-memory cache for reasoning details - * Key: signature - * Value: reasoning array with timestamp - */ -export class ReasoningCache { - private cache = new Map>() - private readonly ttlMs: number - private cleanupInterval: ReturnType | null = null - - constructor(ttlMs: number = 30 * 60 * 1000) { - // Default 30 minutes TTL - this.ttlMs = ttlMs - this.startCleanup() - } - - /** - * Store reasoning details by signature - */ - set(signature: string, details: T): void { - if (!signature || !details) return - - this.cache.set(signature, { - details, - timestamp: Date.now() - }) - } - - /** - * Retrieve reasoning details by signature - */ - get(signature: string): T | undefined { - const entry = this.cache.get(signature) - if (!entry) return undefined - - // Check TTL - if (Date.now() - entry.timestamp > this.ttlMs) { - this.cache.delete(signature) - return undefined - } - - return entry.details - } - - listKeys(): string[] { - return Array.from(this.cache.keys()) - } - - listEntries(): Array<{ key: string; entry: CacheEntry }> { - const entries: Array<{ key: string; entry: CacheEntry }> = [] - for (const [key, entry] of this.cache.entries()) { - entries.push({ key, entry }) - } - return entries - } - - /** - * Clear expired entries - */ - cleanup(): void { - const now = Date.now() - let cleaned = 0 - - for (const [key, entry] of this.cache) { - if (now - entry.timestamp > this.ttlMs) { - this.cache.delete(key) - cleaned++ - } - } - - if (cleaned > 0) { - logger.debug('Cleaned up expired reasoning cache entries', { cleaned, remaining: this.cache.size }) - } - } - - /** - * Start periodic cleanup - */ - private startCleanup(): void { - // Cleanup every 5 minutes - this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000) - } - - /** - * Stop cleanup and clear cache - */ - destroy(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - this.cache.clear() - } - - /** - * Get cache stats for debugging - */ - stats(): { size: number; ttlMs: number } { - return { - size: this.cache.size, - ttlMs: this.ttlMs - } - } -} - -// Singleton cache instance -export const reasoningCache = new ReasoningCache() diff --git a/src/main/apiServer/services/unified-messages.ts b/src/main/apiServer/services/unified-messages.ts index 815b1217f2..7c85e2d6cf 100644 --- a/src/main/apiServer/services/unified-messages.ts +++ b/src/main/apiServer/services/unified-messages.ts @@ -9,11 +9,11 @@ import type { import { type AiPlugin, createExecutor } from '@cherrystudio/ai-core' import { createProvider as createProviderCore } from '@cherrystudio/ai-core/provider' import { loggerService } from '@logger' +import { AiSdkToAnthropicSSE, formatSSEDone, formatSSEEvent } from '@main/apiServer/adapters' import { generateSignature as cherryaiGenerateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' import copilotService from '@main/services/CopilotService' import { reduxService } from '@main/services/ReduxService' -import { AiSdkToAnthropicSSE, formatSSEDone, formatSSEEvent } from '@shared/adapters' import { isGemini3ModelId } from '@shared/middleware' import { type AiSdkConfig, @@ -33,12 +33,16 @@ import { net } from 'electron' import type { Response } from 'express' import * as z from 'zod' -import { reasoningCache } from './cache' +import { googleReasoningCache, openRouterReasoningCache } from '../../services/CacheService' const logger = loggerService.withContext('UnifiedMessagesService') const MAGIC_STRING = 'skip_thought_signature_validator' +function sanitizeJson(value: unknown): JSONValue { + return JSON.parse(JSON.stringify(value)) +} + initializeSharedProviders({ warn: (message) => logger.warn(message), error: (message, error) => logger.error(message, error) @@ -303,13 +307,13 @@ function convertAnthropicToAiMessages(params: MessageCreateParams): ModelMessage const options: ProviderOptions = {} if (isGemini3ModelId(params.model)) { - if (reasoningCache.get(`google-${block.name}`)) { + if (googleReasoningCache.get(`google-${block.name}`)) { options.google = { thoughtSignature: MAGIC_STRING } - } else if (reasoningCache.get('openrouter')) { + } else if (openRouterReasoningCache.get('openrouter')) { options.openrouter = { - reasoning_details: (reasoningCache.get('openrouter') as JSONValue[]) || [] + reasoning_details: (sanitizeJson(openRouterReasoningCache.get('openrouter')) as JSONValue[]) || [] } } } @@ -345,10 +349,10 @@ function convertAnthropicToAiMessages(params: MessageCreateParams): ModelMessage const assistantContent = [...reasoningParts, ...textParts, ...toolCallParts] if (assistantContent.length > 0) { let providerOptions: ProviderOptions | undefined = undefined - if (reasoningCache.get('openrouter')) { + if (openRouterReasoningCache.get('openrouter')) { providerOptions = { openrouter: { - reasoning_details: (reasoningCache.get('openrouter') as JSONValue[]) || [] + reasoning_details: (sanitizeJson(openRouterReasoningCache.get('openrouter')) as JSONValue[]) || [] } } } else if (isGemini3ModelId(params.model)) { @@ -510,8 +514,7 @@ async function executeStream(config: ExecuteStreamConfig): Promise {}), - reasoningCache + onEvent: onEvent || (() => {}) }) // Execute stream - pass model object instead of string diff --git a/src/main/services/CacheService.ts b/src/main/services/CacheService.ts index d2984a9984..b9de349b7b 100644 --- a/src/main/services/CacheService.ts +++ b/src/main/services/CacheService.ts @@ -4,6 +4,26 @@ interface CacheItem { duration: number } +// Import the reasoning detail type from openrouter adapter +type ReasoningDetailUnion = { + id?: string | null + format?: 'unknown' | 'openai-responses-v1' | 'xai-responses-v1' | 'anthropic-claude-v1' | 'google-gemini-v1' | null + index?: number + type: 'reasoning.summary' | 'reasoning.encrypted' | 'reasoning.text' + summary?: string + data?: string + text?: string | null + signature?: string | null +} + +/** + * Interface for reasoning cache + */ +export interface IReasoningCache { + set(key: string, value: T): void + get(key: string): T | undefined +} + export class CacheService { private static cache: Map> = new Map() @@ -72,3 +92,14 @@ export class CacheService { return true } } + +// Singleton cache instances using CacheService +export const googleReasoningCache: IReasoningCache = { + set: (key, value) => CacheService.set(`google-reasoning:${key}`, value, 30 * 60 * 1000), + get: (key) => CacheService.get(`google-reasoning:${key}`) || undefined +} + +export const openRouterReasoningCache: IReasoningCache = { + set: (key, value) => CacheService.set(`openrouter-reasoning:${key}`, value, 30 * 60 * 1000), + get: (key) => CacheService.get(`openrouter-reasoning:${key}`) || undefined +}