diff --git a/packages/core/src/core/openaiContentGenerator.test.ts b/packages/core/src/core/openaiContentGenerator.test.ts index 9cee8202422..ca5fe2c3da7 100644 --- a/packages/core/src/core/openaiContentGenerator.test.ts +++ b/packages/core/src/core/openaiContentGenerator.test.ts @@ -388,9 +388,73 @@ describe('OpenAIContentGenerator', () => { temperature: 0.7, // From config sampling params (higher priority) max_tokens: 1000, // From config sampling params (higher priority) top_p: 0.9, - }), + }), ); }); + + it('should omit store for strict providers like Cerebras', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://api.cerebras.ai/v1'); + generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + mockOpenAIClient.baseURL = 'https://api.cerebras.ai/v1'; + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await generator.generateContent(request, 'test-prompt-id'); + + const createCall = + mockOpenAIClient.chat.completions.create.mock.calls[0]?.[0]; + expect(createCall).toBeDefined(); + expect(createCall).not.toHaveProperty('store'); + }); + + it('should include store for regular OpenAI providers on GPT models', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://api.openai.com/v1'); + generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + mockOpenAIClient.baseURL = 'https://api.openai.com/v1'; + + const mockResponse = { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Response' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + model: 'gpt-4', + }; + mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + await generator.generateContent(request, 'test-prompt-id'); + + const createCall = + mockOpenAIClient.chat.completions.create.mock.calls[0]?.[0]; + expect(createCall).toBeDefined(); + expect(createCall?.store).toBe(true); + }); }); describe('generateContentStream', () => { @@ -570,6 +634,89 @@ describe('OpenAIContentGenerator', () => { } } }); + + it('should omit stream_options and store for strict providers like Cerebras', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://api.cerebras.ai/v1'); + generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + mockOpenAIClient.baseURL = 'https://api.cerebras.ai/v1'; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: 'ok' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + }; + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream( + request, + 'test-prompt-id', + ); + for await (const _response of stream) { + // Exhaust stream + } + + const createCall = + mockOpenAIClient.chat.completions.create.mock.calls[0]?.[0]; + expect(createCall).toBeDefined(); + expect(createCall).not.toHaveProperty('stream_options'); + expect(createCall).not.toHaveProperty('store'); + }); + + it('should include stream_options and store for regular OpenAI providers', async () => { + vi.stubEnv('OPENAI_BASE_URL', 'https://api.openai.com/v1'); + generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig); + mockOpenAIClient.baseURL = 'https://api.openai.com/v1'; + + mockOpenAIClient.chat.completions.create.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + id: 'chatcmpl-123', + choices: [ + { + index: 0, + delta: { content: 'ok' }, + finish_reason: 'stop', + }, + ], + created: 1677652288, + }; + }, + }); + + const request: GenerateContentParameters = { + contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], + model: 'gpt-4', + }; + + const stream = await generator.generateContentStream( + request, + 'test-prompt-id', + ); + for await (const _response of stream) { + // Exhaust stream + } + + const createCall = + mockOpenAIClient.chat.completions.create.mock.calls[0]?.[0]; + expect(createCall).toBeDefined(); + expect(createCall).toHaveProperty('stream_options'); + expect(createCall?.stream_options).toEqual({ include_usage: true }); + expect(createCall?.store).toBe(true); + }); }); describe('countTokens', () => { diff --git a/packages/core/src/core/openaiContentGenerator.ts b/packages/core/src/core/openaiContentGenerator.ts index 202527134eb..7cf8583d646 100644 --- a/packages/core/src/core/openaiContentGenerator.ts +++ b/packages/core/src/core/openaiContentGenerator.ts @@ -164,6 +164,47 @@ export class OpenAIContentGenerator implements ContentGenerator { return false; // Default behavior: never suppress error logging } + /** + * Check if stream_options should be included in streaming requests. + * Some providers (e.g. Cerebras) strictly validate request parameters + * and return 422 for unknown fields like stream_options. + * Default: include stream_options (most OpenAI-compatible providers support it). + */ + private shouldIncludeStreamOptions(): boolean { + const baseURL = this.client?.baseURL || ''; + let hostname: string | undefined; + try { + hostname = new URL(baseURL).hostname; + } catch (_e) { + return true; // Default to including stream_options + } + // Providers known to reject stream_options with 422 + const strictProviders = ['api.cerebras.ai']; + return !strictProviders.some( + (h) => hostname === h || hostname!.endsWith('.' + h), + ); + } + + /** + * Check if store should be included in requests. + * Some providers (e.g. Cerebras) reject unsupported fields like store with 422. + * Default: include store for GPT models unless provider is known strict. + */ + private shouldIncludeStore(): boolean { + const baseURL = this.client?.baseURL || ''; + let hostname: string | undefined; + try { + hostname = new URL(baseURL).hostname; + } catch (_e) { + return true; // Default to including store + } + // Providers known to reject store with 422 + const strictProviders = ['api.cerebras.ai']; + return !strictProviders.some( + (h) => hostname === h || hostname!.endsWith('.' + h), + ); + } + /** * Check if metadata should be included in the request * Only include metadata for specific providers that support it @@ -264,7 +305,9 @@ export class OpenAIContentGenerator implements ContentGenerator { modelName.includes('gpt5') || modelName.includes('gpt4') ) { - createParams.store = true; + if (this.shouldIncludeStore()) { + createParams.store = true; + } } // Handle JSON schema requests (for generateJson calls) @@ -431,7 +474,9 @@ export class OpenAIContentGenerator implements ContentGenerator { messages, ...samplingParams, stream: true, - stream_options: { include_usage: true }, + ...(this.shouldIncludeStreamOptions() && { + stream_options: { include_usage: true }, + }), ...(metadata && { metadata }), }; @@ -442,7 +487,9 @@ export class OpenAIContentGenerator implements ContentGenerator { modelNameStream.includes('gpt5') || modelNameStream.includes('gpt4') ) { - createParams.store = true; + if (this.shouldIncludeStore()) { + createParams.store = true; + } } // Handle JSON schema requests (for generateJson calls) - same as non-streaming