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
35 changes: 35 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,41 @@ describe('ContentGenerationPipeline', () => {
expect(apiCall.reasoning).toBeUndefined();
});

it('should preserve reasoning_effort none when thinking is disabled', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new condition typed['reasoning_effort'] !== 'none' has two branches (preserve when 'none', strip otherwise), but only the preserve branch is tested. No test verifies that reasoning_effort with a non-'none' value (e.g., 'high') is still stripped when thinking is disabled. — Failure scenario: a future change accidentally widens the preservation condition (e.g., removes the !== 'none' guard), causing non-none reasoning_effort values to leak through when thinking is disabled, contradicting the disable signal and adding unwanted reasoning latency/cost.

Suggested change
it('should preserve reasoning_effort none when thinking is disabled', async () => {
it('should preserve reasoning_effort none when thinking is disabled', async () => {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
samplingParams: { reasoning_effort: 'none' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);
const request: GenerateContentParameters = {
model: 'gpt-5',
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
config: { thinkingConfig: { includeThoughts: false } },
};
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Classify action' },
]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
new GenerateContentResponse(),
);
(mockClient.chat.completions.create as Mock).mockResolvedValue({
id: 'response-id',
choices: [{ message: { content: 'safe' }, finish_reason: 'stop' }],
} as OpenAI.Chat.ChatCompletion);
await pipeline.execute(request, 'side-query:permission-classifier');
const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBe('none');
});
it('should still strip non-none reasoning_effort when thinking is disabled', async () => {
mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
samplingParams: { reasoning_effort: 'high' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);
const request: GenerateContentParameters = {
model: 'gpt-5',
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
config: { thinkingConfig: { includeThoughts: false } },
};
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Classify action' },
]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
new GenerateContentResponse(),
);
(mockClient.chat.completions.create as Mock).mockResolvedValue({
id: 'response-id',
choices: [{ message: { content: 'safe' }, finish_reason: 'stop' }],
} as OpenAI.Chat.ChatCompletion);
await pipeline.execute(request, 'side-query:permission-classifier');
const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBeUndefined();
});

— qwen3.7-max via Qwen Code /review

mockContentGeneratorConfig = {
...mockContentGeneratorConfig,
samplingParams: { reasoning_effort: 'none' },
} as ContentGeneratorConfig;
mockConfig = {
...mockConfig,
contentGeneratorConfig: mockContentGeneratorConfig,
};
pipeline = new ContentGenerationPipeline(mockConfig);

const request: GenerateContentParameters = {
model: 'gpt-5',
contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }],
config: { thinkingConfig: { includeThoughts: false } },
};

(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([
{ role: 'user', content: 'Classify action' },
]);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
new GenerateContentResponse(),
);
(mockClient.chat.completions.create as Mock).mockResolvedValue({
id: 'response-id',
choices: [{ message: { content: 'safe' }, finish_reason: 'stop' }],
} as OpenAI.Chat.ChatCompletion);

await pipeline.execute(request, 'side-query:permission-classifier');

const apiCall = (mockClient.chat.completions.create as Mock).mock
.calls[0][0];
expect(apiCall.reasoning_effort).toBe('none');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding a reverse test: reasoning_effort: 'high' with thinking disabled should still be stripped. The existing test at L829 only covers the nested reasoning object removal, not the top-level string. This would prevent a future regression where the guard is accidentally widened to preserve all values.


it('should preserve enable_thinking when thinking is not explicitly disabled', async () => {
// Arrange — normal request (not forked query), enable_thinking should be preserved
(mockProvider.buildRequest as Mock).mockImplementation((req) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ export class ContentGenerationPipeline {
if ('reasoning' in typed) {
delete typed['reasoning'];
}
if ('reasoning_effort' in typed) {
if ('reasoning_effort' in typed && typed['reasoning_effort'] !== 'none') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the comparison is case-sensitive (!== 'none'). OpenAI only uses lowercase, but a user config with "None" would be silently stripped. Consider String(typed['reasoning_effort']).toLowerCase() !== 'none' for robustness. Non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing worth noting: reasoningDisabled also fires on config-level reasoning: false, not just side queries. So a user who sets both reasoning: false and samplingParams.reasoning_effort: 'none' now ships the literal none on every request. On providers that reject that literal (DeepSeek's chat API only documents high/max), that combo goes from silently working to a 400. Sending the user's explicit value is still the right call — but the Risk section only mentions side queries, so might be worth calling this out there. Non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment above still says "we strip both shapes here", which is no longer accurate for the flat shape. And the 'none' exemption is exactly the kind of guard someone will simplify away later — the gateway constraint (function tools on GPT-5 rejected unless reasoning is explicitly disabled) isn't inferable from the code. Might be worth a one-line why next to the condition. Non-blocking.

delete typed['reasoning_effort'];
}
// DeepSeek V4+ defaults `thinking.type` to `'enabled'`, so removing
Expand Down
Loading