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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): CatalogModel => ({
Expand Down Expand Up @@ -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');
});

Expand Down Expand Up @@ -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' })] } },
Expand All @@ -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'] })] } },
Expand Down
31 changes: 29 additions & 2 deletions apps/desktop/src/main/maker-host/auto-review-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
* 强制思考的模型:思考段 + 结论段都要装得下。
Expand Down Expand Up @@ -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<Record<AgentKind, CatalogModel[]>>;
}>,
): 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<{
Expand Down
81 changes: 54 additions & 27 deletions apps/desktop/src/main/maker-host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
ClaudeCodeAgent,
CodexAgent,
configureDefaultImageResizer,
type AutoReviewRequest,
type McpProvider,
} from '@cindy/maker-core';
import {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/main/utility-model/oneShotCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down