diff --git a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts index ed0ba5cb030..78e866e103f 100644 --- a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest'; import type { CatalogModel } from '@cindy/model-providers'; import { + AUTO_REVIEW_COMPACT_MAX_TOKENS, + AUTO_REVIEW_COMPACT_TIMEOUT_MS, findCatalogModel, modelCanSuppressReasoning, resolveAutoReviewBudget, + resolveAutoReviewGatewayModel, } from '../auto-review-budget.js'; const model = (over: Partial = {}): CatalogModel => ({ @@ -44,8 +47,8 @@ describe('modelCanSuppressReasoning', () => { describe('resolveAutoReviewBudget', () => { it('keeps the compact budget for models that can turn reasoning down', () => { const budget = resolveAutoReviewBudget(model({ efforts: ['low', 'high'] })); - expect(budget.maxTokens).toBe(384); - expect(budget.timeoutMs).toBe(12_000); + expect(budget.maxTokens).toBe(AUTO_REVIEW_COMPACT_MAX_TOKENS); + expect(budget.timeoutMs).toBe(AUTO_REVIEW_COMPACT_TIMEOUT_MS); expect(budget.reasoningEffort).toBe('low'); }); @@ -87,6 +90,44 @@ describe('resolveAutoReviewBudget', () => { }); }); +describe('resolveAutoReviewGatewayModel', () => { + const providers = (codexModels: CatalogModel[]) => [ + { id: 'xd', models: { codex: codexModels } }, + ]; + + it('picks a suppressible gpt-class model and skips DeepSeek', () => { + const picked = resolveAutoReviewGatewayModel(providers([ + model({ id: 'deepseek/deepseek-v4-pro', efforts: ['high', 'max'] }), + model({ id: 'moonshotai/kimi-k2.6', efforts: [] }), + model({ id: 'codex/gpt-5.4-mini', efforts: ['low', 'high'] }), + ])); + expect(picked?.id).toBe('codex/gpt-5.4-mini'); + }); + + it('prefers budget-class mini/nano gpt over frontier gpt', () => { + const picked = resolveAutoReviewGatewayModel(providers([ + model({ id: 'openai/gpt-5.6-luna', efforts: ['low', 'high'] }), + model({ id: 'codex/gpt-5.4-mini', efforts: ['low', 'high'] }), + ])); + expect(picked?.id).toBe('codex/gpt-5.4-mini'); + }); + + it('falls back to any suppressible non-DeepSeek model when no gpt class exists', () => { + const picked = resolveAutoReviewGatewayModel(providers([ + model({ id: 'deepseek/deepseek-v4-flash', efforts: ['high', 'max'] }), + model({ id: 'moonshotai/kimi-k2.6', efforts: [] }), + ])); + expect(picked?.id).toBe('moonshotai/kimi-k2.6'); + }); + + it('returns undefined when xd only has forced-reasoning models', () => { + expect(resolveAutoReviewGatewayModel(providers([ + model({ id: 'deepseek/deepseek-v4-pro', efforts: ['high', 'max'] }), + ]))).toBeUndefined(); + expect(resolveAutoReviewGatewayModel([])).toBeUndefined(); + }); +}); + describe('findCatalogModel', () => { const providers = [ { id: 'xd', models: { 'claude-code': [model({ id: 'shared' })], pi: [model({ id: 'shared' })] } }, @@ -110,7 +151,7 @@ describe('findCatalogModel', () => { it('never borrows another provider capability when a provider is named', () => { // 回归 PR #2474 review:同一个模型 id 在两家目录下能力不同时,跨家借用会把 - // 强制思考的路由误判成"能关思考",于是又拿回��凑额度 —— 正是本 PR 要修的故障。 + // 强制思考的路由误判成"能关思考",于是又拿回紧凑额度 —— 正是本 PR 要修的故障。 const crossProvider = [ { id: 'xd', models: { 'claude-code': [model({ id: 'dual', efforts: ['high'] })] } }, { id: 'other', models: { 'claude-code': [model({ id: 'dual', efforts: ['low', 'high'] })] } }, diff --git a/apps/desktop/src/main/maker-host/auto-review-budget.ts b/apps/desktop/src/main/maker-host/auto-review-budget.ts index a81b3e3124c..fb195fcf110 100644 --- a/apps/desktop/src/main/maker-host/auto-review-budget.ts +++ b/apps/desktop/src/main/maker-host/auto-review-budget.ts @@ -16,8 +16,10 @@ import type { AgentKind, CatalogModel } from '@cindy/model-providers'; /** 能关思考的模型:够写一个 JSON 裁决即可。 */ -const COMPACT_MAX_TOKENS = 384; -const COMPACT_TIMEOUT_MS = 12_000; +export const AUTO_REVIEW_COMPACT_MAX_TOKENS = 384; +export const AUTO_REVIEW_COMPACT_TIMEOUT_MS = 12_000; +const COMPACT_MAX_TOKENS = AUTO_REVIEW_COMPACT_MAX_TOKENS; +const COMPACT_TIMEOUT_MS = AUTO_REVIEW_COMPACT_TIMEOUT_MS; /** * 强制思考的模型:思考段 + 结论段都要装得下。 @@ -104,6 +106,31 @@ export function resolveAutoReviewBudget(model: CatalogModel | undefined): AutoRe }; } +/** + * Auto-review 灰区分类器的网关模型选型。 + * + * 硬编码模型 id 不可行:xd 目录是 model-access server 按账号下发的(运行时才知道 + * 有哪些模型,#2174)。选型规则:xd 的 codex 模型里挑「能关思考且非 DeepSeek」的, + * 优先 gpt 系 —— 分类器要短 JSON,强制思考模型会 malformed。 + */ +export function resolveAutoReviewGatewayModel( + providers: ReadonlyArray<{ + id: string; + models: Partial>; + }>, +): CatalogModel | undefined { + const xd = providers.find((item) => item.id === 'xd'); + const models = xd?.models.codex ?? []; + const candidates = models.filter((m) => { + if (m.id.toLowerCase().includes('deepseek')) return false; + return modelCanSuppressReasoning(m); + }); + const gptModels = candidates.filter((m) => m.id.toLowerCase().includes('gpt')); + // 优先 budget 档(mini/nano):分类器是高热路径,每个灰区动作都付一次模型钱。 + const budgetClass = gptModels.find((m) => /mini|nano/.test(m.id.toLowerCase())); + return budgetClass ?? gptModels[0] ?? candidates[0]; +} + /** 从当前目录里查一个 (供应商, agent, 模型) 的目录条目。 */ export function findCatalogModel( providers: ReadonlyArray<{ diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index a25c1fc4b81..086aad99775 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -16,7 +16,6 @@ import { ClaudeCodeAgent, CodexAgent, configureDefaultImageResizer, - type AutoReviewRequest, type McpProvider, } from '@cindy/maker-core'; import { @@ -110,7 +109,14 @@ import { import { resolveRemoteClaudeRoute } from './remote-claude-route.js'; import { claudeSubagentUsageBridge } from './claude-subagent-usage-bridge.js'; import { createAutoPermissionReviewer } from './auto-permission-reviewer.js'; -import { findCatalogModel, resolveAutoReviewBudget } from './auto-review-budget.js'; +import { + AUTO_REVIEW_COMPACT_MAX_TOKENS, + AUTO_REVIEW_COMPACT_TIMEOUT_MS, + findCatalogModel, + modelCanSuppressReasoning, + resolveAutoReviewBudget, + resolveAutoReviewGatewayModel, +} from './auto-review-budget.js'; import { requestUtilityText } from '../utility-model/oneShotCandidates.js'; import { accountProviderReadinessBarrier } from './account-provider-readiness-barrier.js'; import { hasClaudeAiOAuth } from './claude-credentials-store.js'; @@ -235,20 +241,6 @@ type RemoteCcQuery = Awaited< let _maker: Maker | null = null; -/** - * 本次审核请求的额度。按模型能力自适应:能关思考的走紧凑档,强制思考的 - * (以及目录里查不到的)给足思考+结论的空间 —— 固定 384 token 会让 DeepSeek - * 这类模型正文恒为空。详见 auto-review-budget.ts。 - */ -function autoReviewBudgetFor(request: AutoReviewRequest) { - return resolveAutoReviewBudget(findCatalogModel( - getActiveCatalog().providers, - request.providerId, - request.agentKind, - request.model, - )); -} - let providerAccessRuntimeRefreshListener: (() => void) | null = null; /** Register the bootstrap-owned runtime reconciliation that follows provider access changes. */ @@ -258,21 +250,56 @@ export function setProviderAccessRuntimeRefreshListener(listener: (() => void) | const reviewAutoPermissionAction = createAutoPermissionReviewer({ logger: desktopMakerLogger, - // 重试守卫必须按同一份额度计时,否则放宽额度的那一档会被自己的守卫切断。 - resolveRequestTimeoutMs: (request) => autoReviewBudgetFor(request).timeoutMs, + // 三档候选都被约束为「能关思考」,额度恒为紧凑 12s,守卫无需再按 request 动态取。 + resolveRequestTimeoutMs: () => AUTO_REVIEW_COMPACT_TIMEOUT_MS, requestText: async (request, prompt) => { const maker = _maker; if (!maker) return null; - const budget = autoReviewBudgetFor(request); - const result = await requestUtilityText(maker, prompt, { - providerId: request.providerId?.trim() || undefined, - agentKind: request.agentKind, - model: request.model, - maxTokens: budget.maxTokens, - timeoutMs: budget.timeoutMs, - ...(budget.reasoningEffort ? { reasoningEffort: budget.reasoningEffort } : {}), + // 1) xd 网关:按账号下发目录动态选「能关思考、非 DeepSeek」的 gpt 系模型。 + // 额度与 effort 随选中模型算(如 efforts:[] 的 Kimi 不传 low,避免 400)。 + const gatewayModel = resolveAutoReviewGatewayModel(getActiveCatalog().providers); + if (gatewayModel) { + const budget = resolveAutoReviewBudget(gatewayModel); + const result = await requestUtilityText(maker, prompt, { + providerId: 'xd', + agentKind: 'codex', + model: gatewayModel.id, + maxTokens: budget.maxTokens, + timeoutMs: budget.timeoutMs, + ...(budget.reasoningEffort ? { reasoningEffort: budget.reasoningEffort } : {}), + }); + if (result.ok) return result.text; + } + // 2) ChatGPT 订阅:没有网关模型的账号回落订阅档。 + const subscription = await requestUtilityText(maker, prompt, { + pinnedProfileId: 'codex-gpt-5.4-mini', + maxTokens: AUTO_REVIEW_COMPACT_MAX_TOKENS, + timeoutMs: AUTO_REVIEW_COMPACT_TIMEOUT_MS, + reasoningEffort: 'low', }); - return result.ok ? result.text : null; + if (subscription.ok) return subscription.text; + // 3) 会话模型兜底:保留 #1227 的「跟会话 provider/model」能力,只加一条 + // 门槛——会话模型必须能关思考。DeepSeek 等强制思考模型不再当分类器 + // (#2174),其余自定义 provider(如 taptap + codex/gpt-5.6-terra)照旧可用。 + const sessionModel = findCatalogModel( + getActiveCatalog().providers, + request.providerId, + request.agentKind, + request.model, + ); + if (sessionModel && modelCanSuppressReasoning(sessionModel)) { + const budget = resolveAutoReviewBudget(sessionModel); + const result = await requestUtilityText(maker, prompt, { + providerId: request.providerId?.trim() || undefined, + agentKind: request.agentKind, + model: request.model, + maxTokens: budget.maxTokens, + timeoutMs: budget.timeoutMs, + ...(budget.reasoningEffort ? { reasoningEffort: budget.reasoningEffort } : {}), + }); + if (result.ok) return result.text; + } + return null; }, }); diff --git a/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts b/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts index 7bfac0bfedf..c5b8b451b94 100644 --- a/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts +++ b/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts @@ -1000,6 +1000,44 @@ describe('utility one-shot candidates', () => { expect(body.max_tokens).toBe(64_000); }); + it('routes Auto-review gateway codex/ model through xd /v1/responses', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'xd', + name: 'XD', + source: 'builtin', + agents: ['codex'], + auth: { method: 'api-key' }, + routing: { + codex: { upstream: 'https://xd.example/v1', authStrategy: 'gateway-key' }, + }, + models: { codex: [{ id: 'codex/gpt-5.4-mini', name: 'Mini', contextWindow: 272_000 }] }, + }], + } as never); + readKey.mockReturnValue('xd-key'); + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => 'data: {"type":"response.output_text.delta","delta":"{\\"verdict\\":\\"allow\\"}"}\ndata: [DONE]\n', + } as never); + + const result = await requestUtilityText(makerMock(false), 'classify', { + providerId: 'xd', + agentKind: 'codex', + model: 'codex/gpt-5.4-mini', + maxTokens: 384, + reasoningEffort: 'low', + }); + + expect(result).toMatchObject({ ok: true, providerId: 'xd', model: 'codex/gpt-5.4-mini' }); + expect(fetchMock).toHaveBeenCalledWith('https://gateway.test.invalid/v1/responses', expect.anything()); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + model: 'codex/gpt-5.4-mini', + max_output_tokens: 384, + reasoning: { effort: 'low' }, + }); + }); + it('缺省不传 maxTokens 时,Anthropic wire 用模型目录 maxOutput 兜底(协议必填,非宿主上限)', async () => { activeCatalog.mockReturnValue({ providers: [{ diff --git a/apps/desktop/src/main/utility-model/oneShotCandidates.ts b/apps/desktop/src/main/utility-model/oneShotCandidates.ts index 3462bcc59be..9fb04e47c98 100644 --- a/apps/desktop/src/main/utility-model/oneShotCandidates.ts +++ b/apps/desktop/src/main/utility-model/oneShotCandidates.ts @@ -588,20 +588,23 @@ async function requestBuiltinProviderText( const baseUrl = effectiveXdGatewayBaseUrl().trim(); if (!apiKey) return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'api_key_missing')] }; if (!baseUrl) return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'endpoint_missing')] }; + // Nova / Codex 折扣档(codex/…)在网关上走 Responses,chat-completions 会 400。 + const useResponses = input.model.startsWith('codex/'); return executeCandidates([{ providerId: input.provider.id, model: input.model, - transport: 'litellm-chat-completions', - profile, + transport: useResponses ? 'codex-responses' : 'litellm-chat-completions', + profile: useResponses ? { ...profile, transport: 'codex-responses' } : profile, execute: (text, requestOpts) => requestProviderHttpText({ - wire: 'chat-completions', - endpoint: joinProxyPath(baseUrl, '/v1/chat/completions'), + wire: useResponses ? 'responses' : 'chat-completions', + endpoint: joinProxyPath(baseUrl, useResponses ? '/v1/responses' : '/v1/chat/completions'), headers: { Authorization: `Bearer ${apiKey}` }, model: input.model, prompt: text, maxTokens: requestOpts?.maxTokens ?? input.maxTokens, timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, + retryWithMinimalBodyOnInvalidRequest: useResponses, }), }], prompt, [], input); }