mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-05 20:41:30 +08:00
Refactor: Remove old cache implementation and integrate new reasoning cache service
- Deleted the old ReasoningCache class and its instance. - Introduced CacheService for managing reasoning caches. - Updated unified-messages service to utilize new googleReasoningCache and openRouterReasoningCache. - Added AiSdkToAnthropicSSE adapter to handle streaming events and integrate with new cache service. - Reorganized shared adapters to include the new AiSdkToAnthropicSSE adapter. - Created openrouter adapter with detailed reasoning schemas for better type safety and validation.
This commit is contained in:
parent
4c1466cd27
commit
4a913fcef7
@ -36,9 +36,10 @@ import type {
|
|||||||
Usage
|
Usage
|
||||||
} from '@anthropic-ai/sdk/resources/messages'
|
} from '@anthropic-ai/sdk/resources/messages'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import type { JSONValue } from 'ai'
|
|
||||||
import { type FinishReason, type LanguageModelUsage, type TextStreamPart, type ToolSet } from 'ai'
|
import { type FinishReason, type LanguageModelUsage, type TextStreamPart, type ToolSet } from 'ai'
|
||||||
|
|
||||||
|
import { googleReasoningCache, openRouterReasoningCache } from '../../services/CacheService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('AiSdkToAnthropicSSE')
|
const logger = loggerService.withContext('AiSdkToAnthropicSSE')
|
||||||
|
|
||||||
interface ContentBlockState {
|
interface ContentBlockState {
|
||||||
@ -71,20 +72,11 @@ interface AdapterState {
|
|||||||
|
|
||||||
export type SSEEventCallback = (event: RawMessageStreamEvent) => void
|
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 {
|
export interface AiSdkToAnthropicSSEOptions {
|
||||||
model: string
|
model: string
|
||||||
messageId?: string
|
messageId?: string
|
||||||
inputTokens?: number
|
inputTokens?: number
|
||||||
onEvent: SSEEventCallback
|
onEvent: SSEEventCallback
|
||||||
reasoningCache?: ReasoningCacheInterface
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -93,11 +85,9 @@ export interface AiSdkToAnthropicSSEOptions {
|
|||||||
export class AiSdkToAnthropicSSE {
|
export class AiSdkToAnthropicSSE {
|
||||||
private state: AdapterState
|
private state: AdapterState
|
||||||
private onEvent: SSEEventCallback
|
private onEvent: SSEEventCallback
|
||||||
private reasoningCache?: ReasoningCacheInterface
|
|
||||||
|
|
||||||
constructor(options: AiSdkToAnthropicSSEOptions) {
|
constructor(options: AiSdkToAnthropicSSEOptions) {
|
||||||
this.onEvent = options.onEvent
|
this.onEvent = options.onEvent
|
||||||
this.reasoningCache = options.reasoningCache
|
|
||||||
this.state = {
|
this.state = {
|
||||||
messageId: options.messageId || `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
|
messageId: options.messageId || `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
@ -185,16 +175,22 @@ export class AiSdkToAnthropicSSE {
|
|||||||
|
|
||||||
// === Tool Events ===
|
// === Tool Events ===
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
if (this.reasoningCache && chunk.providerMetadata?.google?.thoughtSignature) {
|
if (googleReasoningCache && chunk.providerMetadata?.google?.thoughtSignature) {
|
||||||
this.reasoningCache.set(`google-${chunk.toolName}`, chunk.providerMetadata?.google?.thoughtSignature)
|
googleReasoningCache.set(
|
||||||
|
`google-${chunk.toolName}`,
|
||||||
|
chunk.providerMetadata?.google?.thoughtSignature as string
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// FIXME: 按toolcall id绑定
|
// FIXME: 按toolcall id绑定
|
||||||
if (
|
if (
|
||||||
this.reasoningCache &&
|
openRouterReasoningCache &&
|
||||||
chunk.providerMetadata?.openrouter?.reasoning_details &&
|
chunk.providerMetadata?.openrouter?.reasoning_details &&
|
||||||
Array.isArray(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({
|
this.handleToolCall({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
95
src/main/apiServer/adapters/openrouter.ts
Normal file
95
src/main/apiServer/adapters/openrouter.ts
Normal file
@ -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<T>(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<typeof ReasoningDetailSummarySchema>
|
||||||
|
|
||||||
|
export const ReasoningDetailEncryptedSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal(ReasoningDetailType.Encrypted),
|
||||||
|
data: z.string()
|
||||||
|
})
|
||||||
|
.extend(CommonReasoningDetailSchema.shape)
|
||||||
|
|
||||||
|
export type ReasoningDetailEncrypted = z.infer<typeof ReasoningDetailEncryptedSchema>
|
||||||
|
|
||||||
|
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<typeof ReasoningDetailTextSchema>
|
||||||
|
|
||||||
|
export const ReasoningDetailUnionSchema = z.union([
|
||||||
|
ReasoningDetailSummarySchema,
|
||||||
|
ReasoningDetailEncryptedSchema,
|
||||||
|
ReasoningDetailTextSchema
|
||||||
|
])
|
||||||
|
|
||||||
|
export type ReasoningDetailUnion = z.infer<typeof ReasoningDetailUnionSchema>
|
||||||
|
|
||||||
|
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))
|
||||||
|
])
|
||||||
@ -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<T> {
|
|
||||||
details: T
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory cache for reasoning details
|
|
||||||
* Key: signature
|
|
||||||
* Value: reasoning array with timestamp
|
|
||||||
*/
|
|
||||||
export class ReasoningCache<T> {
|
|
||||||
private cache = new Map<string, CacheEntry<T>>()
|
|
||||||
private readonly ttlMs: number
|
|
||||||
private cleanupInterval: ReturnType<typeof setInterval> | 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<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
|
|
||||||
*/
|
|
||||||
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<JSONValue>()
|
|
||||||
@ -9,11 +9,11 @@ import type {
|
|||||||
import { type AiPlugin, createExecutor } from '@cherrystudio/ai-core'
|
import { type AiPlugin, createExecutor } from '@cherrystudio/ai-core'
|
||||||
import { createProvider as createProviderCore } from '@cherrystudio/ai-core/provider'
|
import { createProvider as createProviderCore } from '@cherrystudio/ai-core/provider'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { AiSdkToAnthropicSSE, formatSSEDone, formatSSEEvent } from '@main/apiServer/adapters'
|
||||||
import { generateSignature as cherryaiGenerateSignature } from '@main/integration/cherryai'
|
import { generateSignature as cherryaiGenerateSignature } from '@main/integration/cherryai'
|
||||||
import anthropicService from '@main/services/AnthropicService'
|
import anthropicService from '@main/services/AnthropicService'
|
||||||
import copilotService from '@main/services/CopilotService'
|
import copilotService from '@main/services/CopilotService'
|
||||||
import { reduxService } from '@main/services/ReduxService'
|
import { reduxService } from '@main/services/ReduxService'
|
||||||
import { AiSdkToAnthropicSSE, formatSSEDone, formatSSEEvent } from '@shared/adapters'
|
|
||||||
import { isGemini3ModelId } from '@shared/middleware'
|
import { isGemini3ModelId } from '@shared/middleware'
|
||||||
import {
|
import {
|
||||||
type AiSdkConfig,
|
type AiSdkConfig,
|
||||||
@ -33,12 +33,16 @@ import { net } from 'electron'
|
|||||||
import type { Response } from 'express'
|
import type { Response } from 'express'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
import { reasoningCache } from './cache'
|
import { googleReasoningCache, openRouterReasoningCache } from '../../services/CacheService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('UnifiedMessagesService')
|
const logger = loggerService.withContext('UnifiedMessagesService')
|
||||||
|
|
||||||
const MAGIC_STRING = 'skip_thought_signature_validator'
|
const MAGIC_STRING = 'skip_thought_signature_validator'
|
||||||
|
|
||||||
|
function sanitizeJson(value: unknown): JSONValue {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
initializeSharedProviders({
|
initializeSharedProviders({
|
||||||
warn: (message) => logger.warn(message),
|
warn: (message) => logger.warn(message),
|
||||||
error: (message, error) => logger.error(message, error)
|
error: (message, error) => logger.error(message, error)
|
||||||
@ -303,13 +307,13 @@ function convertAnthropicToAiMessages(params: MessageCreateParams): ModelMessage
|
|||||||
const options: ProviderOptions = {}
|
const options: ProviderOptions = {}
|
||||||
|
|
||||||
if (isGemini3ModelId(params.model)) {
|
if (isGemini3ModelId(params.model)) {
|
||||||
if (reasoningCache.get(`google-${block.name}`)) {
|
if (googleReasoningCache.get(`google-${block.name}`)) {
|
||||||
options.google = {
|
options.google = {
|
||||||
thoughtSignature: MAGIC_STRING
|
thoughtSignature: MAGIC_STRING
|
||||||
}
|
}
|
||||||
} else if (reasoningCache.get('openrouter')) {
|
} else if (openRouterReasoningCache.get('openrouter')) {
|
||||||
options.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]
|
const assistantContent = [...reasoningParts, ...textParts, ...toolCallParts]
|
||||||
if (assistantContent.length > 0) {
|
if (assistantContent.length > 0) {
|
||||||
let providerOptions: ProviderOptions | undefined = undefined
|
let providerOptions: ProviderOptions | undefined = undefined
|
||||||
if (reasoningCache.get('openrouter')) {
|
if (openRouterReasoningCache.get('openrouter')) {
|
||||||
providerOptions = {
|
providerOptions = {
|
||||||
openrouter: {
|
openrouter: {
|
||||||
reasoning_details: (reasoningCache.get('openrouter') as JSONValue[]) || []
|
reasoning_details: (sanitizeJson(openRouterReasoningCache.get('openrouter')) as JSONValue[]) || []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (isGemini3ModelId(params.model)) {
|
} else if (isGemini3ModelId(params.model)) {
|
||||||
@ -510,8 +514,7 @@ async function executeStream(config: ExecuteStreamConfig): Promise<AiSdkToAnthro
|
|||||||
// Create the adapter
|
// Create the adapter
|
||||||
const adapter = new AiSdkToAnthropicSSE({
|
const adapter = new AiSdkToAnthropicSSE({
|
||||||
model: `${provider.id}:${modelId}`,
|
model: `${provider.id}:${modelId}`,
|
||||||
onEvent: onEvent || (() => {}),
|
onEvent: onEvent || (() => {})
|
||||||
reasoningCache
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Execute stream - pass model object instead of string
|
// Execute stream - pass model object instead of string
|
||||||
|
|||||||
@ -4,6 +4,26 @@ interface CacheItem<T> {
|
|||||||
duration: number
|
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<T> {
|
||||||
|
set(key: string, value: T): void
|
||||||
|
get(key: string): T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
private static cache: Map<string, CacheItem<any>> = new Map()
|
private static cache: Map<string, CacheItem<any>> = new Map()
|
||||||
|
|
||||||
@ -72,3 +92,14 @@ export class CacheService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Singleton cache instances using CacheService
|
||||||
|
export const googleReasoningCache: IReasoningCache<string> = {
|
||||||
|
set: (key, value) => CacheService.set(`google-reasoning:${key}`, value, 30 * 60 * 1000),
|
||||||
|
get: (key) => CacheService.get(`google-reasoning:${key}`) || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openRouterReasoningCache: IReasoningCache<ReasoningDetailUnion[]> = {
|
||||||
|
set: (key, value) => CacheService.set(`openrouter-reasoning:${key}`, value, 30 * 60 * 1000),
|
||||||
|
get: (key) => CacheService.get(`openrouter-reasoning:${key}`) || undefined
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user