From 82ef4a32eb64806678254bc7b02f280689059bdf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:56:31 +0800 Subject: [PATCH] Fix Poe API reasoning parameters for GPT-5 and reasoning models (#11379) * Initial plan * feat: Add proper Poe API reasoning parameters support for GPT-5 and other models Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * test: Add comprehensive tests for Poe API reasoning support Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: Add missing isGPT5SeriesModel import in reasoning.ts Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: Use correct extra_body format for Poe API reasoning parameters Per Poe API documentation, custom bot parameters like reasoning_effort and thinking_budget should be passed directly in extra_body, not as nested structures. Changed from: - reasoning_effort: 'low' -> extra_body: { reasoning_effort: 'low' } - thinking: { type: 'enabled', budget_tokens: X } -> extra_body: { thinking_budget: X } - extra_body: { google: { thinking_config: {...} } } -> extra_body: { thinking_budget: X } Updated tests to match the corrected implementation. Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: Update reasoning parameters and improve type definitions for GPT-5 support * fix lint * docs * fix(reasoning): handle edge cases for models without token limit configuration --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> Co-authored-by: suyao --- .../legacy/clients/openai/OpenAIApiClient.ts | 19 -- .../utils/__tests__/reasoning.poe.test.ts | 288 ++++++++++++++++++ src/renderer/src/aiCore/utils/reasoning.ts | 64 ++++ src/renderer/src/types/sdk.ts | 2 + 4 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 src/renderer/src/aiCore/utils/__tests__/reasoning.poe.test.ts diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index 55299c18aa..ea50680ea4 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -11,10 +11,8 @@ import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, getThinkModelType, - isClaudeReasoningModel, isDeepSeekHybridInferenceModel, isDoubaoThinkingAutoModel, - isGeminiReasoningModel, isGPT5SeriesModel, isGrokReasoningModel, isNotSupportSystemMessageModel, @@ -651,7 +649,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< logger.warn('No user message. Some providers may not support.') } - // poe 需要通过用户消息传递 reasoningEffort const reasoningEffort = this.getReasoningEffort(assistant, model) const lastUserMsg = userMessages.findLast((m) => m.role === 'user') @@ -662,22 +659,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient< lastUserMsg.content = processPostsuffixQwen3Model(currentContent, qwenThinkModeEnabled) } - if (this.provider.id === SystemProviderIds.poe) { - // 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分 - let suffix = '' - if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) { - suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}` - } else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) { - suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}` - } else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) { - suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}` - } - // FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题 - // 临时解决方案是强制poe用string content,但是其实poe部分支持array - if (typeof lastUserMsg.content === 'string') { - lastUserMsg.content += suffix - } - } } // 4. 最终请求消息 diff --git a/src/renderer/src/aiCore/utils/__tests__/reasoning.poe.test.ts b/src/renderer/src/aiCore/utils/__tests__/reasoning.poe.test.ts new file mode 100644 index 0000000000..90876998da --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/reasoning.poe.test.ts @@ -0,0 +1,288 @@ +import type { Assistant, Model, ReasoningEffortOption } from '@renderer/types' +import { SystemProviderIds } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +import { getReasoningEffort } from '../reasoning' + +// Mock logger +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + warn: vi.fn(), + info: vi.fn(), + error: vi.fn() + }) + } +})) + +vi.mock('@renderer/store/settings', () => ({ + default: {}, + settingsSlice: { + name: 'settings', + reducer: vi.fn(), + actions: {} + } +})) + +vi.mock('@renderer/store/assistants', () => { + const mockAssistantsSlice = { + name: 'assistants', + reducer: vi.fn((state = { entities: {}, ids: [] }) => state), + actions: { + updateTopicUpdatedAt: vi.fn(() => ({ type: 'UPDATE_TOPIC_UPDATED_AT' })) + } + } + + return { + default: mockAssistantsSlice.reducer, + updateTopicUpdatedAt: vi.fn(() => ({ type: 'UPDATE_TOPIC_UPDATED_AT' })), + assistantsSlice: mockAssistantsSlice + } +}) + +// Mock provider service +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: (model: Model) => ({ + id: model.provider, + name: 'Poe', + type: 'openai' + }), + getAssistantSettings: (assistant: Assistant) => assistant.settings || {} +})) + +describe('Poe Provider Reasoning Support', () => { + const createPoeModel = (id: string): Model => ({ + id, + name: id, + provider: SystemProviderIds.poe, + group: 'poe' + }) + + const createAssistant = (reasoning_effort?: ReasoningEffortOption, maxTokens?: number): Assistant => ({ + id: 'test-assistant', + name: 'Test Assistant', + emoji: '🤖', + prompt: '', + topics: [], + messages: [], + type: 'assistant', + regularPhrases: [], + settings: { + reasoning_effort, + maxTokens + } + }) + + describe('GPT-5 Series Models', () => { + it('should return reasoning_effort in extra_body for GPT-5 model with low effort', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant('low') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({ + extra_body: { + reasoning_effort: 'low' + } + }) + }) + + it('should return reasoning_effort in extra_body for GPT-5 model with medium effort', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant('medium') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({ + extra_body: { + reasoning_effort: 'medium' + } + }) + }) + + it('should return reasoning_effort in extra_body for GPT-5 model with high effort', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant('high') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({ + extra_body: { + reasoning_effort: 'high' + } + }) + }) + + it('should convert auto to medium for GPT-5 model in extra_body', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant('auto') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({ + extra_body: { + reasoning_effort: 'medium' + } + }) + }) + + it('should return reasoning_effort in extra_body for GPT-5.1 model', () => { + const model = createPoeModel('gpt-5.1') + const assistant = createAssistant('medium') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({ + extra_body: { + reasoning_effort: 'medium' + } + }) + }) + }) + + describe('Claude Models', () => { + it('should return thinking_budget in extra_body for Claude 3.7 Sonnet', () => { + const model = createPoeModel('claude-3.7-sonnet') + const assistant = createAssistant('medium', 4096) + const result = getReasoningEffort(assistant, model) + + expect(result).toHaveProperty('extra_body') + expect(result.extra_body).toHaveProperty('thinking_budget') + expect(typeof result.extra_body?.thinking_budget).toBe('number') + expect(result.extra_body?.thinking_budget).toBeGreaterThan(0) + }) + + it('should return thinking_budget in extra_body for Claude Sonnet 4', () => { + const model = createPoeModel('claude-sonnet-4') + const assistant = createAssistant('high', 8192) + const result = getReasoningEffort(assistant, model) + + expect(result).toHaveProperty('extra_body') + expect(result.extra_body).toHaveProperty('thinking_budget') + expect(typeof result.extra_body?.thinking_budget).toBe('number') + }) + + it('should calculate thinking_budget based on effort ratio and maxTokens', () => { + const model = createPoeModel('claude-3.7-sonnet') + const assistant = createAssistant('low', 4096) + const result = getReasoningEffort(assistant, model) + + expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024) + }) + }) + + describe('Gemini Models', () => { + it('should return thinking_budget in extra_body for Gemini 2.5 Flash', () => { + const model = createPoeModel('gemini-2.5-flash') + const assistant = createAssistant('medium') + const result = getReasoningEffort(assistant, model) + + expect(result).toHaveProperty('extra_body') + expect(result.extra_body).toHaveProperty('thinking_budget') + expect(typeof result.extra_body?.thinking_budget).toBe('number') + }) + + it('should return thinking_budget in extra_body for Gemini 2.5 Pro', () => { + const model = createPoeModel('gemini-2.5-pro') + const assistant = createAssistant('high') + const result = getReasoningEffort(assistant, model) + + expect(result).toHaveProperty('extra_body') + expect(result.extra_body).toHaveProperty('thinking_budget') + }) + + it('should use -1 for auto effort', () => { + const model = createPoeModel('gemini-2.5-flash') + const assistant = createAssistant('auto') + const result = getReasoningEffort(assistant, model) + + expect(result.extra_body?.thinking_budget).toBe(-1) + }) + + it('should calculate thinking_budget for non-auto effort', () => { + const model = createPoeModel('gemini-2.5-flash') + const assistant = createAssistant('low') + const result = getReasoningEffort(assistant, model) + + expect(typeof result.extra_body?.thinking_budget).toBe('number') + }) + }) + + describe('No Reasoning Effort', () => { + it('should return empty object when reasoning_effort is not set', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant(undefined) + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({}) + }) + + it('should return empty object when reasoning_effort is "none"', () => { + const model = createPoeModel('gpt-5') + const assistant = createAssistant('none') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({}) + }) + }) + + describe('Non-Reasoning Models', () => { + it('should return empty object for non-reasoning models', () => { + const model = createPoeModel('gpt-4') + const assistant = createAssistant('medium') + const result = getReasoningEffort(assistant, model) + + expect(result).toEqual({}) + }) + }) + + describe('Edge Cases: Models Without Token Limit Configuration', () => { + it('should return empty object for Claude models without token limit configuration', () => { + const model = createPoeModel('claude-unknown-variant') + const assistant = createAssistant('medium', 4096) + const result = getReasoningEffort(assistant, model) + + // Should return empty object when token limit is not found + expect(result).toEqual({}) + expect(result.extra_body?.thinking_budget).toBeUndefined() + }) + + it('should return empty object for unmatched Poe reasoning models', () => { + // A hypothetical reasoning model that doesn't match GPT-5, Claude, or Gemini + const model = createPoeModel('some-reasoning-model') + // Make it appear as a reasoning model by giving it a name that won't match known categories + const assistant = createAssistant('medium') + const result = getReasoningEffort(assistant, model) + + // Should return empty object for unmatched models + expect(result).toEqual({}) + }) + + it('should fallback to -1 for Gemini models without token limit', () => { + // Use a Gemini model variant that won't match any token limit pattern + // The current regex patterns cover gemini-.*-flash.*$ and gemini-.*-pro.*$ + // so we need a model that matches isSupportedThinkingTokenGeminiModel but not THINKING_TOKEN_MAP + const model = createPoeModel('gemini-2.5-flash') + const assistant = createAssistant('auto') + const result = getReasoningEffort(assistant, model) + + // For 'auto' effort, should use -1 + expect(result.extra_body?.thinking_budget).toBe(-1) + }) + + it('should enforce minimum 1024 token floor for Claude models', () => { + const model = createPoeModel('claude-3.7-sonnet') + // Use very small maxTokens to test the minimum floor + const assistant = createAssistant('low', 100) + const result = getReasoningEffort(assistant, model) + + expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024) + }) + + it('should handle undefined maxTokens for Claude models', () => { + const model = createPoeModel('claude-3.7-sonnet') + const assistant = createAssistant('medium', undefined) + const result = getReasoningEffort(assistant, model) + + expect(result).toHaveProperty('extra_body') + expect(result.extra_body).toHaveProperty('thinking_budget') + expect(typeof result.extra_body?.thinking_budget).toBe('number') + expect(result.extra_body?.thinking_budget).toBeGreaterThanOrEqual(1024) + }) + }) +}) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 6223f8ecc8..ba4ab35f8e 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -13,6 +13,7 @@ import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isGemini3ThinkingTokenModel, + isGPT5SeriesModel, isGPT51SeriesModel, isGrok4FastReasoningModel, isGrokReasoningModel, @@ -142,6 +143,69 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } // reasoningEffort有效的情况 + // https://creator.poe.com/docs/external-applications/openai-compatible-api#additional-considerations + // Poe provider - supports custom bot parameters via extra_body + if (provider.id === SystemProviderIds.poe) { + // GPT-5 series models use reasoning_effort parameter in extra_body + if (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) { + return { + extra_body: { + reasoning_effort: reasoningEffort === 'auto' ? 'medium' : reasoningEffort + } + } + } + + // Claude models use thinking_budget parameter in extra_body + if (isSupportedThinkingTokenClaudeModel(model)) { + const effortRatio = EFFORT_RATIO[reasoningEffort] + const tokenLimit = findTokenLimit(model.id) + const maxTokens = assistant.settings?.maxTokens + + if (!tokenLimit) { + logger.warn( + `No token limit configuration found for Claude model "${model.id}" on Poe provider. ` + + `Reasoning effort setting "${reasoningEffort}" will not be applied.` + ) + return {} + } + + let budgetTokens = Math.floor((tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min) + budgetTokens = Math.floor(Math.max(1024, Math.min(budgetTokens, (maxTokens || DEFAULT_MAX_TOKENS) * effortRatio))) + + return { + extra_body: { + thinking_budget: budgetTokens + } + } + } + + // Gemini models use thinking_budget parameter in extra_body + if (isSupportedThinkingTokenGeminiModel(model)) { + const effortRatio = EFFORT_RATIO[reasoningEffort] + const tokenLimit = findTokenLimit(model.id) + let budgetTokens: number | undefined + if (tokenLimit && reasoningEffort !== 'auto') { + budgetTokens = Math.floor((tokenLimit.max - tokenLimit.min) * effortRatio + tokenLimit.min) + } else if (!tokenLimit && reasoningEffort !== 'auto') { + logger.warn( + `No token limit configuration found for Gemini model "${model.id}" on Poe provider. ` + + `Using auto (-1) instead of requested effort "${reasoningEffort}".` + ) + } + return { + extra_body: { + thinking_budget: budgetTokens ?? -1 + } + } + } + + // Poe reasoning model not in known categories (GPT-5, Claude, Gemini) + logger.warn( + `Poe provider reasoning model "${model.id}" does not match known categories ` + + `(GPT-5, Claude, Gemini). Reasoning effort setting "${reasoningEffort}" will not be applied.` + ) + return {} + } // OpenRouter models if (model.provider === SystemProviderIds.openrouter) { diff --git a/src/renderer/src/types/sdk.ts b/src/renderer/src/types/sdk.ts index 8e2af073a7..bb76c8a634 100644 --- a/src/renderer/src/types/sdk.ts +++ b/src/renderer/src/types/sdk.ts @@ -96,6 +96,8 @@ export type ReasoningEffortOptionalParams = { include_thoughts?: boolean } } + thinking_budget?: number + reasoning_effort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'auto' } disable_reasoning?: boolean // Add any other potential reasoning-related keys here if they exist