diff --git a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts index 5d68474a298..3de5f7eddc5 100644 --- a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts @@ -689,7 +689,7 @@ describe('接管陈旧锁', () => { try { const started = performance.now(); await expect( - withCrossProcessLock(lock, { label: 'churn', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'churn', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: false, reason: 'busy' }); expect(performance.now() - started).toBeLessThan(1_000); expect(takeovers).toBe(3); @@ -716,7 +716,7 @@ describe('接管陈旧锁', () => { }) as typeof fsp.rename); try { await expect( - withCrossProcessLock(lock, { label: 'final-takeover', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'final-takeover', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: true }); expect(takeovers).toBe(3); } finally { @@ -754,7 +754,7 @@ describe('接管陈旧锁', () => { }) as typeof fsp.link); try { await expect( - withCrossProcessLock(lock, { label: 'final-takeover-busy', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'final-takeover-busy', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: true }); expect(takeovers).toBe(3); expect(busyPublishes).toBe(1); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts index a846ab4f4bc..21a6107f7da 100644 --- a/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts @@ -341,4 +341,28 @@ describe('createAutoPermissionReviewer', () => { await expect(pending).resolves.toEqual({ verdict: 'allow', reason: 'Routine test' }); }); + + it('runs a retry-owning request chain once and aborts it at the reviewer deadline', async () => { + vi.useFakeTimers(); + const observedSignals: AbortSignal[] = []; + const requestText = vi.fn((_request, _prompt, context: { signal: AbortSignal }) => { + observedSignals.push(context.signal); + return new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(null), { once: true }); + }); + }); + const reviewer = createAutoPermissionReviewer({ + requestText, + logger: { debug: vi.fn(), warn: vi.fn() }, + managesRetries: true, + resolveRequestTimeoutMs: () => 50, + }); + + const pending = reviewer(request()); + await vi.advanceTimersByTimeAsync(50); + + await expect(pending).resolves.toBeNull(); + expect(requestText).toHaveBeenCalledTimes(1); + expect(observedSignals[0]?.aborted).toBe(true); + }); }); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts deleted file mode 100644 index ed0ba5cb030..00000000000 --- a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { CatalogModel } from '@cindy/model-providers'; - -import { - findCatalogModel, - modelCanSuppressReasoning, - resolveAutoReviewBudget, -} from '../auto-review-budget.js'; - -const model = (over: Partial = {}): CatalogModel => ({ - id: 'm1', - name: 'M1', - contextWindow: 200_000, - efforts: [], - defaultEffort: null, - ...over, -} as CatalogModel); - -describe('modelCanSuppressReasoning', () => { - it('treats an explicit low/minimal tier as suppressible', () => { - expect(modelCanSuppressReasoning(model({ efforts: ['low', 'medium', 'high'] }))).toBe(true); - expect(modelCanSuppressReasoning(model({ efforts: ['minimal', 'high', 'max'] }))).toBe(true); - }); - - it('treats a model with no effort switch as suppressible', () => { - // Kimi K2.6 这类:不支持切档,但也不会强制长思考,紧凑额度够用。 - expect(modelCanSuppressReasoning(model({ efforts: [] }))).toBe(true); - }); - - it('treats a forced-reasoning model as NOT suppressible', () => { - // DeepSeek V4 Pro/Flash:档位只有 high/max,传 low 会被上调 —— 384 token - // 会被思考烧光、正文为空,正是审核静默失效的根因。 - expect(modelCanSuppressReasoning(model({ efforts: ['high', 'max'] }))).toBe(false); - }); - - it('is conservative when the model is unknown', () => { - // 目录里查不到(自定义供应商未声明能力)时宁可多给额度,也不要重演 384 token - // 判不出来的静默失败。 - expect(modelCanSuppressReasoning(undefined)).toBe(false); - }); -}); - -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.reasoningEffort).toBe('low'); - }); - - it('widens the budget for forced-reasoning models and drops the ineffective effort hint', () => { - const budget = resolveAutoReviewBudget(model({ efforts: ['high', 'max'] })); - expect(budget.maxTokens).toBeGreaterThan(384); - expect(budget.timeoutMs).toBeGreaterThan(12_000); - // 传 low 会被上调成模型支持的最低档,平白带一个不生效的字段;个别上游还会 - // 因为不认的值直接 400。 - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('widens the budget when the model is unknown', () => { - const budget = resolveAutoReviewBudget(undefined); - expect(budget.maxTokens).toBeGreaterThan(384); - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('sends minimal — not low — when that is the lowest tier the model declares', () => { - // 回归 PR #2474 review P1:目录里 z-ai/glm-5.2 是 ['minimal','high','max'], - // 固定发 low 会被上游拒绝或悄悄提到更高档,反而烧掉 384 token 的正文空间。 - const budget = resolveAutoReviewBudget(model({ efforts: ['minimal', 'high', 'max'] })); - expect(budget.maxTokens).toBe(384); - expect(budget.reasoningEffort).toBe('minimal'); - }); - - it('omits the effort field entirely for models with no effort tiers', () => { - // efforts: [] 的模型目录里有 10 个(Haiku 4.5 / Kimi K2.6 / grok 系 / qwen3.7-max - // 等)。它们不强制长思考,所以照常走紧凑额度,但带一个不认的字段是白冒 400 的险。 - const budget = resolveAutoReviewBudget(model({ efforts: [] })); - expect(budget.maxTokens).toBe(384); - expect(budget.timeoutMs).toBe(12_000); - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('prefers low over minimal when the model declares both', () => { - const budget = resolveAutoReviewBudget(model({ efforts: ['minimal', 'low', 'high'] })); - expect(budget.reasoningEffort).toBe('low'); - }); -}); - -describe('findCatalogModel', () => { - const providers = [ - { id: 'xd', models: { 'claude-code': [model({ id: 'shared' })], pi: [model({ id: 'shared' })] } }, - { id: 'other', models: { 'claude-code': [model({ id: 'only-here', efforts: ['high'] })] } }, - ]; - - it('prefers the named provider', () => { - expect(findCatalogModel(providers, 'xd', 'pi', 'shared')?.id).toBe('shared'); - }); - - it('falls back to a catalog-wide lookup when no provider is named', () => { - // Pi 的 providerId=null 表示走默认网关路由;这里只读能力元数据,不参与路由。 - expect(findCatalogModel(providers, null, 'claude-code', 'only-here')?.efforts) - .toEqual(['high']); - }); - - it('returns undefined for an unknown model instead of guessing', () => { - expect(findCatalogModel(providers, 'xd', 'pi', 'nope')).toBeUndefined(); - expect(findCatalogModel(providers, 'xd', 'pi', ' ')).toBeUndefined(); - }); - - it('never borrows another provider capability when a provider is named', () => { - // 回归 PR #2474 review:同一个模型 id 在两家目录下能力不同时,跨家借用会把 - // 强制思考的路由误判成"能关思考",于是又拿回��凑额度 —— 正是本 PR 要修的故障。 - const crossProvider = [ - { id: 'xd', models: { 'claude-code': [model({ id: 'dual', efforts: ['high'] })] } }, - { id: 'other', models: { 'claude-code': [model({ id: 'dual', efforts: ['low', 'high'] })] } }, - ]; - // 点名 'nowhere' 这家没有该模型 → 返回 undefined,由调用方走保守宽裕档; - // 绝不落到 'other' 的 low 档。 - expect(findCatalogModel(crossProvider, 'nowhere', 'claude-code', 'dual')).toBeUndefined(); - // 点名 'xd' 时只认 xd 自己的声明。 - expect(findCatalogModel(crossProvider, 'xd', 'claude-code', 'dual')?.efforts).toEqual(['high']); - }); -}); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts new file mode 100644 index 00000000000..4b3f9039b06 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { UtilityTextResult } from '../../../shared/utilityTextResult.js'; +import { DEDICATED_AUTO_REVIEW_CANDIDATES } from '../../utility-model/oneShotCandidates.js'; +import { + AUTO_REVIEW_CHAIN_TIMEOUT_MS, + createAutoReviewModelRouter, +} from '../auto-review-model-router.js'; + +const logger = () => ({ debug: vi.fn(), warn: vi.fn() }); + +function failed( + candidate: (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number], + reason: 'timeout' | 'empty_response' | 'request_failed' | 'http_error', + httpStatus?: number, +): UtilityTextResult { + return { + ok: false, + reason: reason === 'timeout' || reason === 'empty_response' ? reason : 'all_candidates_failed', + attempts: [reason === 'http_error' + ? { + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason, + httpStatus: httpStatus ?? 500, + } + : { + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason, + }], + }; +} + +function succeeded( + candidate: (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number], + text: string, +): UtilityTextResult { + return { + ok: true, + text, + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('dedicated Auto-review candidate policy', () => { + it('contains only the managed Gateway and supported subscription models in fixed order', () => { + expect(DEDICATED_AUTO_REVIEW_CANDIDATES.map((candidate) => [ + candidate.providerId, + candidate.model, + ])).toEqual([ + ['xd', 'cindy/auto-review'], + ['openai', 'gpt-5.4-nano'], + ['openai', 'gpt-5.6-luna'], + ['anthropic', 'claude-haiku-4-5'], + ]); + expect(JSON.stringify(DEDICATED_AUTO_REVIEW_CANDIDATES)).not.toMatch( + /xai|deepseek|kimi|custom/i, + ); + }); + + it('continues across malformed JSON, HTTP errors, and empty responses', async () => { + const log = logger(); + const calls: string[] = []; + const requestCandidate = vi.fn(async (_prompt, candidate) => { + calls.push(candidate.id); + switch (candidate.id) { + case 'cindy-gateway': + return succeeded(candidate, 'not json'); + case 'chatgpt-nano': + return failed(candidate, 'http_error', 400); + case 'chatgpt-luna': + return failed(candidate, 'empty_response'); + case 'claude-haiku': + return succeeded(candidate, '{"verdict":"allow","reason":"Routine"}'); + default: + throw new Error('Unexpected Auto-review candidate'); + } + }); + const route = createAutoReviewModelRouter({ logger: log, requestCandidate }); + + await expect(route('classify')).resolves.toBe( + '{"verdict":"allow","reason":"Routine"}', + ); + expect(calls).toEqual([ + 'cindy-gateway', + 'chatgpt-nano', + 'chatgpt-luna', + 'chatgpt-luna', + 'claude-haiku', + ]); + }); + + it('retries a quick transient provider failure in place', async () => { + const candidate = DEDICATED_AUTO_REVIEW_CANDIDATES[0]; + const requestCandidate = vi.fn() + .mockRejectedValueOnce(new Error('credential refresh failed with sensitive details')) + .mockResolvedValueOnce(succeeded(candidate, '{"verdict":"block"}')); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + await expect(route('classify')).resolves.toBe('{"verdict":"block"}'); + expect(requestCandidate).toHaveBeenCalledTimes(2); + expect(requestCandidate.mock.calls.map((call) => call[1].id)).toEqual([ + 'cindy-gateway', + 'cindy-gateway', + ]); + }); + + it('moves to the next provider after a full candidate timeout instead of starving fallback', async () => { + vi.useFakeTimers(); + const calls: string[] = []; + const requestCandidate = vi.fn((_prompt, candidate) => { + calls.push(candidate.id); + if (candidate.id === 'cindy-gateway') { + // Simulates credential refresh that ignores AbortSignal and never settles. + return new Promise(() => undefined); + } + return Promise.resolve(succeeded(candidate, '{"verdict":"allow"}')); + }); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + const pending = route('classify'); + await vi.advanceTimersByTimeAsync(12_000); + + await expect(pending).resolves.toBe('{"verdict":"allow"}'); + expect(calls).toEqual(['cindy-gateway', 'chatgpt-nano']); + }); + + it('aborts the in-flight request at the total deadline without starting another chain', async () => { + vi.useFakeTimers(); + const observedSignals: AbortSignal[] = []; + const repeatedCandidates = Array.from( + { length: 8 }, + () => DEDICATED_AUTO_REVIEW_CANDIDATES[0], + ); + const requestCandidate = vi.fn((_prompt, _candidate, opts) => { + observedSignals.push(opts.signal as AbortSignal); + return new Promise(() => undefined); + }); + const route = createAutoReviewModelRouter({ + logger: logger(), + candidates: repeatedCandidates, + requestCandidate, + }); + + const pending = route('classify'); + await vi.advanceTimersByTimeAsync(AUTO_REVIEW_CHAIN_TIMEOUT_MS); + + await expect(pending).resolves.toBeNull(); + expect(requestCandidate).toHaveBeenCalledTimes(5); + expect(observedSignals).toHaveLength(5); + expect(observedSignals.every((signal) => signal.aborted)).toBe(true); + }); + + it('stops immediately when the owning reviewer aborts', async () => { + const controller = new AbortController(); + const requestCandidate = vi.fn((_prompt, candidate, opts) => + new Promise((resolve) => { + opts.signal?.addEventListener( + 'abort', + () => resolve(failed(candidate, 'timeout')), + { once: true }, + ); + })); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + const pending = route('classify', controller.signal); + controller.abort(); + + await expect(pending).resolves.toBeNull(); + expect(requestCandidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts b/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts index eb9b513617a..905aece1430 100644 --- a/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts +++ b/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts @@ -17,14 +17,20 @@ interface AutoPermissionReviewerLogger { } export interface AutoPermissionReviewerDeps { - requestText(request: AutoReviewRequest, prompt: string): Promise; + requestText( + request: AutoReviewRequest, + prompt: string, + context: { signal: AbortSignal }, + ): Promise; logger: AutoPermissionReviewerLogger; /** - * 本次请求允许的单次耗时。缺省用构造时的 timeoutPolicy.requestTimeoutMs。 - * - * 存在的理由:额度是**按模型能力逐请求**决定的(强制思考的模型要 30s,能关 - * 思考的 12s 就够,见 auto-review-budget.ts),而 timeoutPolicy 是构造期固定的。 - * 如果这里的守卫仍按固定值计时,放宽额度的那一档会被自己的守卫切断。 + * `true` when requestText owns candidate fallback and transient retries. The reviewer then + * invokes it once, avoiding duplicate full-chain runs after an inner timeout. + */ + managesRetries?: boolean; + /** + * 本次 requestText 执行边界允许的总耗时。缺省用构造时的 + * timeoutPolicy.requestTimeoutMs;专用候选链用它把整链预算交给同一个取消守卫。 */ resolveRequestTimeoutMs?(request: AutoReviewRequest): number; } @@ -209,11 +215,15 @@ async function attemptReview( requestTimeoutMs: number, ): Promise<{ decision: AutoReviewDecision } | { failure: AttemptFailure; error?: string }> { let timeout: ReturnType | undefined; + const controller = new AbortController(); try { const text = await Promise.race([ - deps.requestText(request, prompt), + deps.requestText(request, prompt, { signal: controller.signal }), new Promise((resolve) => { - timeout = setTimeout(() => resolve(REVIEW_TIMEOUT), requestTimeoutMs); + timeout = setTimeout(() => { + controller.abort(); + resolve(REVIEW_TIMEOUT); + }, requestTimeoutMs); }), ]); if (text === REVIEW_TIMEOUT) return { failure: 'timeout' }; @@ -269,8 +279,10 @@ export function createAutoPermissionReviewer( // 切分是错的:抖动恢复往往就差那几秒,把 12s 切成 4s 会把本来能成功的请求 // 也判成超时,反而制造失败。总耗时的上界由 maker-core 的 // AUTO_REVIEW_DELEGATE_HARD_CEILING_MS 兜住,那里已按最慢一档 + 重试留足。 - const attempts = 1 + REVIEW_RETRIES; - // 单次超时按本次请求的模型能力取(强制思考的模型需要更久),缺省回到构造期策略。 + // 专用模型路由在一次 requestText 内完成候选回退与短暂错误重试;外层若再按 + // 旧规则重试,会在首轮超时后重新启动整条候选链。普通调用方继续沿用三次尝试。 + const attempts = deps.managesRetries ? 1 : 1 + REVIEW_RETRIES; + // 单次超时按本次请求的执行边界取,缺省回到构造期策略。 const requestTimeoutMs = deps.resolveRequestTimeoutMs?.(request) ?? timeoutPolicy.requestTimeoutMs; // 重试的**意图是次数**(试满 attempts 次),不是"在某个时间窗内尽量试"。 diff --git a/apps/desktop/src/main/maker-host/auto-review-budget.ts b/apps/desktop/src/main/maker-host/auto-review-budget.ts deleted file mode 100644 index a81b3e3124c..00000000000 --- a/apps/desktop/src/main/maker-host/auto-review-budget.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Auto-review 请求的额度自适应。 - * - * 背景:审核请求原本固定 384 token / 12 秒 / reasoningEffort='low'。这套参数 - * 对「能关掉思考」的模型足够,但对**强制思考**的模型是致命的 —— 它们的档位里 - * 根本没有 low/minimal,传了也会被上调,于是 384 token 全烧在思考上、正文为空, - * 审核判定失败。实测(2026-08-11)DeepSeek V4 Pro 在部分灰区用例上三轮全部 - * `empty_content_reasoning_only`,而且越是需要斟酌的场景越容易触发 —— - * 审核最该发挥作用的时刻恰好最容易失效。 - * - * 参考 Codex Guardian 的取舍:它给审阅器的是 272k 窗口、无输出上限、medium 档, - * 还允许调工具查证。我们不做到那个量级(那需要嵌套 agent 会话),但至少要让 - * 强制思考的模型有写完结论的空间 —— 慢一点可以接受,判不出来不行。 - */ - -import type { AgentKind, CatalogModel } from '@cindy/model-providers'; - -/** 能关思考的模型:够写一个 JSON 裁决即可。 */ -const COMPACT_MAX_TOKENS = 384; -const COMPACT_TIMEOUT_MS = 12_000; - -/** - * 强制思考的模型:思考段 + 结论段都要装得下。 - * - * 4096 是按实测的思考长度留的余量(DeepSeek 类模型在灰区用例上的思考通常 - * 1-2k token),不是拍脑袋的大数 —— 再大只会让超时更容易先触发。 - */ -const REASONING_MAX_TOKENS = 4_096; - -/** - * 相应放宽的超时。思考模型多花的是**输出**时间,首 token 延迟差别不大, - * 所以按输出量等比放宽而不是无限等 —— 用户仍在等这次工具调用。 - */ -const REASONING_TIMEOUT_MS = 30_000; - -/** 审核请求的执行参数。 */ -export interface AutoReviewBudget { - maxTokens: number; - timeoutMs: number; - /** `undefined` = 不传该字段,让模型走自己的默认档。 */ - reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; -} - -/** - * 该模型能否把思考关到最低档。 - * - * 判据是模型目录声明的 `efforts`: - * - 含 `minimal` 或 `low` → 能关,给紧凑额度; - * - 明确非空但不含二者(如 DeepSeek 的 `['high','max']`)→ 强制思考,给宽裕额度; - * - 空数组 `[]` → 该模型不支持切档(如 Kimi K2.6),它也不会强制长思考,按紧凑处理; - * - `undefined`(目录里没这个模型 / 自定义供应商未声明)→ **保守按强制思考处理**。 - * 宁可多给额度慢一点,也不要重演 384 token 判不出来的静默失败。 - */ -export function modelCanSuppressReasoning(model: CatalogModel | undefined): boolean { - const efforts = model?.efforts; - if (efforts === undefined) return false; - if (efforts.length === 0) return true; - return efforts.includes('minimal') || efforts.includes('low'); -} - -/** - * 紧凑档实际该发的 effort:该模型**自己声明过**的最低档,没有声明就不发。 - * - * 只在 `modelCanSuppressReasoning` 为真时调用,所以这里只需区分 low / minimal / - * 空数组三种;`undefined`(目录查不到)走的是宽裕分支,压根到不了这里。 - */ -function lowestDeclaredEffort( - model: CatalogModel | undefined, -): 'minimal' | 'low' | undefined { - const efforts = model?.efforts; - if (!efforts || efforts.length === 0) return undefined; - if (efforts.includes('low')) return 'low'; - if (efforts.includes('minimal')) return 'minimal'; - return undefined; -} - -/** - * 按模型能力选额度。 - * - * `model` 传 `undefined` 表示目录里查不到 —— 走保守分支(宽裕额度)。 - */ -export function resolveAutoReviewBudget(model: CatalogModel | undefined): AutoReviewBudget { - if (modelCanSuppressReasoning(model)) { - return { - maxTokens: COMPACT_MAX_TOKENS, - timeoutMs: COMPACT_TIMEOUT_MS, - // 只发该模型真正声明的最低档 —— 紧凑分支覆盖三种模型,不能一律发 low: - // - 声明了 low → low - // - 只声明 minimal → minimal(如 z-ai/glm-5.2;发 low 会被上游拒绝或 - // 悄悄提到更高档,反而烧掉 384 token 的正文空间) - // - efforts: [] → 省略(如 Haiku 4.5 / Kimi K2.6 / grok 系共 10 个; - // 它们根本没有档位概念,带一个不认的字段是白白冒 400 的险) - // 发错档会让审阅请求连续失败 → 重试耗尽 → 每个灰区操作降级成用户确认, - // 正好绕回本 PR 要修的故障(PR #2474 review P1)。 - reasoningEffort: lowestDeclaredEffort(model), - }; - } - return { - maxTokens: REASONING_MAX_TOKENS, - timeoutMs: REASONING_TIMEOUT_MS, - // 强制思考的模型不传 effort:传 low 会被���调成它支持的最低档,平白让请求 - // 带一个不生效的字段;个别上游还会因为不认的值直接 400。 - reasoningEffort: undefined, - }; -} - -/** 从当前目录里查一个 (供应商, agent, 模型) 的目录条目。 */ -export function findCatalogModel( - providers: ReadonlyArray<{ - id: string; - models: Partial>; - }>, - providerId: string | null | undefined, - agentKind: AgentKind, - modelId: string, -): CatalogModel | undefined { - const normalizedModel = modelId.trim(); - if (!normalizedModel) return undefined; - const normalizedProvider = providerId?.trim(); - if (normalizedProvider) { - // 点名了供应商就**只**在它的目录里找:同一个模型 id 在不同供应商下可能声明不同 - // 的 efforts,跨家借用会把强制思考的路由误判成"能关思考",于是又拿回 384/12s 的 - // 紧凑额度 —— 正是本 PR 要修的那个空正文故障(PR #2474 review)。 - // 未命中返回 undefined,由调用方走保守宽裕档。 - const provider = providers.find((item) => item.id === normalizedProvider); - return (provider?.models[agentKind] ?? []).find((m) => m.id === normalizedModel); - } - // 没有 providerId(Pi 的 null = 走默认网关路由)时按模型 id 全目录找第一个命中。 - // 只用于读能力元数据,不参与路由决策,所以首见即可。 - for (const provider of providers) { - const hit = (provider.models[agentKind] ?? []).find((m) => m.id === normalizedModel); - if (hit) return hit; - } - return undefined; -} diff --git a/apps/desktop/src/main/maker-host/auto-review-model-router.ts b/apps/desktop/src/main/maker-host/auto-review-model-router.ts new file mode 100644 index 00000000000..722bb626ee8 --- /dev/null +++ b/apps/desktop/src/main/maker-host/auto-review-model-router.ts @@ -0,0 +1,247 @@ +import type { AutoReviewDecision } from '@cindy/maker-core'; + +import type { UtilityTextResult } from '../../shared/utilityTextResult.js'; +import { + DEDICATED_AUTO_REVIEW_CANDIDATES, + requestDedicatedAutoReviewCandidateText, + type DedicatedAutoReviewCandidate, +} from '../utility-model/oneShotCandidates.js'; +import { parseAutoPermissionReviewDecision } from './auto-permission-reviewer.js'; + +export const AUTO_REVIEW_CANDIDATE_TIMEOUT_MS = 12_000; +export const AUTO_REVIEW_CHAIN_TIMEOUT_MS = 52_000; +export const AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS = AUTO_REVIEW_CHAIN_TIMEOUT_MS + 1_000; +const AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS = 2; +const AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS = 100; +const AUTO_REVIEW_CANDIDATE_TIMEOUT = Symbol('auto-review-candidate-timeout'); + +interface AutoReviewModelRouterLogger { + debug(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; +} + +interface AutoReviewModelRouterDeps { + logger: AutoReviewModelRouterLogger; + candidates?: readonly DedicatedAutoReviewCandidate[]; + requestCandidate?: ( + prompt: string, + candidate: DedicatedAutoReviewCandidate, + opts: { timeoutMs: number; signal?: AbortSignal }, + ) => Promise; + parseDecision?: (text: string) => AutoReviewDecision | null; + now?: () => number; + sleep?: (ms: number, signal: AbortSignal) => Promise; +} + +function sleepWithSignal(ms: number, signal: AbortSignal): Promise { + if (signal.aborted || ms <= 0) return Promise.resolve(); + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal.addEventListener('abort', finish, { once: true }); + }); +} + +function transientCandidateFailure(result: UtilityTextResult): boolean { + if (result.ok) return false; + return result.attempts.some((attempt) => { + if (attempt.status !== 'failed') return false; + if ( + attempt.reason === 'timeout' + || attempt.reason === 'empty_response' + || attempt.reason === 'request_failed' + ) { + return true; + } + return attempt.reason === 'http_error' + && (attempt.httpStatus === 408 || attempt.httpStatus === 429 || attempt.httpStatus >= 500); + }); +} + +function candidateRequestFailure( + candidate: DedicatedAutoReviewCandidate, +): UtilityTextResult { + return { + ok: false, + reason: 'all_candidates_failed', + attempts: [{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason: 'request_failed', + }], + }; +} + +function candidateTimeoutFailure( + candidate: DedicatedAutoReviewCandidate, +): UtilityTextResult { + return { + ok: false, + reason: 'timeout', + attempts: [{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason: 'timeout', + }], + }; +} + +async function requestCandidateWithinTimeout( + requestCandidate: NonNullable, + prompt: string, + candidate: DedicatedAutoReviewCandidate, + timeoutMs: number, + chainSignal: AbortSignal, +): Promise { + if (chainSignal.aborted) return candidateTimeoutFailure(candidate); + const controller = new AbortController(); + let timeout: ReturnType | undefined; + let abortFromChain: (() => void) | undefined; + const cutoff = new Promise((resolve) => { + const abort = () => { + controller.abort(); + resolve(AUTO_REVIEW_CANDIDATE_TIMEOUT); + }; + abortFromChain = abort; + if (chainSignal.aborted) abort(); + else chainSignal.addEventListener('abort', abort, { once: true }); + timeout = setTimeout(abort, timeoutMs); + }); + + try { + const result = await Promise.race([ + requestCandidate(prompt, candidate, { timeoutMs, signal: controller.signal }), + cutoff, + ]); + return result === AUTO_REVIEW_CANDIDATE_TIMEOUT + ? candidateTimeoutFailure(candidate) + : result; + } finally { + if (timeout) clearTimeout(timeout); + if (abortFromChain) chainSignal.removeEventListener('abort', abortFromChain); + } +} + +function safeFailureReason(result: UtilityTextResult): string { + if (result.ok) return 'none'; + const failed = result.attempts.find((attempt) => attempt.status === 'failed'); + return failed?.reason ?? result.reason; +} + +/** + * Runs the dedicated Auto-review model chain once. + * + * Each candidate owns its HTTP timeout and receives the chain AbortSignal. Only a quick, + * infrastructure-shaped failure is retried in place; a full timeout immediately yields to + * the next provider so one outage cannot consume the entire fallback budget. + */ +export function createAutoReviewModelRouter( + deps: AutoReviewModelRouterDeps, +): (prompt: string, signal?: AbortSignal) => Promise { + const candidates = deps.candidates ?? DEDICATED_AUTO_REVIEW_CANDIDATES; + const requestCandidate = deps.requestCandidate ?? requestDedicatedAutoReviewCandidateText; + const parseDecision = deps.parseDecision ?? parseAutoPermissionReviewDecision; + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? sleepWithSignal; + + return async (prompt, parentSignal) => { + const startedAt = now(); + const deadlineAt = startedAt + AUTO_REVIEW_CHAIN_TIMEOUT_MS; + const controller = new AbortController(); + const abortFromParent = () => controller.abort(); + if (parentSignal?.aborted) abortFromParent(); + else parentSignal?.addEventListener('abort', abortFromParent, { once: true }); + const deadline = setTimeout(() => controller.abort(), AUTO_REVIEW_CHAIN_TIMEOUT_MS); + + try { + for (const [candidateIndex, candidate] of candidates.entries()) { + if (controller.signal.aborted) break; + + for (let attempt = 1; attempt <= AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS; attempt++) { + const remainingMs = deadlineAt - now(); + if (remainingMs <= 0 || controller.signal.aborted) break; + const attemptStartedAt = now(); + let result: UtilityTextResult; + try { + result = await requestCandidateWithinTimeout( + requestCandidate, + prompt, + candidate, + Math.min(AUTO_REVIEW_CANDIDATE_TIMEOUT_MS, remainingMs), + controller.signal, + ); + } catch { + // Credential refresh and catalog probes are runtime boundaries too. A thrown + // candidate must not skip the remaining controlled providers or leak details. + result = candidateRequestFailure(candidate); + } + const durationMs = now() - attemptStartedAt; + + if (result.ok) { + const decision = parseDecision(result.text); + if (decision) { + deps.logger.debug('auto-review model candidate completed', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + verdict: decision.verdict, + durationMs: now() - startedAt, + }); + return JSON.stringify(decision); + } + deps.logger.warn('auto-review model candidate returned malformed decision', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + durationMs, + }); + break; + } + + const remainingAfterAttemptMs = deadlineAt - now(); + const laterCandidateReserveMs = + (candidates.length - candidateIndex - 1) * AUTO_REVIEW_CANDIDATE_TIMEOUT_MS; + const canRetry = attempt < AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS + && transientCandidateFailure(result) + && remainingAfterAttemptMs >= ( + AUTO_REVIEW_CANDIDATE_TIMEOUT_MS + + AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS + + laterCandidateReserveMs + ) + && !controller.signal.aborted; + deps.logger.warn('auto-review model candidate failed', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + reason: safeFailureReason(result), + retrying: canRetry, + durationMs, + }); + if (!canRetry) break; + await sleep(AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS, controller.signal); + } + } + } finally { + clearTimeout(deadline); + parentSignal?.removeEventListener('abort', abortFromParent); + } + + deps.logger.warn('auto-review model chain exhausted', { + candidates: candidates.length, + aborted: controller.signal.aborted, + durationMs: now() - startedAt, + }); + return null; + }; +} diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index 13c2fbe7714..0c85acdf634 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 { @@ -132,8 +131,10 @@ 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 { requestUtilityText } from '../utility-model/oneShotCandidates.js'; +import { + AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS, + createAutoReviewModelRouter, +} from './auto-review-model-router.js'; import { accountProviderReadinessBarrier } from './account-provider-readiness-barrier.js'; import { hasClaudeAiOAuth } from './claude-credentials-store.js'; import { @@ -260,20 +261,6 @@ let _maker: Maker | null = null; /** 视觉桥实例(层 A/B/C 共用),在 resetMaker 时释放缓存。 */ let _visionBridgeInstance: ReturnType | 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. */ @@ -281,24 +268,15 @@ export function setProviderAccessRuntimeRefreshListener(listener: (() => void) | providerAccessRuntimeRefreshListener = listener; } +const requestAutoReviewText = createAutoReviewModelRouter({ + logger: desktopMakerLogger, +}); + const reviewAutoPermissionAction = createAutoPermissionReviewer({ logger: desktopMakerLogger, - // 重试守卫必须按同一份额度计时,否则放宽额度的那一档会被自己的守卫切断。 - resolveRequestTimeoutMs: (request) => autoReviewBudgetFor(request).timeoutMs, - 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 } : {}), - }); - return result.ok ? result.text : null; - }, + managesRetries: true, + resolveRequestTimeoutMs: () => AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS, + requestText: (_request, prompt, { signal }) => requestAutoReviewText(prompt, signal), }); /** 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 6ac57261dba..14b476ca30a 100644 --- a/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts +++ b/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts @@ -11,6 +11,10 @@ vi.mock('electron', () => ({ }, })); +vi.mock('../../appCapabilities.js', () => ({ + getAppCapabilities: vi.fn(() => ({ canUseCindyGateway: true })), +})); + vi.mock('../UtilityModelSelection.js', () => ({ getUtilityModelChainProfiles: vi.fn(), })); @@ -70,6 +74,7 @@ vi.mock('../../model-access/effectiveEndpoint.js', async () => { import type { Maker } from '@cindy/maker-core'; import { fetch as undiciFetch } from 'undici'; +import { getAppCapabilities } from '../../appCapabilities.js'; import { readClaudeApiKey } from '../../maker-host/auth-adapters.js'; import { getChatgptBridgeAuth } from '../../maker-host/anthropic-responses-bridge-host.js'; import { getValidClaudeAiOAuth } from '../../maker-host/claude-oauth-refresh.js'; @@ -80,9 +85,16 @@ import { readModelDisableOverrides } from '../../maker-host/model-disable-store. import { isProviderRouteMutationInProgress } from '../../maker-host/provider-route.js'; import { readCustomProviderKey } from '../../secrets/providerSecretStore.js'; import { getUtilityModelChainProfiles } from '../UtilityModelSelection.js'; -import { getUtilityTextCandidates, requestUtilityText, toAnthropicApiModelId } from '../oneShotCandidates.js'; +import { + DEDICATED_AUTO_REVIEW_CANDIDATES, + getUtilityTextCandidates, + requestDedicatedAutoReviewCandidateText, + requestUtilityText, + toAnthropicApiModelId, +} from '../oneShotCandidates.js'; const getProfiles = vi.mocked(getUtilityModelChainProfiles); +const appCapabilities = vi.mocked(getAppCapabilities); const readKey = vi.mocked(readClaudeApiKey); const readCodexCreds = vi.mocked(getChatgptBridgeAuth); const readClaudeOAuth = vi.mocked(getValidClaudeAiOAuth); @@ -105,6 +117,8 @@ function makerMock(authenticated: boolean): Maker { describe('utility one-shot candidates', () => { beforeEach(() => { vi.clearAllMocks(); + fetchMock.mockReset(); + appCapabilities.mockReturnValue({ canUseCindyGateway: true } as never); readKey.mockReturnValue(null); readCodexCreds.mockRejectedValue(new Error('not authenticated')); readClaudeOAuth.mockResolvedValue(null); @@ -1619,6 +1633,203 @@ describe('utility one-shot candidates', () => { expect(body).not.toHaveProperty('max_output_tokens'); }); + it('routes the fixed cindy/auto-review alias without requiring a catalog entry', async () => { + readKey.mockReturnValue('xd-key'); + activeCatalog.mockReturnValue({ providers: [] } as never); + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + choices: [{ message: { content: '{"verdict":"allow"}' } }], + }), + } as never); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: true, + providerId: 'xd', + model: 'cindy/auto-review', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://gateway.test.invalid/v1/chat/completions', + expect.anything(), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + model: 'cindy/auto-review', + max_tokens: 384, + messages: [{ role: 'user', content: 'classify' }], + }); + }); + + it('cancels the Gateway HTTP request through the candidate signal', async () => { + readKey.mockReturnValue('xd-key'); + const controller = new AbortController(); + fetchMock.mockImplementationOnce((_url, init) => + new Promise((_resolve, reject) => { + (init?.signal as AbortSignal | undefined)?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + })); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not use the Gateway alias outside a Cindy cloud session', async () => { + appCapabilities.mockReturnValue({ canUseCindyGateway: false } as never); + readKey.mockReturnValue('xd-key'); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: false, + reason: 'no_candidate', + attempts: [expect.objectContaining({ reason: 'not_authenticated' })], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses a supported ChatGPT subscription model with a tool-free Responses body', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'openai', + name: 'OpenAI', + source: 'builtin', + agents: ['codex'], + auth: { method: 'oauth' }, + routing: { + codex: { + upstream: 'https://chatgpt.example/backend-api/codex', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + codex: [{ id: 'gpt-5.4-nano', name: 'Nano', contextWindow: 272_000 }], + }, + }], + } as never); + readCodexCreds.mockResolvedValue({ accessToken: 'codex-token', accountId: 'account-1' }); + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => + 'data: {"type":"response.output_text.delta","delta":"{\\"verdict\\":\\"allow\\"}"}\ndata: [DONE]\n', + } as never); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[1], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: true, + providerId: 'openai', + model: 'gpt-5.4-nano', + }); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body).toMatchObject({ + model: 'gpt-5.4-nano', + reasoning: { effort: 'low' }, + }); + expect(body).not.toHaveProperty('tools'); + expect(body).not.toHaveProperty('tool_choice'); + expect(body).not.toHaveProperty('parallel_tool_calls'); + }); + + it('does not start OpenAI HTTP after credential refresh outlives the candidate', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'openai', + name: 'OpenAI', + source: 'builtin', + agents: ['codex'], + auth: { method: 'oauth' }, + routing: { + codex: { + upstream: 'https://chatgpt.example/backend-api/codex', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + codex: [{ id: 'gpt-5.4-nano', name: 'Nano', contextWindow: 272_000 }], + }, + }], + } as never); + let resolveAuth: ((auth: { accessToken: string; accountId: string }) => void) | undefined; + readCodexCreds.mockImplementationOnce(() => new Promise((resolve) => { + resolveAuth = resolve; + })); + const controller = new AbortController(); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[1], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(); + resolveAuth?.({ accessToken: 'late-token', accountId: 'late-account' }); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not start Anthropic HTTP after credential refresh outlives the candidate', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'anthropic', + name: 'Anthropic', + source: 'builtin', + agents: ['claude-code'], + auth: { method: 'oauth' }, + routing: { + 'claude-code': { + upstream: 'https://anthropic.example', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + 'claude-code': [{ id: 'claude-haiku-4-5', name: 'Haiku', contextWindow: 200_000 }], + }, + }], + } as never); + let resolveOAuth: ((auth: { accessToken: string }) => void) | undefined; + readClaudeOAuth.mockImplementationOnce(() => new Promise((resolve) => { + resolveOAuth = resolve; + })); + const controller = new AbortController(); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[3], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(); + resolveOAuth?.({ accessToken: 'late-token' }); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('uses xAI OAuth and the selected xAI Responses route', async () => { activeCatalog.mockReturnValue({ providers: [{ @@ -1646,10 +1857,14 @@ describe('utility one-shot candidates', () => { expect(result).toMatchObject({ ok: true, providerId: 'xai', model: 'xai/grok-4.3' }); expect(fetchMock).toHaveBeenCalledWith('https://xai.example/v1/responses', expect.anything()); - expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body).toMatchObject({ model: 'grok-4.3', reasoning: { effort: 'low' }, }); + expect(body).not.toHaveProperty('tools'); + expect(body).not.toHaveProperty('tool_choice'); + expect(body).not.toHaveProperty('parallel_tool_calls'); }); it.each(['xai/grok-code-fast', 'xai/grok-build-preview'])( diff --git a/apps/desktop/src/main/utility-model/oneShotCandidates.ts b/apps/desktop/src/main/utility-model/oneShotCandidates.ts index 0b99284c739..459f2a1b81c 100644 --- a/apps/desktop/src/main/utility-model/oneShotCandidates.ts +++ b/apps/desktop/src/main/utility-model/oneShotCandidates.ts @@ -4,6 +4,7 @@ import { type AgentKind, type Maker } from '@cindy/maker-core'; import { appendProviderRequestPath, storedCustomProviderId } from '@cindy/model-providers'; import { createLogger } from '../logger.js'; +import { getAppCapabilities } from '../appCapabilities.js'; import { readClaudeApiKey } from '../maker-host/auth-adapters.js'; import { getChatgptBridgeAuth } from '../maker-host/anthropic-responses-bridge-host.js'; import { getValidClaudeAiOAuth } from '../maker-host/claude-oauth-refresh.js'; @@ -57,6 +58,8 @@ export type UtilityTextRequestOptions = { timeoutMs?: number; /** Optional lightweight reasoning hint for short internal classifiers. */ reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + /** Abort an in-flight direct HTTP request when the owning workflow ends. */ + signal?: AbortSignal; /** 显式任务来源;存在时禁止跨来源 fallback。 */ providerId?: string; agentKind?: AgentKind; @@ -271,6 +274,144 @@ export async function requestUtilityText( return requestDefaultUtilityText(maker, prompt, opts); } +const DEDICATED_AUTO_REVIEW_MAX_TOKENS = 384; + +/** + * Auto-review 的封闭候选表。它刻意不接受调用方传 provider/model:待审内容只能 + * 发往 Cindy 托管网关或用户已连接的 OpenAI/Anthropic 订阅,不能跟随主会话 + * 落到 xAI、DeepSeek、Kimi 或自定义 BYOM。 + */ +export const DEDICATED_AUTO_REVIEW_CANDIDATES = Object.freeze([ + { + id: 'cindy-gateway', + providerId: 'xd', + agentKind: 'codex', + model: 'cindy/auto-review', + transport: 'litellm-chat-completions', + reasoningEffort: undefined, + }, + { + id: 'chatgpt-nano', + providerId: 'openai', + agentKind: 'codex', + model: 'gpt-5.4-nano', + transport: 'codex-responses', + reasoningEffort: 'low', + }, + { + id: 'chatgpt-luna', + providerId: 'openai', + agentKind: 'codex', + model: 'gpt-5.6-luna', + transport: 'codex-responses', + reasoningEffort: 'low', + }, + { + id: 'claude-haiku', + providerId: 'anthropic', + agentKind: 'claude-code', + model: 'claude-haiku-4-5', + transport: 'litellm-chat-completions', + reasoningEffort: undefined, + }, +] as const satisfies ReadonlyArray<{ + id: string; + providerId: 'xd' | 'openai' | 'anthropic'; + agentKind: AgentKind; + model: string; + transport: UtilityModelTransport; + reasoningEffort: 'low' | undefined; +}>); + +export type DedicatedAutoReviewCandidate = (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number]; + +/** + * 执行一个专用 Auto-review 候选。 + * + * Gateway 别名不属于用户模型目录,必须走这条受限入口绕过普通显式路由的目录 + * 校验;订阅候选反过来必须存在于实时目录,防止对账号不支持的模型盲发请求。 + */ +export async function requestDedicatedAutoReviewCandidateText( + prompt: string, + candidate: DedicatedAutoReviewCandidate, + opts: { timeoutMs: number; signal?: AbortSignal }, +): Promise { + const profile: UtilityModelProfile = { + id: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + auth: candidate.providerId === 'xd' ? 'api-key' : 'codex', + settingsTab: 'providers', + missingCredentialMessage: 'The Auto-review provider is not authenticated.', + }; + + if (opts.signal?.aborted) return cancelledUtilityTextResult(profile); + if (isProviderDisabled(readModelDisableOverrides(), candidate.providerId)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + + if (candidate.id === 'cindy-gateway') { + if (!getAppCapabilities().canUseCindyGateway) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; + } + const apiKey = readClaudeApiKey(); + 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')] }; + } + return executeCandidates([{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + profile, + execute: (text, requestOpts) => requestProviderHttpText({ + wire: 'chat-completions', + endpoint: joinProxyPath(baseUrl, '/v1/chat/completions'), + headers: { Authorization: `Bearer ${apiKey}` }, + model: candidate.model, + prompt: text, + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: requestOpts?.timeoutMs ?? opts.timeoutMs, + signal: requestOpts?.signal ?? opts.signal, + }), + }], prompt, [], { + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: opts.timeoutMs, + signal: opts.signal, + }); + } + + const provider = getActiveCatalog().providers.find((item) => item.id === candidate.providerId); + const configured = provider?.models[candidate.agentKind] ?? []; + if ( + !provider + || !provider.agents.includes(candidate.agentKind) + || !provider.routing[candidate.agentKind] + ) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'agent_unavailable')] }; + } + if (!configured.some((model) => model.id === candidate.model)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + if (isProviderModelRouteDisabled(candidate.providerId, candidate.model)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + + return requestBuiltinProviderText(prompt, { + provider, + agentKind: candidate.agentKind, + model: candidate.model, + transport: candidate.transport, + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: opts.timeoutMs, + reasoningEffort: candidate.reasoningEffort, + signal: opts.signal, + }); +} + /** Older remote/mobile callers may omit providerId; a model unique to one * non-XD provider is still enough to preserve the selected route. */ function inferUniqueProviderId(agentKind: AgentKind | undefined, model: string | undefined): string | undefined { @@ -524,6 +665,7 @@ async function requestExplicitProviderText( maxTokens: requestOpts?.maxTokens, timeoutMs: requestOpts?.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort, + signal: requestOpts?.signal, }), }; return executeCandidates([candidate], prompt, [], opts); @@ -542,6 +684,20 @@ function supportsXaiReasoning(model: string): boolean { return !(normalized.startsWith('grok-code') || normalized.startsWith('grok-build')); } +function cancelledUtilityTextResult(profile: UtilityModelProfile): UtilityTextResult { + return { + ok: false, + reason: 'timeout', + attempts: [{ + providerId: profile.id, + model: profile.model, + transport: profile.transport, + status: 'failed', + reason: 'timeout', + }], + }; +} + // 内置供应商的执行分支只认下面硬编码的 xd/anthropic/openai/xai 四家;钉档 // 清单侧(textOneshotPinOptions.isRoutableForOneshot)按同一集合过滤——新增 // 第五个聊天型内置供应商时两边一起动,否则清单会列出这里接不住的模型。 @@ -555,6 +711,7 @@ async function requestBuiltinProviderText( maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }, ): Promise { const profile: UtilityModelProfile = { @@ -565,6 +722,7 @@ async function requestBuiltinProviderText( settingsTab: 'providers', missingCredentialMessage: 'The selected provider is not authenticated.', }; + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); const routing = input.provider.routing[input.agentKind]; if (!routing) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'agent_unavailable')] }; @@ -602,12 +760,14 @@ async function requestBuiltinProviderText( maxTokens: requestOpts?.maxTokens ?? input.maxTokens, timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } if (input.provider.id === 'anthropic') { const oauth = await getValidClaudeAiOAuth(); + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); if (!oauth?.accessToken) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } @@ -633,6 +793,7 @@ async function requestBuiltinProviderText( maxTokens: requestOpts?.maxTokens ?? input.maxTokens ?? catalogModel?.maxOutput ?? 81_920, timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -644,6 +805,7 @@ async function requestBuiltinProviderText( } catch { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); if (!creds.accountId) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } @@ -673,6 +835,7 @@ async function requestBuiltinProviderText( // parameter with HTTP 400. The Auto reviewer enforces its own compact // output ceiling after the response instead. supportsMaxOutputTokens: false, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -684,6 +847,7 @@ async function requestBuiltinProviderText( } catch { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); return executeCandidates([{ providerId: input.provider.id, model: input.model, @@ -699,6 +863,7 @@ async function requestBuiltinProviderText( timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, supportsReasoning: supportsXaiReasoning(input.model), + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -814,6 +979,7 @@ function resolveLiteLlmCandidate(profile: UtilityModelProfile): UtilityTextCandi maxTokens: opts?.maxTokens, timeoutMs: opts?.timeoutMs, reasoningEffort: opts?.reasoningEffort, + signal: opts?.signal, }), }, }; @@ -827,9 +993,13 @@ async function requestLiteLlmText(input: { maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }): Promise { const controller = new AbortController(); const timeoutMs = input.timeoutMs ?? 20_000; + const abortFromParent = () => controller.abort(); + if (input.signal?.aborted) abortFromParent(); + else input.signal?.addEventListener('abort', abortFromParent, { once: true }); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await undiciFetch(joinProxyPath(input.baseUrl, '/v1/chat/completions'), { @@ -872,6 +1042,7 @@ async function requestLiteLlmText(input: { throw new UtilityTextExecutionError({ reason: 'request_failed' }); } finally { clearTimeout(timeout); + input.signal?.removeEventListener('abort', abortFromParent); } } @@ -955,9 +1126,14 @@ async function requestProviderHttpText(input: { retryWithMinimalBodyOnInvalidRequest?: boolean; /** Some private Responses-compatible endpoints reject max_output_tokens. */ supportsMaxOutputTokens?: boolean; + /** Owning workflow cancellation; linked with the candidate timeout below. */ + signal?: AbortSignal; }): Promise { const controller = new AbortController(); const timeoutMs = input.timeoutMs ?? 90_000; + const abortFromParent = () => controller.abort(); + if (input.signal?.aborted) abortFromParent(); + else input.signal?.addEventListener('abort', abortFromParent, { once: true }); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const supportsRequestedReasoning = Boolean( @@ -973,9 +1149,6 @@ async function requestProviderHttpText(input: { model: input.model, input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: input.prompt }] }], ...(!minimal ? { - tools: [], - tool_choice: 'auto', - parallel_tool_calls: false, store: false, stream: true, } : {}), @@ -1053,6 +1226,7 @@ async function requestProviderHttpText(input: { throw new UtilityTextExecutionError({ reason: 'request_failed' }); } finally { clearTimeout(timeout); + input.signal?.removeEventListener('abort', abortFromParent); } } @@ -1136,6 +1310,7 @@ async function requestCustomProviderText(input: { maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }): Promise { const headers: Record = { ...(input.headers ?? {}), @@ -1182,6 +1357,7 @@ async function requestCustomProviderText(input: { timeoutMs: input.timeoutMs, reasoningEffort: input.reasoningEffort, retryWithMinimalBodyOnInvalidRequest: true, + signal: input.signal, }); }