mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-18 22:20:56 +08:00
fix: preserve openrouter reasoning with web search (#11505)
* feat(options): implement deep merging for provider options Add deep merge functionality to preserve nested properties when combining provider options. The new implementation handles object merging recursively while maintaining type safety. * refactor(tsconfig): reorganize include paths in tsconfig files Clean up and reorder include paths for better maintainability and consistency between tsconfig.node.json and tsconfig.web.json * test: add aiCore test configuration and script Add new test configuration for aiCore package and corresponding test script in package.json to enable running tests specifically for the aiCore module. * fix: format * fix(aiCore): resolve test failures and update test infrastructure - Add vitest setup file with global mocks for @cherrystudio/ai-sdk-provider - Fix context assertions: use 'model' instead of 'modelId' in plugin tests - Fix error handling tests: update expected error messages to match actual behavior - Fix streamText tests: use 'maxOutputTokens' instead of 'maxTokens' - Fix schemas test: update expected provider list to match actual implementation - Fix mock-responses: use AI SDK v5 format (inputTokens/outputTokens) - Update vi.mock to use importOriginal for preserving jsonSchema export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(aiCore): add alias mock for @cherrystudio/ai-sdk-provider in tests The vi.mock in setup file doesn't work for source code imports. Use vitest resolve.alias to mock the external package properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(aiCore): disable unused-vars warnings in mock file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(aiCore): use import.meta.url for ESM compatibility in vitest config __dirname is not available in ESM modules, use fileURLToPath instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(aiCore): use absolute paths in vitest config for workspace compatibility - Use path.resolve for setupFiles and all alias paths - Extend aiCore vitest.config.ts from root workspace config - Change aiCore test environment to 'node' instead of 'jsdom' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * docs(factory): improve mergeProviderOptions documentation Add detailed explanation of merge behavior with examples * test(factory): add tests for mergeProviderOptions behavior Add test cases to verify mergeProviderOptions correctly handles primitive values, arrays, and nested objects during merging * refactor(tests): clean up mock responses test fixtures Remove unused mock streaming chunks and error responses to simplify test fixtures Update warning details structure in mock complete responses * docs(test): clarify comment in generateImage test Update comment to use consistent 'model id' terminology instead of 'modelId' * test(factory): verify array replacement in mergeProviderOptions --------- Co-authored-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b18c64b725
commit
5167c927be
@ -62,6 +62,7 @@
|
|||||||
"test": "vitest run --silent",
|
"test": "vitest run --silent",
|
||||||
"test:main": "vitest run --project main",
|
"test:main": "vitest run --project main",
|
||||||
"test:renderer": "vitest run --project renderer",
|
"test:renderer": "vitest run --project renderer",
|
||||||
|
"test:aicore": "vitest run --project aiCore",
|
||||||
"test:update": "yarn test:renderer --update",
|
"test:update": "yarn test:renderer --update",
|
||||||
"test:coverage": "vitest run --coverage --silent",
|
"test:coverage": "vitest run --coverage --silent",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
|
|||||||
@ -3,12 +3,13 @@
|
|||||||
* Provides realistic mock responses for all provider types
|
* Provides realistic mock responses for all provider types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { jsonSchema, type ModelMessage, type Tool } from 'ai'
|
import type { ModelMessage, Tool } from 'ai'
|
||||||
|
import { jsonSchema } from 'ai'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard test messages for all scenarios
|
* Standard test messages for all scenarios
|
||||||
*/
|
*/
|
||||||
export const testMessages = {
|
export const testMessages: Record<string, ModelMessage[]> = {
|
||||||
simple: [{ role: 'user' as const, content: 'Hello, how are you?' }],
|
simple: [{ role: 'user' as const, content: 'Hello, how are you?' }],
|
||||||
|
|
||||||
conversation: [
|
conversation: [
|
||||||
@ -45,7 +46,7 @@ export const testMessages = {
|
|||||||
{ role: 'assistant' as const, content: '15 * 23 = 345' },
|
{ role: 'assistant' as const, content: '15 * 23 = 345' },
|
||||||
{ role: 'user' as const, content: 'Now divide that by 5' }
|
{ role: 'user' as const, content: 'Now divide that by 5' }
|
||||||
]
|
]
|
||||||
} satisfies Record<string, ModelMessage[]>
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard test tools for tool calling scenarios
|
* Standard test tools for tool calling scenarios
|
||||||
@ -138,68 +139,17 @@ export const testTools: Record<string, Tool> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock streaming chunks for different providers
|
|
||||||
*/
|
|
||||||
export const mockStreamingChunks = {
|
|
||||||
text: [
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'Hello' },
|
|
||||||
{ type: 'text-delta' as const, textDelta: ', ' },
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'this ' },
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'is ' },
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'a ' },
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'test.' }
|
|
||||||
],
|
|
||||||
|
|
||||||
withToolCall: [
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'Let me check the weather for you.' },
|
|
||||||
{
|
|
||||||
type: 'tool-call-delta' as const,
|
|
||||||
toolCallType: 'function' as const,
|
|
||||||
toolCallId: 'call_123',
|
|
||||||
toolName: 'getWeather',
|
|
||||||
argsTextDelta: '{"location":'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tool-call-delta' as const,
|
|
||||||
toolCallType: 'function' as const,
|
|
||||||
toolCallId: 'call_123',
|
|
||||||
toolName: 'getWeather',
|
|
||||||
argsTextDelta: ' "San Francisco, CA"}'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'tool-call' as const,
|
|
||||||
toolCallType: 'function' as const,
|
|
||||||
toolCallId: 'call_123',
|
|
||||||
toolName: 'getWeather',
|
|
||||||
args: { location: 'San Francisco, CA' }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
withFinish: [
|
|
||||||
{ type: 'text-delta' as const, textDelta: 'Complete response.' },
|
|
||||||
{
|
|
||||||
type: 'finish' as const,
|
|
||||||
finishReason: 'stop' as const,
|
|
||||||
usage: {
|
|
||||||
promptTokens: 10,
|
|
||||||
completionTokens: 5,
|
|
||||||
totalTokens: 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock complete responses for non-streaming scenarios
|
* Mock complete responses for non-streaming scenarios
|
||||||
|
* Note: AI SDK v5 uses inputTokens/outputTokens instead of promptTokens/completionTokens
|
||||||
*/
|
*/
|
||||||
export const mockCompleteResponses = {
|
export const mockCompleteResponses = {
|
||||||
simple: {
|
simple: {
|
||||||
text: 'This is a simple response.',
|
text: 'This is a simple response.',
|
||||||
finishReason: 'stop' as const,
|
finishReason: 'stop' as const,
|
||||||
usage: {
|
usage: {
|
||||||
promptTokens: 15,
|
inputTokens: 15,
|
||||||
completionTokens: 8,
|
outputTokens: 8,
|
||||||
totalTokens: 23
|
totalTokens: 23
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -215,8 +165,8 @@ export const mockCompleteResponses = {
|
|||||||
],
|
],
|
||||||
finishReason: 'tool-calls' as const,
|
finishReason: 'tool-calls' as const,
|
||||||
usage: {
|
usage: {
|
||||||
promptTokens: 25,
|
inputTokens: 25,
|
||||||
completionTokens: 12,
|
outputTokens: 12,
|
||||||
totalTokens: 37
|
totalTokens: 37
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -225,14 +175,15 @@ export const mockCompleteResponses = {
|
|||||||
text: 'Response with warnings.',
|
text: 'Response with warnings.',
|
||||||
finishReason: 'stop' as const,
|
finishReason: 'stop' as const,
|
||||||
usage: {
|
usage: {
|
||||||
promptTokens: 10,
|
inputTokens: 10,
|
||||||
completionTokens: 5,
|
outputTokens: 5,
|
||||||
totalTokens: 15
|
totalTokens: 15
|
||||||
},
|
},
|
||||||
warnings: [
|
warnings: [
|
||||||
{
|
{
|
||||||
type: 'unsupported-setting' as const,
|
type: 'unsupported-setting' as const,
|
||||||
message: 'Temperature parameter not supported for this model'
|
setting: 'temperature',
|
||||||
|
details: 'Temperature parameter not supported for this model'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -285,47 +236,3 @@ export const mockImageResponses = {
|
|||||||
warnings: []
|
warnings: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock error responses
|
|
||||||
*/
|
|
||||||
export const mockErrors = {
|
|
||||||
invalidApiKey: {
|
|
||||||
name: 'APIError',
|
|
||||||
message: 'Invalid API key provided',
|
|
||||||
statusCode: 401
|
|
||||||
},
|
|
||||||
|
|
||||||
rateLimitExceeded: {
|
|
||||||
name: 'RateLimitError',
|
|
||||||
message: 'Rate limit exceeded. Please try again later.',
|
|
||||||
statusCode: 429,
|
|
||||||
headers: {
|
|
||||||
'retry-after': '60'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
modelNotFound: {
|
|
||||||
name: 'ModelNotFoundError',
|
|
||||||
message: 'The requested model was not found',
|
|
||||||
statusCode: 404
|
|
||||||
},
|
|
||||||
|
|
||||||
contextLengthExceeded: {
|
|
||||||
name: 'ContextLengthError',
|
|
||||||
message: "This model's maximum context length is 4096 tokens",
|
|
||||||
statusCode: 400
|
|
||||||
},
|
|
||||||
|
|
||||||
timeout: {
|
|
||||||
name: 'TimeoutError',
|
|
||||||
message: 'Request timed out after 30000ms',
|
|
||||||
code: 'ETIMEDOUT'
|
|
||||||
},
|
|
||||||
|
|
||||||
networkError: {
|
|
||||||
name: 'NetworkError',
|
|
||||||
message: 'Network connection failed',
|
|
||||||
code: 'ECONNREFUSED'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
35
packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts
Normal file
35
packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Mock for @cherrystudio/ai-sdk-provider
|
||||||
|
* This mock is used in tests to avoid importing the actual package
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CherryInProviderSettings = {
|
||||||
|
apiKey?: string
|
||||||
|
baseURL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
export const createCherryIn = (_options?: CherryInProviderSettings) => ({
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
languageModel: (_modelId: string) => ({
|
||||||
|
specificationVersion: 'v1',
|
||||||
|
provider: 'cherryin',
|
||||||
|
modelId: 'mock-model',
|
||||||
|
doGenerate: async () => ({ text: 'mock response' }),
|
||||||
|
doStream: async () => ({ stream: (async function* () {})() })
|
||||||
|
}),
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
chat: (_modelId: string) => ({
|
||||||
|
specificationVersion: 'v1',
|
||||||
|
provider: 'cherryin-chat',
|
||||||
|
modelId: 'mock-model',
|
||||||
|
doGenerate: async () => ({ text: 'mock response' }),
|
||||||
|
doStream: async () => ({ stream: (async function* () {})() })
|
||||||
|
}),
|
||||||
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
textEmbeddingModel: (_modelId: string) => ({
|
||||||
|
specificationVersion: 'v1',
|
||||||
|
provider: 'cherryin',
|
||||||
|
modelId: 'mock-embedding-model'
|
||||||
|
})
|
||||||
|
})
|
||||||
9
packages/aiCore/src/__tests__/setup.ts
Normal file
9
packages/aiCore/src/__tests__/setup.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Vitest Setup File
|
||||||
|
* Global test configuration and mocks for @cherrystudio/ai-core package
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock Vite SSR helper to avoid Node environment errors
|
||||||
|
;(globalThis as any).__vite_ssr_exportName__ = (_name: string, value: any) => value
|
||||||
|
|
||||||
|
// Note: @cherrystudio/ai-sdk-provider is mocked via alias in vitest.config.ts
|
||||||
109
packages/aiCore/src/core/options/__tests__/factory.test.ts
Normal file
109
packages/aiCore/src/core/options/__tests__/factory.test.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createOpenAIOptions, createOpenRouterOptions, mergeProviderOptions } from '../factory'
|
||||||
|
|
||||||
|
describe('mergeProviderOptions', () => {
|
||||||
|
it('deep merges provider options for the same provider', () => {
|
||||||
|
const reasoningOptions = createOpenRouterOptions({
|
||||||
|
reasoning: {
|
||||||
|
enabled: true,
|
||||||
|
effort: 'medium'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const webSearchOptions = createOpenRouterOptions({
|
||||||
|
plugins: [{ id: 'web', max_results: 5 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = mergeProviderOptions(reasoningOptions, webSearchOptions)
|
||||||
|
|
||||||
|
expect(merged.openrouter).toEqual({
|
||||||
|
reasoning: {
|
||||||
|
enabled: true,
|
||||||
|
effort: 'medium'
|
||||||
|
},
|
||||||
|
plugins: [{ id: 'web', max_results: 5 }]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves options from other providers while merging', () => {
|
||||||
|
const openRouter = createOpenRouterOptions({
|
||||||
|
reasoning: { enabled: true }
|
||||||
|
})
|
||||||
|
const openAI = createOpenAIOptions({
|
||||||
|
reasoningEffort: 'low'
|
||||||
|
})
|
||||||
|
const merged = mergeProviderOptions(openRouter, openAI)
|
||||||
|
|
||||||
|
expect(merged.openrouter).toEqual({ reasoning: { enabled: true } })
|
||||||
|
expect(merged.openai).toEqual({ reasoningEffort: 'low' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites primitive values with later values', () => {
|
||||||
|
const first = createOpenAIOptions({
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
user: 'user-123'
|
||||||
|
})
|
||||||
|
const second = createOpenAIOptions({
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
maxToolCalls: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = mergeProviderOptions(first, second)
|
||||||
|
|
||||||
|
expect(merged.openai).toEqual({
|
||||||
|
reasoningEffort: 'high', // overwritten by second
|
||||||
|
user: 'user-123', // preserved from first
|
||||||
|
maxToolCalls: 5 // added from second
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites arrays with later values instead of merging', () => {
|
||||||
|
const first = createOpenRouterOptions({
|
||||||
|
models: ['gpt-4', 'gpt-3.5-turbo']
|
||||||
|
})
|
||||||
|
const second = createOpenRouterOptions({
|
||||||
|
models: ['claude-3-opus', 'claude-3-sonnet']
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = mergeProviderOptions(first, second)
|
||||||
|
|
||||||
|
// Array is completely replaced, not merged
|
||||||
|
expect(merged.openrouter?.models).toEqual(['claude-3-opus', 'claude-3-sonnet'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deeply merges nested objects while overwriting primitives', () => {
|
||||||
|
const first = createOpenRouterOptions({
|
||||||
|
reasoning: {
|
||||||
|
enabled: true,
|
||||||
|
effort: 'low'
|
||||||
|
},
|
||||||
|
user: 'user-123'
|
||||||
|
})
|
||||||
|
const second = createOpenRouterOptions({
|
||||||
|
reasoning: {
|
||||||
|
effort: 'high',
|
||||||
|
max_tokens: 500
|
||||||
|
},
|
||||||
|
user: 'user-456'
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = mergeProviderOptions(first, second)
|
||||||
|
|
||||||
|
expect(merged.openrouter).toEqual({
|
||||||
|
reasoning: {
|
||||||
|
enabled: true, // preserved from first
|
||||||
|
effort: 'high', // overwritten by second
|
||||||
|
max_tokens: 500 // added from second
|
||||||
|
},
|
||||||
|
user: 'user-456' // overwritten by second
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces arrays instead of merging them', () => {
|
||||||
|
const first = createOpenRouterOptions({ plugins: [{ id: 'old' }] })
|
||||||
|
const second = createOpenRouterOptions({ plugins: [{ id: 'new' }] })
|
||||||
|
const merged = mergeProviderOptions(first, second)
|
||||||
|
// @ts-expect-error type-check for openrouter options is skipped. see function signature of createOpenRouterOptions
|
||||||
|
expect(merged.openrouter?.plugins).toEqual([{ id: 'new' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -26,13 +26,65 @@ export function createGenericProviderOptions<T extends string>(
|
|||||||
return { [provider]: options } as Record<T, Record<string, any>>
|
return { [provider]: options } as Record<T, Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlainObject = Record<string, any>
|
||||||
|
|
||||||
|
const isPlainObject = (value: unknown): value is PlainObject => {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMergeObjects<T extends PlainObject>(target: T, source: PlainObject): T {
|
||||||
|
const result: PlainObject = { ...target }
|
||||||
|
Object.entries(source).forEach(([key, value]) => {
|
||||||
|
if (isPlainObject(value) && isPlainObject(result[key])) {
|
||||||
|
result[key] = deepMergeObjects(result[key], value)
|
||||||
|
} else {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result as T
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并多个供应商的options
|
* Deep-merge multiple provider-specific options.
|
||||||
* @param optionsMap 包含多个供应商选项的对象
|
* Nested objects are recursively merged; primitive values are overwritten.
|
||||||
* @returns 合并后的TypedProviderOptions
|
*
|
||||||
|
* When the same key appears in multiple options:
|
||||||
|
* - If both values are plain objects: they are deeply merged (recursive merge)
|
||||||
|
* - If values are primitives/arrays: the later value overwrites the earlier one
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* mergeProviderOptions(
|
||||||
|
* { openrouter: { reasoning: { enabled: true, effort: 'low' }, user: 'user-123' } },
|
||||||
|
* { openrouter: { reasoning: { effort: 'high', max_tokens: 500 }, models: ['gpt-4'] } }
|
||||||
|
* )
|
||||||
|
* // Result: {
|
||||||
|
* // openrouter: {
|
||||||
|
* // reasoning: { enabled: true, effort: 'high', max_tokens: 500 },
|
||||||
|
* // user: 'user-123',
|
||||||
|
* // models: ['gpt-4']
|
||||||
|
* // }
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* @param optionsMap Objects containing options for multiple providers
|
||||||
|
* @returns Fully merged TypedProviderOptions
|
||||||
*/
|
*/
|
||||||
export function mergeProviderOptions(...optionsMap: Partial<TypedProviderOptions>[]): TypedProviderOptions {
|
export function mergeProviderOptions(...optionsMap: Partial<TypedProviderOptions>[]): TypedProviderOptions {
|
||||||
return Object.assign({}, ...optionsMap)
|
return optionsMap.reduce<TypedProviderOptions>((acc, options) => {
|
||||||
|
if (!options) {
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
Object.entries(options).forEach(([providerId, providerOptions]) => {
|
||||||
|
if (!providerOptions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (acc[providerId]) {
|
||||||
|
acc[providerId] = deepMergeObjects(acc[providerId] as PlainObject, providerOptions as PlainObject)
|
||||||
|
} else {
|
||||||
|
acc[providerId] = providerOptions as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
}, {} as TypedProviderOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,15 +19,20 @@ describe('Provider Schemas', () => {
|
|||||||
expect(Array.isArray(baseProviders)).toBe(true)
|
expect(Array.isArray(baseProviders)).toBe(true)
|
||||||
expect(baseProviders.length).toBeGreaterThan(0)
|
expect(baseProviders.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// These are the actual base providers defined in schemas.ts
|
||||||
const expectedIds = [
|
const expectedIds = [
|
||||||
'openai',
|
'openai',
|
||||||
'openai-responses',
|
'openai-chat',
|
||||||
'openai-compatible',
|
'openai-compatible',
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'google',
|
'google',
|
||||||
'xai',
|
'xai',
|
||||||
'azure',
|
'azure',
|
||||||
'deepseek'
|
'azure-responses',
|
||||||
|
'deepseek',
|
||||||
|
'openrouter',
|
||||||
|
'cherryin',
|
||||||
|
'cherryin-chat'
|
||||||
]
|
]
|
||||||
const actualIds = baseProviders.map((p) => p.id)
|
const actualIds = baseProviders.map((p) => p.id)
|
||||||
expectedIds.forEach((id) => {
|
expectedIds.forEach((id) => {
|
||||||
|
|||||||
@ -232,11 +232,13 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
|
|
||||||
expect(pluginCallOrder).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd'])
|
expect(pluginCallOrder).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd'])
|
||||||
|
|
||||||
|
// transformParams receives params without model (model is handled separately)
|
||||||
|
// and context with core fields + dynamic fields (requestId, startTime, etc.)
|
||||||
expect(testPlugin.transformParams).toHaveBeenCalledWith(
|
expect(testPlugin.transformParams).toHaveBeenCalledWith(
|
||||||
{ prompt: 'A test image' },
|
expect.objectContaining({ prompt: 'A test image' }),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelId: 'dall-e-3'
|
model: 'dall-e-3'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -273,11 +275,12 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
|
|
||||||
await executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })
|
await executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })
|
||||||
|
|
||||||
|
// resolveModel receives model id and context with core fields
|
||||||
expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith(
|
expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith(
|
||||||
'dall-e-3',
|
'dall-e-3',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelId: 'dall-e-3'
|
model: 'dall-e-3'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -339,12 +342,11 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
.generateImage({ model: 'invalid-model', prompt: 'A test image' })
|
.generateImage({ model: 'invalid-model', prompt: 'A test image' })
|
||||||
.catch((error) => error)
|
.catch((error) => error)
|
||||||
|
|
||||||
expect(thrownError).toBeInstanceOf(ImageGenerationError)
|
// Error is thrown from pluginEngine directly as ImageModelResolutionError
|
||||||
expect(thrownError.message).toContain('Failed to generate image:')
|
expect(thrownError).toBeInstanceOf(ImageModelResolutionError)
|
||||||
|
expect(thrownError.message).toContain('Failed to resolve image model: invalid-model')
|
||||||
expect(thrownError.providerId).toBe('openai')
|
expect(thrownError.providerId).toBe('openai')
|
||||||
expect(thrownError.modelId).toBe('invalid-model')
|
expect(thrownError.modelId).toBe('invalid-model')
|
||||||
expect(thrownError.cause).toBeInstanceOf(ImageModelResolutionError)
|
|
||||||
expect(thrownError.cause.message).toContain('Failed to resolve image model: invalid-model')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle ImageModelResolutionError without provider', async () => {
|
it('should handle ImageModelResolutionError without provider', async () => {
|
||||||
@ -362,8 +364,9 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
const apiError = new Error('API request failed')
|
const apiError = new Error('API request failed')
|
||||||
vi.mocked(aiGenerateImage).mockRejectedValue(apiError)
|
vi.mocked(aiGenerateImage).mockRejectedValue(apiError)
|
||||||
|
|
||||||
|
// Error propagates directly from pluginEngine without wrapping
|
||||||
await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
||||||
'Failed to generate image:'
|
'API request failed'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -376,8 +379,9 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
vi.mocked(aiGenerateImage).mockRejectedValue(noImageError)
|
vi.mocked(aiGenerateImage).mockRejectedValue(noImageError)
|
||||||
vi.mocked(NoImageGeneratedError.isInstance).mockReturnValue(true)
|
vi.mocked(NoImageGeneratedError.isInstance).mockReturnValue(true)
|
||||||
|
|
||||||
|
// Error propagates directly from pluginEngine
|
||||||
await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
||||||
'Failed to generate image:'
|
'No image generated'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -398,15 +402,17 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
[errorPlugin]
|
[errorPlugin]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Error propagates directly from pluginEngine
|
||||||
await expect(executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
await expect(executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow(
|
||||||
'Failed to generate image:'
|
'Generation failed'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// onError receives the original error and context with core fields
|
||||||
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
||||||
error,
|
error,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelId: 'dall-e-3'
|
model: 'dall-e-3'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -419,9 +425,10 @@ describe('RuntimeExecutor.generateImage', () => {
|
|||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
setTimeout(() => abortController.abort(), 10)
|
setTimeout(() => abortController.abort(), 10)
|
||||||
|
|
||||||
|
// Error propagates directly from pluginEngine
|
||||||
await expect(
|
await expect(
|
||||||
executor.generateImage({ model: 'dall-e-3', prompt: 'A test image', abortSignal: abortController.signal })
|
executor.generateImage({ model: 'dall-e-3', prompt: 'A test image', abortSignal: abortController.signal })
|
||||||
).rejects.toThrow('Failed to generate image:')
|
).rejects.toThrow('Operation was aborted')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -17,10 +17,14 @@ import type { AiPlugin } from '../../plugins'
|
|||||||
import { globalRegistryManagement } from '../../providers/RegistryManagement'
|
import { globalRegistryManagement } from '../../providers/RegistryManagement'
|
||||||
import { RuntimeExecutor } from '../executor'
|
import { RuntimeExecutor } from '../executor'
|
||||||
|
|
||||||
// Mock AI SDK
|
// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports
|
||||||
vi.mock('ai', () => ({
|
vi.mock('ai', async (importOriginal) => {
|
||||||
generateText: vi.fn()
|
const actual = (await importOriginal()) as Record<string, unknown>
|
||||||
}))
|
return {
|
||||||
|
...actual,
|
||||||
|
generateText: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('../../providers/RegistryManagement', () => ({
|
vi.mock('../../providers/RegistryManagement', () => ({
|
||||||
globalRegistryManagement: {
|
globalRegistryManagement: {
|
||||||
@ -409,11 +413,12 @@ describe('RuntimeExecutor.generateText', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow('Generation failed')
|
).rejects.toThrow('Generation failed')
|
||||||
|
|
||||||
|
// onError receives the original error and context with core fields
|
||||||
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
||||||
error,
|
error,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelId: 'gpt-4'
|
model: 'gpt-4'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -11,10 +11,14 @@ import type { AiPlugin } from '../../plugins'
|
|||||||
import { globalRegistryManagement } from '../../providers/RegistryManagement'
|
import { globalRegistryManagement } from '../../providers/RegistryManagement'
|
||||||
import { RuntimeExecutor } from '../executor'
|
import { RuntimeExecutor } from '../executor'
|
||||||
|
|
||||||
// Mock AI SDK
|
// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports
|
||||||
vi.mock('ai', () => ({
|
vi.mock('ai', async (importOriginal) => {
|
||||||
streamText: vi.fn()
|
const actual = (await importOriginal()) as Record<string, unknown>
|
||||||
}))
|
return {
|
||||||
|
...actual,
|
||||||
|
streamText: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('../../providers/RegistryManagement', () => ({
|
vi.mock('../../providers/RegistryManagement', () => ({
|
||||||
globalRegistryManagement: {
|
globalRegistryManagement: {
|
||||||
@ -153,7 +157,7 @@ describe('RuntimeExecutor.streamText', () => {
|
|||||||
describe('Max Tokens Parameter', () => {
|
describe('Max Tokens Parameter', () => {
|
||||||
const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000]
|
const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000]
|
||||||
|
|
||||||
it.each(maxTokensValues)('should support maxTokens=%s', async (maxTokens) => {
|
it.each(maxTokensValues)('should support maxOutputTokens=%s', async (maxOutputTokens) => {
|
||||||
const mockStream = {
|
const mockStream = {
|
||||||
textStream: (async function* () {
|
textStream: (async function* () {
|
||||||
yield 'Response'
|
yield 'Response'
|
||||||
@ -168,12 +172,13 @@ describe('RuntimeExecutor.streamText', () => {
|
|||||||
await executor.streamText({
|
await executor.streamText({
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
messages: testMessages.simple,
|
messages: testMessages.simple,
|
||||||
maxOutputTokens: maxTokens
|
maxOutputTokens
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Parameters are passed through without transformation
|
||||||
expect(streamText).toHaveBeenCalledWith(
|
expect(streamText).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
maxTokens
|
maxOutputTokens
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -513,11 +518,12 @@ describe('RuntimeExecutor.streamText', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toThrow('Stream error')
|
).rejects.toThrow('Stream error')
|
||||||
|
|
||||||
|
// onError receives the original error and context with core fields
|
||||||
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
||||||
error,
|
error,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
providerId: 'openai',
|
providerId: 'openai',
|
||||||
modelId: 'gpt-4'
|
model: 'gpt-4'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true
|
globals: true,
|
||||||
|
setupFiles: [path.resolve(__dirname, './src/__tests__/setup.ts')]
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': './src'
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
// Mock external packages that may not be available in test environment
|
||||||
|
'@cherrystudio/ai-sdk-provider': path.resolve(__dirname, './src/__tests__/mocks/ai-sdk-provider.ts')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
"include": [
|
"include": [
|
||||||
"electron.vite.config.*",
|
"electron.vite.config.*",
|
||||||
"src/main/**/*",
|
|
||||||
"src/preload/**/*",
|
|
||||||
"src/main/env.d.ts",
|
|
||||||
"src/renderer/src/types/*",
|
|
||||||
"packages/shared/**/*",
|
|
||||||
"scripts",
|
"scripts",
|
||||||
|
"src/main/**/*",
|
||||||
|
"src/main/env.d.ts",
|
||||||
|
"src/preload/**/*",
|
||||||
|
"src/renderer/src/services/traceApi.ts",
|
||||||
|
"src/renderer/src/types/*",
|
||||||
"packages/mcp-trace/**/*",
|
"packages/mcp-trace/**/*",
|
||||||
"src/renderer/src/services/traceApi.ts"
|
"packages/shared/**/*",
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
"include": [
|
"include": [
|
||||||
"src/renderer/src/**/*",
|
|
||||||
"src/preload/*.d.ts",
|
|
||||||
"local/src/renderer/**/*",
|
"local/src/renderer/**/*",
|
||||||
"packages/shared/**/*",
|
"src/renderer/src/**/*",
|
||||||
"tests/__mocks__/**/*",
|
|
||||||
"packages/mcp-trace/**/*",
|
|
||||||
"packages/aiCore/src/**/*",
|
|
||||||
"src/main/integration/cherryai/index.js",
|
"src/main/integration/cherryai/index.js",
|
||||||
|
"src/preload/*.d.ts",
|
||||||
|
"tests/__mocks__/**/*",
|
||||||
|
"packages/aiCore/src/**/*",
|
||||||
|
"packages/ai-sdk-provider/**/*",
|
||||||
"packages/extension-table-plus/**/*",
|
"packages/extension-table-plus/**/*",
|
||||||
"packages/ai-sdk-provider/**/*"
|
"packages/mcp-trace/**/*",
|
||||||
|
"packages/shared/**/*",
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|||||||
@ -44,6 +44,18 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['scripts/**/*.{test,spec}.{ts,tsx}', 'scripts/**/__tests__/**/*.{test,spec}.{ts,tsx}']
|
include: ['scripts/**/*.{test,spec}.{ts,tsx}', 'scripts/**/__tests__/**/*.{test,spec}.{ts,tsx}']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// aiCore 包单元测试配置
|
||||||
|
{
|
||||||
|
extends: 'packages/aiCore/vitest.config.ts',
|
||||||
|
test: {
|
||||||
|
name: 'aiCore',
|
||||||
|
environment: 'node',
|
||||||
|
include: [
|
||||||
|
'packages/aiCore/**/*.{test,spec}.{ts,tsx}',
|
||||||
|
'packages/aiCore/**/__tests__/**/*.{test,spec}.{ts,tsx}'
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// 全局共享配置
|
// 全局共享配置
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user