fix(cliproxy): preserve adaptive thinking on opus 4.7 paths

This commit is contained in:
Tam Nhu Tran
2026-04-22 21:43:11 -04:00
parent 45fe7ab086
commit 71deda553a
11 changed files with 159 additions and 9 deletions
+8
View File
@@ -264,9 +264,17 @@ export function mergeCatalog(
mergedIds.add(remote.id.toLowerCase()); mergedIds.add(remote.id.toLowerCase());
if (staticEntry) { if (staticEntry) {
const mergedThinking = remoteEntry.thinking
? {
...remoteEntry.thinking,
maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel,
}
: staticEntry.thinking;
// Merge: remote overrides, static fills gaps // Merge: remote overrides, static fills gaps
mergedModels.push({ mergedModels.push({
...remoteEntry, ...remoteEntry,
thinking: mergedThinking,
// Preserve static-only fields // Preserve static-only fields
tier: staticEntry.tier, tier: staticEntry.tier,
broken: staticEntry.broken, broken: staticEntry.broken,
+12 -2
View File
@@ -7,7 +7,7 @@ import type { CLIProxyProvider } from '../types';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
import type { ThinkingConfig } from '../../config/unified-config-types'; import type { ThinkingConfig } from '../../config/unified-config-types';
import { getThinkingConfig } from '../../config/unified-config-loader'; import { getThinkingConfig } from '../../config/unified-config-loader';
import { supportsThinking } from '../model-catalog'; import { getModelThinkingSupport, supportsThinking } from '../model-catalog';
import { isThinkingOffValue, validateThinking } from '../thinking-validator'; import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { normalizeModelIdForProvider } from '../model-id-normalizer'; import { normalizeModelIdForProvider } from '../model-id-normalizer';
import { warn } from '../../utils/ui'; import { warn } from '../../utils/ui';
@@ -86,7 +86,8 @@ function applyThinkingSuffixForProvider(
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/); const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
// Existing parenthesized suffix: // Existing parenthesized suffix:
// - keep as-is for non-codex providers // - keep as-is for non-codex providers unless the target model now expects
// named levels and we need to rewrite an old numeric suffix
// - for codex effort levels, normalize to codex model suffix style // - for codex effort levels, normalize to codex model suffix style
if (parenthesizedSuffixMatch) { if (parenthesizedSuffixMatch) {
if (provider === 'codex') { if (provider === 'codex') {
@@ -95,6 +96,15 @@ function applyThinkingSuffixForProvider(
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`); return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
} }
} }
if (provider) {
const normalizedBaseModel = normalizeModelForThinkingLookup(model, provider);
const thinking = getModelThinkingSupport(provider, normalizedBaseModel);
if (thinking?.type === 'levels') {
return model.replace(/\([^)]+\)$/, `(${thinkingValue})`);
}
}
return model; return model;
} }
+18 -5
View File
@@ -68,17 +68,30 @@ function toToolResultContent(content: unknown, label: string): string {
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
} }
function mapThinkingToReasoningEffort( function mapAdaptiveEffortToCursorReasoningEffort(effort: string | undefined): string {
thinking: CursorAnthropicRequest['thinking'] const normalized = effort?.trim().toLowerCase();
): string | undefined { if (!normalized || normalized === 'auto') {
return 'high';
}
if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium') {
return 'medium';
}
return 'high';
}
function mapThinkingToReasoningEffort(request: CursorAnthropicRequest): string | undefined {
const thinking = request.thinking;
if (!thinking) { if (!thinking) {
return undefined; return undefined;
} }
if (thinking.type === 'disabled') { if (thinking.type === 'disabled') {
return undefined; return undefined;
} }
if (thinking.type === 'adaptive') {
return mapAdaptiveEffortToCursorReasoningEffort(request.output_config?.effort);
}
if (thinking.type !== 'enabled') { if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"'); throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"');
} }
return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
? 'high' ? 'high'
@@ -209,7 +222,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ
? request.model ? request.model
: undefined, : undefined,
stream: request.stream === true, stream: request.stream === true,
reasoning_effort: mapThinkingToReasoningEffort(request.thinking), reasoning_effort: mapThinkingToReasoningEffort(request),
tools: Array.isArray(request.tools) ? request.tools : undefined, tools: Array.isArray(request.tools) ? request.tools : undefined,
messages: translatedMessages, messages: translatedMessages,
}; };
+3
View File
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
system?: string | AnthropicTextBlock[]; system?: string | AnthropicTextBlock[];
stream?: boolean; stream?: boolean;
tools?: CursorTool[]; tools?: CursorTool[];
output_config?: {
effort?: string;
};
thinking?: { thinking?: {
type?: string; type?: string;
budget_tokens?: number; budget_tokens?: number;
@@ -396,7 +396,7 @@ function mapThinkingToReasoning(
}; };
} }
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']); const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
function resolveOutputConfigEffort( function resolveOutputConfigEffort(
outputConfig: AnthropicOutputConfig | undefined outputConfig: AnthropicOutputConfig | undefined
@@ -416,7 +416,7 @@ function resolveOutputConfigEffort(
* for Codex; for generic OpenAI-compat providers we clamp to high. * for Codex; for generic OpenAI-compat providers we clamp to high.
*/ */
function toOpenAIEffort(effort: string): string { function toOpenAIEffort(effort: string): string {
return effort === 'max' ? 'high' : effort; return effort === 'max' || effort === 'xhigh' ? 'high' : effort;
} }
function transformMessages(messagesValue: unknown): OpenAIMessage[] { function transformMessages(messagesValue: unknown): OpenAIMessage[] {
+44
View File
@@ -9,6 +9,9 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as lockfile from 'proper-lockfile'; import * as lockfile from 'proper-lockfile';
import { getModelThinkingSupport } from '../cliproxy/model-catalog';
import { validateThinking } from '../cliproxy/thinking-validator';
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
const CCS_MODEL_PREFIX = 'ccs-'; const CCS_MODEL_PREFIX = 'ccs-';
const CCS_DISPLAY_PREFIX = 'CCS '; const CCS_DISPLAY_PREFIX = 'CCS ';
@@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number {
return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high;
} }
function resolveAnthropicModelId(model: string): string {
return stripModelConfigurationSuffixes(model);
}
function usesAnthropicAdaptiveThinking(model: string): boolean {
return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels';
}
function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined {
const validation = validateThinking('claude', resolveAnthropicModelId(model), value);
if (isReasoningOffValue(validation.value)) {
return undefined;
}
const normalized = String(validation.value).trim().toLowerCase();
return normalized === 'auto' ? undefined : normalized;
}
function toReasoningEffort(value: string | number): string { function toReasoningEffort(value: string | number): string {
if (typeof value === 'number') { if (typeof value === 'number') {
if (value <= 4000) return 'low'; if (value <= 4000) return 'low';
@@ -181,12 +202,35 @@ function applyReasoningOverride(
if (isReasoningOffValue(reasoningOverride)) { if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.thinking; delete extraArgs.thinking;
delete extraArgs.output_config;
} else if (usesAnthropicAdaptiveThinking(entry.model)) {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {};
const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride);
thinking.type = 'adaptive';
delete thinking.budget_tokens;
delete thinking.budgetTokens;
if (effort) {
outputConfig.effort = effort;
} else {
delete outputConfig.effort;
}
extraArgs.thinking = thinking;
if (Object.keys(outputConfig).length > 0) {
extraArgs.output_config = outputConfig;
} else {
delete extraArgs.output_config;
}
} else { } else {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
thinking.type = 'enabled'; thinking.type = 'enabled';
thinking.budget_tokens = toAnthropicBudget(reasoningOverride); thinking.budget_tokens = toAnthropicBudget(reasoningOverride);
delete thinking.budgetTokens; delete thinking.budgetTokens;
extraArgs.thinking = thinking; extraArgs.thinking = thinking;
delete extraArgs.output_config;
} }
} else if (provider === 'openai') { } else if (provider === 'openai') {
delete extraArgs.reasoning_effort; delete extraArgs.reasoning_effort;
@@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => {
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
}); });
it('rewrites legacy budget suffixes for claude level-based models', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max');
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)');
});
it('should use codex effort suffix style when provider is codex', () => { it('should use codex effort suffix style when provider is codex', () => {
const envVars: NodeJS.ProcessEnv = { const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_MODEL: 'gpt-5.3-codex',
@@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => {
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
}); });
it('preserves static maxLevel when live thinking metadata omits it', () => {
const catalog = mergeCatalog('claude', [
{
id: 'claude-opus-4-7',
display_name: 'Claude Opus 4.7',
thinking: {
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
dynamic_allowed: true,
},
},
]);
expect(catalog?.models[0]?.thinking).toMatchObject({
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
});
});
}); });
@@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => {
]); ]);
}); });
it('maps adaptive anthropic thinking into Cursor reasoning effort', () => {
const translated = translateAnthropicRequest({
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
messages: [{ role: 'user', content: 'hello' }],
});
expect(translated.reasoning_effort).toBe('high');
});
it('preserves mixed user text around tool_result blocks in order', () => { it('preserves mixed user text around tool_result blocks in order', () => {
const translated = translateAnthropicRequest({ const translated = translateAnthropicRequest({
messages: [ messages: [
@@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
}); });
it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
});
expect(result.reasoning_effort).toBe('high');
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('rejects unsupported thinking types instead of silently dropping them', () => { it('rejects unsupported thinking types instead of silently dropping them', () => {
expect(() => expect(() =>
new ProxyRequestTransformer().transform({ new ProxyRequestTransformer().transform({
@@ -143,6 +143,23 @@ describe('droid-config-manager', () => {
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960); expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
}); });
it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => {
await upsertCcsModel('claude', {
model: 'claude-opus-4-7',
displayName: 'CCS claude',
baseUrl: 'https://api.anthropic.com',
apiKey: 'anthropic-key',
provider: 'anthropic',
reasoningOverride: 'max',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive');
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined();
expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max');
});
it('should clear prior reasoning config when override disables thinking', async () => { it('should clear prior reasoning config when override disables thinking', async () => {
await upsertCcsModel('glm', { await upsertCcsModel('glm', {
model: 'glm-4.7', model: 'glm-4.7',