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:
@@ -62,6 +62,7 @@
|
||||
"test": "vitest run --silent",
|
||||
"test:main": "vitest run --project main",
|
||||
"test:renderer": "vitest run --project renderer",
|
||||
"test:aicore": "vitest run --project aiCore",
|
||||
"test:update": "yarn test:renderer --update",
|
||||
"test:coverage": "vitest run --coverage --silent",
|
||||
"test:ui": "vitest --ui",
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* 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
|
||||
*/
|
||||
export const testMessages = {
|
||||
export const testMessages: Record<string, ModelMessage[]> = {
|
||||
simple: [{ role: 'user' as const, content: 'Hello, how are you?' }],
|
||||
|
||||
conversation: [
|
||||
@@ -45,7 +46,7 @@ export const testMessages = {
|
||||
{ role: 'assistant' as const, content: '15 * 23 = 345' },
|
||||
{ role: 'user' as const, content: 'Now divide that by 5' }
|
||||
]
|
||||
} satisfies Record<string, ModelMessage[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Note: AI SDK v5 uses inputTokens/outputTokens instead of promptTokens/completionTokens
|
||||
*/
|
||||
export const mockCompleteResponses = {
|
||||
simple: {
|
||||
text: 'This is a simple response.',
|
||||
finishReason: 'stop' as const,
|
||||
usage: {
|
||||
promptTokens: 15,
|
||||
completionTokens: 8,
|
||||
inputTokens: 15,
|
||||
outputTokens: 8,
|
||||
totalTokens: 23
|
||||
}
|
||||
},
|
||||
@@ -215,8 +165,8 @@ export const mockCompleteResponses = {
|
||||
],
|
||||
finishReason: 'tool-calls' as const,
|
||||
usage: {
|
||||
promptTokens: 25,
|
||||
completionTokens: 12,
|
||||
inputTokens: 25,
|
||||
outputTokens: 12,
|
||||
totalTokens: 37
|
||||
}
|
||||
},
|
||||
@@ -225,14 +175,15 @@ export const mockCompleteResponses = {
|
||||
text: 'Response with warnings.',
|
||||
finishReason: 'stop' as const,
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
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: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>>
|
||||
}
|
||||
|
||||
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
|
||||
* @param optionsMap 包含多个供应商选项的对象
|
||||
* @returns 合并后的TypedProviderOptions
|
||||
* Deep-merge multiple provider-specific options.
|
||||
* Nested objects are recursively merged; primitive values are overwritten.
|
||||
*
|
||||
* 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 {
|
||||
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(baseProviders.length).toBeGreaterThan(0)
|
||||
|
||||
// These are the actual base providers defined in schemas.ts
|
||||
const expectedIds = [
|
||||
'openai',
|
||||
'openai-responses',
|
||||
'openai-chat',
|
||||
'openai-compatible',
|
||||
'anthropic',
|
||||
'google',
|
||||
'xai',
|
||||
'azure',
|
||||
'deepseek'
|
||||
'azure-responses',
|
||||
'deepseek',
|
||||
'openrouter',
|
||||
'cherryin',
|
||||
'cherryin-chat'
|
||||
]
|
||||
const actualIds = baseProviders.map((p) => p.id)
|
||||
expectedIds.forEach((id) => {
|
||||
|
||||
@@ -232,11 +232,13 @@ describe('RuntimeExecutor.generateImage', () => {
|
||||
|
||||
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(
|
||||
{ prompt: 'A test image' },
|
||||
expect.objectContaining({ prompt: 'A test image' }),
|
||||
expect.objectContaining({
|
||||
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' })
|
||||
|
||||
// resolveModel receives model id and context with core fields
|
||||
expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith(
|
||||
'dall-e-3',
|
||||
expect.objectContaining({
|
||||
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' })
|
||||
.catch((error) => error)
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ImageGenerationError)
|
||||
expect(thrownError.message).toContain('Failed to generate image:')
|
||||
// Error is thrown from pluginEngine directly as ImageModelResolutionError
|
||||
expect(thrownError).toBeInstanceOf(ImageModelResolutionError)
|
||||
expect(thrownError.message).toContain('Failed to resolve image model: invalid-model')
|
||||
expect(thrownError.providerId).toBe('openai')
|
||||
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 () => {
|
||||
@@ -362,8 +364,9 @@ describe('RuntimeExecutor.generateImage', () => {
|
||||
const apiError = new Error('API request failed')
|
||||
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(
|
||||
'Failed to generate image:'
|
||||
'API request failed'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -376,8 +379,9 @@ describe('RuntimeExecutor.generateImage', () => {
|
||||
vi.mocked(aiGenerateImage).mockRejectedValue(noImageError)
|
||||
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(
|
||||
'Failed to generate image:'
|
||||
'No image generated'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -398,15 +402,17 @@ describe('RuntimeExecutor.generateImage', () => {
|
||||
[errorPlugin]
|
||||
)
|
||||
|
||||
// Error propagates directly from pluginEngine
|
||||
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(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
providerId: 'openai',
|
||||
modelId: 'dall-e-3'
|
||||
model: 'dall-e-3'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -419,9 +425,10 @@ describe('RuntimeExecutor.generateImage', () => {
|
||||
const abortController = new AbortController()
|
||||
setTimeout(() => abortController.abort(), 10)
|
||||
|
||||
// Error propagates directly from pluginEngine
|
||||
await expect(
|
||||
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 { RuntimeExecutor } from '../executor'
|
||||
|
||||
// Mock AI SDK
|
||||
vi.mock('ai', () => ({
|
||||
generateText: vi.fn()
|
||||
}))
|
||||
// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports
|
||||
vi.mock('ai', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
generateText: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../providers/RegistryManagement', () => ({
|
||||
globalRegistryManagement: {
|
||||
@@ -409,11 +413,12 @@ describe('RuntimeExecutor.generateText', () => {
|
||||
})
|
||||
).rejects.toThrow('Generation failed')
|
||||
|
||||
// onError receives the original error and context with core fields
|
||||
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
providerId: 'openai',
|
||||
modelId: 'gpt-4'
|
||||
model: 'gpt-4'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,10 +11,14 @@ import type { AiPlugin } from '../../plugins'
|
||||
import { globalRegistryManagement } from '../../providers/RegistryManagement'
|
||||
import { RuntimeExecutor } from '../executor'
|
||||
|
||||
// Mock AI SDK
|
||||
vi.mock('ai', () => ({
|
||||
streamText: vi.fn()
|
||||
}))
|
||||
// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports
|
||||
vi.mock('ai', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
streamText: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../providers/RegistryManagement', () => ({
|
||||
globalRegistryManagement: {
|
||||
@@ -153,7 +157,7 @@ describe('RuntimeExecutor.streamText', () => {
|
||||
describe('Max Tokens Parameter', () => {
|
||||
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 = {
|
||||
textStream: (async function* () {
|
||||
yield 'Response'
|
||||
@@ -168,12 +172,13 @@ describe('RuntimeExecutor.streamText', () => {
|
||||
await executor.streamText({
|
||||
model: 'gpt-4',
|
||||
messages: testMessages.simple,
|
||||
maxOutputTokens: maxTokens
|
||||
maxOutputTokens
|
||||
})
|
||||
|
||||
// Parameters are passed through without transformation
|
||||
expect(streamText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxTokens
|
||||
maxOutputTokens
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -513,11 +518,12 @@ describe('RuntimeExecutor.streamText', () => {
|
||||
})
|
||||
).rejects.toThrow('Stream error')
|
||||
|
||||
// onError receives the original error and context with core fields
|
||||
expect(errorPlugin.onError).toHaveBeenCalledWith(
|
||||
error,
|
||||
expect.objectContaining({
|
||||
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'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true
|
||||
globals: true,
|
||||
setupFiles: [path.resolve(__dirname, './src/__tests__/setup.ts')]
|
||||
},
|
||||
resolve: {
|
||||
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: {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/main/env.d.ts",
|
||||
"src/renderer/src/types/*",
|
||||
"packages/shared/**/*",
|
||||
"scripts",
|
||||
"src/main/**/*",
|
||||
"src/main/env.d.ts",
|
||||
"src/preload/**/*",
|
||||
"src/renderer/src/services/traceApi.ts",
|
||||
"src/renderer/src/types/*",
|
||||
"packages/mcp-trace/**/*",
|
||||
"src/renderer/src/services/traceApi.ts"
|
||||
"packages/shared/**/*",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/**/*",
|
||||
"src/preload/*.d.ts",
|
||||
"local/src/renderer/**/*",
|
||||
"packages/shared/**/*",
|
||||
"tests/__mocks__/**/*",
|
||||
"packages/mcp-trace/**/*",
|
||||
"packages/aiCore/src/**/*",
|
||||
"src/renderer/src/**/*",
|
||||
"src/main/integration/cherryai/index.js",
|
||||
"src/preload/*.d.ts",
|
||||
"tests/__mocks__/**/*",
|
||||
"packages/aiCore/src/**/*",
|
||||
"packages/ai-sdk-provider/**/*",
|
||||
"packages/extension-table-plus/**/*",
|
||||
"packages/ai-sdk-provider/**/*"
|
||||
"packages/mcp-trace/**/*",
|
||||
"packages/shared/**/*",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -44,6 +44,18 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
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}'
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
// 全局共享配置
|
||||
|
||||
Reference in New Issue
Block a user