Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ContentGenerator } from '../contentGenerator.js';
import { AuthType } from '../contentGenerator.js';
import { LoggingContentGenerator } from './index.js';
import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js';
import { openaiRequestCaptureContext } from '../openaiContentGenerator/requestCaptureContext.js';
import {
logApiRequest,
logApiResponse,
Expand Down Expand Up @@ -547,6 +548,290 @@ describe('LoggingContentGenerator', () => {
]);
});

it('logs the captured wire request including provider-injected fields (generateContent)', async () => {
const wireRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.5,
max_tokens: 1024,
// Provider-injected fields the synthetic reconstruction would drop:
reasoning_effort: 'max',
extra_body: { thinking: { type: 'enabled' }, enable_thinking: true },
metadata: { dashscope_user_id: 'abc' },
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;

const wrapped = createWrappedGenerator(
vi.fn().mockImplementation(async () => {
openaiRequestCaptureContext.getStore()?.(wireRequest);
return createResponse('resp-cap', 'deepseek-v4-pro', [{ text: 'ok' }]);
}),
vi.fn(),
);

const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'deepseek-v4-pro',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});

const request = {
model: 'deepseek-v4-pro',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
} as unknown as GenerateContentParameters;

await generator.generateContent(request, 'prompt-cap');

const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1);
const [loggedRequest] = openaiLoggerInstance.logInteraction.mock
.calls[0] as [OpenAI.Chat.ChatCompletionCreateParams];
// The logger must observe the actual wire request, not a stripped reconstruction.
expect(loggedRequest).toBe(wireRequest);
expect(loggedRequest).toMatchObject({
reasoning_effort: 'max',
extra_body: { thinking: { type: 'enabled' }, enable_thinking: true },
metadata: { dashscope_user_id: 'abc' },
});
});

it('logs the captured wire request for streaming requests (generateContentStream)', async () => {
const wireRequest: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'glm-5.1',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
stream_options: { include_usage: true },
extra_body: { thinking: { type: 'enabled' } },
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;

const chunk = createResponse('resp-stream-cap', 'glm-5.1', [
{ text: 'ok' },
]);

const wrapped = createWrappedGenerator(
vi.fn(),
vi.fn().mockImplementation(async () => {
openaiRequestCaptureContext.getStore()?.(wireRequest);
return (async function* () {
yield chunk;
})();
}),
);

const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'glm-5.1',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});

const request = {
model: 'glm-5.1',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
} as unknown as GenerateContentParameters;

const stream = await generator.generateContentStream(
request,
'prompt-stream-cap',
);
for await (const _ of stream) {
// drain
}

const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1);
const [loggedRequest] = openaiLoggerInstance.logInteraction.mock
.calls[0] as [OpenAI.Chat.ChatCompletionCreateParams];
expect(loggedRequest).toBe(wireRequest);
expect(loggedRequest).toMatchObject({
stream: true,
stream_options: { include_usage: true },
extra_body: { thinking: { type: 'enabled' } },
});
});

it('falls back to synthetic request when the wrapped generator does not capture', async () => {
const wrapped = createWrappedGenerator(
vi
.fn()
.mockResolvedValue(
createResponse('resp-fallback', 'test-model', [{ text: 'ok' }]),
),
vi.fn(),
);

const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});

const request = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
config: { temperature: 0.4 },
} as unknown as GenerateContentParameters;

await generator.generateContent(request, 'prompt-fallback');
Comment thread
tanzhenxin marked this conversation as resolved.

const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
const [loggedRequest] = openaiLoggerInstance.logInteraction.mock
.calls[0] as [OpenAI.Chat.ChatCompletionCreateParams];
expect(loggedRequest).toEqual(
expect.objectContaining({
model: 'test-model',
temperature: 0.4,
}),
);
});

it('does not propagate logging-side throws (success and error paths)', async () => {
const successResponse = createResponse('resp-safe', 'test-model', [
{ text: 'ok' },
]);
const successWrapped = createWrappedGenerator(
vi.fn().mockResolvedValue(successResponse),
vi.fn(),
);
const successGen = new LoggingContentGenerator(
successWrapped,
createConfig(),
{
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
},
);

// No capture fires, so resolve() falls through to the synthetic builder.
// Force the synthetic build to throw, then verify the API result still surfaces.
convertGeminiRequestToOpenAISpy.mockImplementationOnce(() => {
throw new Error('synth-fail-success');
});

const request = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
} as unknown as GenerateContentParameters;

await expect(
successGen.generateContent(request, 'prompt-safe-success'),
).resolves.toBe(successResponse);

const apiError = new Error('api-boom');
const errorWrapped = createWrappedGenerator(
vi.fn().mockRejectedValue(apiError),
vi.fn(),
);
const errorGen = new LoggingContentGenerator(errorWrapped, createConfig(), {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});
convertGeminiRequestToOpenAISpy.mockImplementationOnce(() => {
throw new Error('synth-fail-error');
});

await expect(
errorGen.generateContent(request, 'prompt-safe-error'),
).rejects.toThrow('api-boom');
});

it('does not propagate logging-side throws on a successful stream', async () => {
const chunk1 = createResponse('resp-stream-safe-1', 'test-model', [
{ text: 'hello' },
]);
const chunk2 = createResponse('resp-stream-safe-2', 'test-model', [
{ text: ' world' },
]);
const wrapped = createWrappedGenerator(
vi.fn(),
vi.fn().mockResolvedValue(
(async function* () {
yield chunk1;
yield chunk2;
})(),
),
);
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});
const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
openaiLoggerInstance.logInteraction.mockRejectedValueOnce(
new Error('log-fail-on-stream-success'),
);

const request = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
} as unknown as GenerateContentParameters;

const stream = await generator.generateContentStream(
request,
'prompt-stream-safe-success',
);
const seen: GenerateContentResponse[] = [];
for await (const item of stream) {
seen.push(item);
}
// All chunks must reach the consumer; the logger throw must not surface.
expect(seen).toHaveLength(2);
expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1);
});

it('does not let logging-side throws replace the original stream error', async () => {
const chunk = createResponse('resp-stream-err', 'test-model', [
{ text: 'partial' },
]);
const apiError = new Error('stream-api-fail');
const wrapped = createWrappedGenerator(
vi.fn(),
vi.fn().mockResolvedValue(
(async function* () {
yield chunk;
throw apiError;
})(),
),
);
const generator = new LoggingContentGenerator(wrapped, createConfig(), {
model: 'test-model',
authType: AuthType.USE_OPENAI,
enableOpenAILogging: true,
openAILoggingDir: 'logs',
});
const openaiLoggerInstance = vi.mocked(OpenAILogger).mock.results[0]
?.value as { logInteraction: ReturnType<typeof vi.fn> };
openaiLoggerInstance.logInteraction.mockRejectedValueOnce(
new Error('log-fail-on-stream-error'),
);

const request = {
model: 'test-model',
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
} as unknown as GenerateContentParameters;

const stream = await generator.generateContentStream(
request,
'prompt-stream-safe-error',
);
await expect(async () => {
for await (const _item of stream) {
// drain
}
}).rejects.toThrow('stream-api-fail');
expect(openaiLoggerInstance.logInteraction).toHaveBeenCalledTimes(1);
});

it.each(['prompt_suggestion', 'forked_query', 'speculation'])(
'skips logApiRequest and OpenAI logging for internal promptId %s (generateContent)',
async (promptId) => {
Expand Down
Loading
Loading