From eedb53b49e41b044f570a7273c6b6a9231cd75d2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 12:27:50 +0700 Subject: [PATCH] feat(droid): sync reasoning effort across CLI and dashboard --- src/ccs.ts | 46 ++- src/targets/droid-adapter.ts | 1 + src/targets/droid-config-manager.ts | 129 +++++- src/targets/droid-reasoning-runtime.ts | 56 +++ src/targets/target-adapter.ts | 6 + .../services/compatible-cli-docs-registry.ts | 2 + .../services/droid-dashboard-service.ts | 46 ++- .../unit/targets/droid-config-manager.test.ts | 73 ++++ .../targets/droid-reasoning-runtime.test.ts | 37 ++ tests/unit/targets/target-registry.test.ts | 25 ++ .../droid-dashboard-service.test.ts | 72 ++++ .../droid-byok-reasoning-controls-card.tsx | 134 +++++++ ui/src/lib/droid-byok-custom-models.ts | 377 ++++++++++++++++++ ui/src/pages/droid.tsx | 60 ++- .../ui/lib/droid-byok-custom-models.test.ts | 182 +++++++++ 15 files changed, 1228 insertions(+), 18 deletions(-) create mode 100644 src/targets/droid-reasoning-runtime.ts create mode 100644 tests/unit/targets/droid-reasoning-runtime.test.ts create mode 100644 ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx create mode 100644 ui/src/lib/droid-byok-custom-models.ts create mode 100644 ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 245d4bf9..f3b24d56 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -58,6 +58,10 @@ import { type TargetCredentials, } from './targets'; import { resolveTargetType, stripTargetFlag } from './targets/target-resolver'; +import { + DroidReasoningFlagError, + resolveDroidReasoningRuntime, +} from './targets/droid-reasoning-runtime'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -711,6 +715,32 @@ async function main(): Promise { } } + let targetRemainingArgs = remainingArgs; + let droidReasoningOverride: string | number | undefined; + if (resolvedTarget === 'droid') { + try { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + droidReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort medium|high|xhigh'); + process.exit(1); + } + throw error; + } + } + // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. @@ -772,14 +802,13 @@ async function main(): Promise { '--remote-only', '--no-fallback', '--allow-self-signed', - '--thinking', - '--effort', '--1m', '--no-1m', ]; const providedUnsupportedFlag = unsupportedCliproxyFlags.find( (flag) => - remainingArgs.includes(flag) || remainingArgs.some((arg) => arg.startsWith(`${flag}=`)) + targetRemainingArgs.includes(flag) || + targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) ); if (providedUnsupportedFlag) { console.error( @@ -817,7 +846,7 @@ async function main(): Promise { const ensureServiceResult = await ensureCliproxyService( cliproxyPort, - remainingArgs.includes('--verbose') || remainingArgs.includes('-v') + targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') ); if (!ensureServiceResult.started) { console.error( @@ -847,6 +876,7 @@ async function main(): Promise { baseUrl: envVars['ANTHROPIC_BASE_URL'], model: envVars['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, envVars, }; @@ -864,7 +894,7 @@ async function main(): Promise { } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -1020,9 +1050,10 @@ async function main(): Promise { baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], model: settingsEnv['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, }; await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs); + const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, profileInfo.type); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; @@ -1091,6 +1122,7 @@ async function main(): Promise { baseUrl: process.env['ANTHROPIC_BASE_URL'], model: process.env['ANTHROPIC_MODEL'], }), + reasoningOverride: droidReasoningOverride, }; if (!creds.baseUrl || !creds.apiKey) { console.error( @@ -1102,7 +1134,7 @@ async function main(): Promise { process.exit(1); } await adapter.prepareCredentials(creds); - const targetArgs = adapter.buildArgs('default', remainingArgs); + const targetArgs = adapter.buildArgs('default', targetRemainingArgs); const targetEnv = adapter.buildEnv(creds, 'default'); adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined }); return; diff --git a/src/targets/droid-adapter.ts b/src/targets/droid-adapter.ts index 2deeb1d7..e42ed2d0 100644 --- a/src/targets/droid-adapter.ts +++ b/src/targets/droid-adapter.ts @@ -55,6 +55,7 @@ export class DroidAdapter implements TargetAdapter { baseUrl: creds.baseUrl, apiKey: creds.apiKey, provider, + reasoningOverride: creds.reasoningOverride, }); if (!modelRef.selector) { throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`); diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index ac3c877a..29a3943e 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -41,6 +41,7 @@ export interface DroidCustomModel { apiKey: string; provider: 'anthropic' | 'openai' | 'generic-chat-completion-api'; maxOutputTokens?: number; + reasoningOverride?: string | number; } export interface DroidManagedModelRef { @@ -64,13 +65,31 @@ interface DroidCustomModelEntry { apiKey: string; provider: string; maxOutputTokens?: number; + extraArgs?: Record; + extra_args?: Record; /** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */ + [key: string]: unknown; } +const DROID_REASONING_OFF_VALUES = new Set(['off', 'none', 'disabled', '0']); +const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record = { + minimal: 4000, + low: 4000, + medium: 12000, + high: 30000, + max: 50000, + xhigh: 64000, + auto: 30000, +}; + function isSupportedProvider(value: string): value is DroidCustomModel['provider'] { return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api'; } +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry { if (!value || typeof value !== 'object') return false; const record = value as Record; @@ -106,6 +125,100 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null { return isDroidCustomModelEntry(value) ? value : null; } +function isReasoningOffValue(value: string | number): boolean { + if (typeof value === 'number') return value <= 0; + const normalized = value.trim().toLowerCase(); + return DROID_REASONING_OFF_VALUES.has(normalized); +} + +function toAnthropicBudget(value: string | number): number { + if (typeof value === 'number') { + return Math.max(1024, Math.floor(value)); + } + + const normalized = value.trim().toLowerCase(); + if (/^\d+$/.test(normalized)) { + return Math.max(1024, Number.parseInt(normalized, 10)); + } + + return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; +} + +function toReasoningEffort(value: string | number): string { + if (typeof value === 'number') { + if (value <= 4000) return 'low'; + if (value <= 12000) return 'medium'; + if (value <= 30000) return 'high'; + if (value <= 50000) return 'max'; + return 'xhigh'; + } + + const normalized = value.trim().toLowerCase(); + if (!normalized) return 'high'; + return normalized; +} + +function applyReasoningOverride( + entry: DroidCustomModelEntry, + provider: DroidCustomModel['provider'], + reasoningOverride: string | number +): void { + const extraArgsKey: 'extraArgs' | 'extra_args' = Object.prototype.hasOwnProperty.call( + entry, + 'extra_args' + ) + ? 'extra_args' + : 'extraArgs'; + const currentExtraArgs = entry[extraArgsKey]; + const extraArgs = isObject(currentExtraArgs) ? { ...currentExtraArgs } : {}; + + // Normalize legacy aliases before writing provider-specific shape. + delete extraArgs.reasoningEffort; + + if (provider === 'anthropic') { + delete extraArgs.reasoning; + delete extraArgs.reasoning_effort; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.thinking; + } else { + const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + thinking.type = 'enabled'; + thinking.budget_tokens = toAnthropicBudget(reasoningOverride); + delete thinking.budgetTokens; + extraArgs.thinking = thinking; + } + } else if (provider === 'openai') { + delete extraArgs.reasoning_effort; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning; + } else { + const reasoning = isObject(extraArgs.reasoning) ? { ...extraArgs.reasoning } : {}; + reasoning.effort = toReasoningEffort(reasoningOverride); + extraArgs.reasoning = reasoning; + } + } else { + delete extraArgs.reasoning; + delete extraArgs.thinking; + + if (isReasoningOffValue(reasoningOverride)) { + delete extraArgs.reasoning_effort; + } else { + extraArgs.reasoning_effort = toReasoningEffort(reasoningOverride); + } + } + + if (Object.keys(extraArgs).length === 0) { + delete entry.extraArgs; + delete entry.extra_args; + return; + } + + entry[extraArgsKey] = extraArgs; +} + function buildSelectorAlias(displayName: string, index: number): string { const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-'); return `${normalizedDisplayName}-${index}`; @@ -331,16 +444,22 @@ export async function upsertCcsModel( const settings = readDroidSettings(); settings.customModels = normalizeCustomModels(settings.customModels); - const entry: DroidCustomModelEntry = { - ...model, - displayName: `CCS ${profile}`, - }; - // Find existing current or legacy entry for this profile. const idx = settings.customModels.findIndex( (m) => parseManagedProfile(m.displayName) === profile ); + const { reasoningOverride, ...modelWithoutReasoning } = model; + const existingEntry = idx >= 0 ? settings.customModels[idx] : undefined; + const entry: DroidCustomModelEntry = { + ...(existingEntry ?? {}), + ...modelWithoutReasoning, + displayName: `CCS ${profile}`, + }; + if (reasoningOverride !== undefined) { + applyReasoningOverride(entry, model.provider, reasoningOverride); + } + if (idx >= 0) { settings.customModels[idx] = entry; } else { diff --git a/src/targets/droid-reasoning-runtime.ts b/src/targets/droid-reasoning-runtime.ts new file mode 100644 index 00000000..c257e329 --- /dev/null +++ b/src/targets/droid-reasoning-runtime.ts @@ -0,0 +1,56 @@ +import { parseThinkingOverride, type ThinkingFlag } from '../cliproxy/executor/thinking-arg-parser'; +import { resolveRuntimeThinkingOverride } from '../cliproxy/executor/thinking-override-resolver'; + +export class DroidReasoningFlagError extends Error { + constructor( + message: string, + public readonly flag: ThinkingFlag + ) { + super(message); + this.name = 'DroidReasoningFlagError'; + } +} + +export interface DroidReasoningRuntime { + argsWithoutReasoningFlags: string[]; + reasoningOverride: string | number | undefined; + sourceFlag: ThinkingFlag | undefined; + sourceDisplay: string | undefined; + duplicateDisplays: string[]; +} + +function stripReasoningFlags(args: string[]): string[] { + return args.filter((arg, idx) => { + if (arg === '--thinking' || arg === '--effort') return false; + if (arg.startsWith('--thinking=')) return false; + if (arg.startsWith('--effort=')) return false; + if (args[idx - 1] === '--thinking' || args[idx - 1] === '--effort') return false; + return true; + }); +} + +export function resolveDroidReasoningRuntime( + args: string[], + envThinkingValue: string | undefined +): DroidReasoningRuntime { + const parseResult = parseThinkingOverride(args); + if (parseResult.error) { + throw new DroidReasoningFlagError( + `${parseResult.error.flag} requires a value`, + parseResult.error.flag + ); + } + + const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride( + parseResult.value, + envThinkingValue + ); + + return { + argsWithoutReasoningFlags: stripReasoningFlags(args), + reasoningOverride: thinkingOverride, + sourceFlag: thinkingSource === 'flag' ? parseResult.sourceFlag : undefined, + sourceDisplay: parseResult.sourceDisplay, + duplicateDisplays: parseResult.duplicateDisplays, + }; +} diff --git a/src/targets/target-adapter.ts b/src/targets/target-adapter.ts index 522ee905..ffe4b890 100644 --- a/src/targets/target-adapter.ts +++ b/src/targets/target-adapter.ts @@ -23,6 +23,12 @@ export interface TargetCredentials { apiKey: string; model?: string; provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api'; + /** + * Runtime reasoning/thinking override resolved from CCS flags/env + * (e.g. --thinking high, --effort xhigh, CCS_THINKING=medium). + * Targets may ignore this when unsupported. + */ + reasoningOverride?: string | number; /** Additional env vars from profile resolution (websearch, hooks, etc.) */ envVars?: NodeJS.ProcessEnv; } diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index 551fa978..ac3fc862 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -42,8 +42,10 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record)', + 'Provider-specific reasoning keys in extraArgs: generic-chat-completion-api => reasoning_effort, openai => reasoning.effort, anthropic => thinking.{type,budget_tokens}', 'droid exec supports --model for one-off execution mode', ], links: [ diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts index ad960f21..e061e022 100644 --- a/src/web-server/services/droid-dashboard-service.ts +++ b/src/web-server/services/droid-dashboard-service.ts @@ -42,10 +42,18 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function asObject(value: unknown): Record | null { + return isObject(value) ? value : null; +} + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + function parseHost(value: string): string | null { try { return new URL(value).host || null; @@ -118,11 +126,11 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo continue; } - const displayName = asString(item.displayName); + const displayName = asString(item.displayName) ?? asString(item.model_display_name); const model = asString(item.model); - const baseUrl = asString(item.baseUrl); + const baseUrl = asString(item.baseUrl) ?? asString(item.base_url); const providerRaw = asString(item.provider); - const apiKey = asString(item.apiKey); + const apiKey = asString(item.apiKey) ?? asString(item.api_key); if (!displayName || !model || !baseUrl || !providerRaw) { invalidModelEntryCount += 1; @@ -138,7 +146,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo provider, baseUrl, host: parseHost(baseUrl), - maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + maxOutputTokens: asNumber(item.maxOutputTokens) ?? asNumber(item.max_tokens), isCcsManaged: isCcsManagedDisplayName(displayName), apiKeyState: apiKey ? 'set' : 'missing', apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, @@ -158,6 +166,25 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo }; } +function resolveCustomModelsValue(settings: Record | null): unknown { + if (!settings) return undefined; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return modern; + + const legacy = settings.custom_models; + if (Array.isArray(legacy) || isObject(legacy)) return legacy; + return undefined; +} + +function usesLegacyCustomModelsKey(settings: Record | null): boolean { + if (!settings) return false; + const modern = settings.customModels; + if (Array.isArray(modern) || isObject(modern)) return false; + + const legacy = settings.custom_models; + return Array.isArray(legacy) || isObject(legacy); +} + export async function getDroidDashboardDiagnostics(): Promise { const paths = resolveDroidConfigPaths(); const binaryPath = detectDroidCli(); @@ -176,7 +203,11 @@ export async function getDroidDashboardDiagnostics(): Promise { expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318'); }); + it('should persist generic provider reasoning_effort from override', async () => { + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'high', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.reasoning_effort).toBe('high'); + expect(settings.customModels[0].extraArgs?.reasoning).toBeUndefined(); + expect(settings.customModels[0].extraArgs?.thinking).toBeUndefined(); + }); + + it('should persist openai provider reasoning.effort from --effort alias override', async () => { + await upsertCcsModel('codex', { + model: 'gpt-5.2', + displayName: 'CCS codex', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'openai-key', + provider: 'openai', + reasoningOverride: 'xhigh', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.reasoning?.effort).toBe('xhigh'); + expect(settings.customModels[0].extraArgs?.reasoning_effort).toBeUndefined(); + }); + + it('should persist anthropic thinking budget from numeric override', async () => { + await upsertCcsModel('agy', { + model: 'claude-opus-4-5-thinking', + displayName: 'CCS agy', + baseUrl: 'https://api.anthropic.com', + apiKey: 'anthropic-key', + provider: 'anthropic', + reasoningOverride: 40960, + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('enabled'); + expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960); + }); + + it('should clear prior reasoning config when override disables thinking', async () => { + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'high', + }); + + await upsertCcsModel('glm', { + model: 'glm-4.7', + displayName: 'CCS glm', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + apiKey: 'glm-key', + provider: 'generic-chat-completion-api', + reasoningOverride: 'off', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs).toBeUndefined(); + }); + it('should preserve user entries', async () => { // Create existing settings with user's own custom model const factoryDir = path.join(tmpDir, '.factory'); diff --git a/tests/unit/targets/droid-reasoning-runtime.test.ts b/tests/unit/targets/droid-reasoning-runtime.test.ts new file mode 100644 index 00000000..445f4949 --- /dev/null +++ b/tests/unit/targets/droid-reasoning-runtime.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { + DroidReasoningFlagError, + resolveDroidReasoningRuntime, +} from '../../../src/targets/droid-reasoning-runtime'; + +describe('droid-reasoning-runtime', () => { + it('extracts --thinking and strips CCS reasoning flags from args', () => { + const runtime = resolveDroidReasoningRuntime(['--thinking', 'high', '--verbose'], undefined); + + expect(runtime.reasoningOverride).toBe('high'); + expect(runtime.sourceFlag).toBe('--thinking'); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']); + }); + + it('extracts --effort alias and strips inline value', () => { + const runtime = resolveDroidReasoningRuntime(['--effort=xhigh', '--help'], undefined); + + expect(runtime.reasoningOverride).toBe('xhigh'); + expect(runtime.sourceFlag).toBe('--effort'); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--help']); + }); + + it('uses CCS_THINKING env fallback when no flag is provided', () => { + const runtime = resolveDroidReasoningRuntime(['--verbose'], 'medium'); + + expect(runtime.reasoningOverride).toBe('medium'); + expect(runtime.sourceFlag).toBeUndefined(); + expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']); + }); + + it('throws on missing reasoning flag value', () => { + expect(() => resolveDroidReasoningRuntime(['--thinking'], undefined)).toThrow( + DroidReasoningFlagError + ); + }); +}); diff --git a/tests/unit/targets/target-registry.test.ts b/tests/unit/targets/target-registry.test.ts index c20b1bda..046036b6 100644 --- a/tests/unit/targets/target-registry.test.ts +++ b/tests/unit/targets/target-registry.test.ts @@ -191,6 +191,31 @@ describe('DroidAdapter', () => { } }); + it('prepareCredentials should persist reasoning override into Droid extraArgs', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-reasoning-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + + try { + await adapter.prepareCredentials({ + profile: 'codex', + baseUrl: 'https://api.openai.com/v1', + apiKey: 'dummy-key', + model: 'gpt-5.2', + provider: 'openai', + reasoningOverride: 'high', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels?.[0]?.extraArgs?.reasoning?.effort).toBe('high'); + } finally { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('buildArgs should use selector returned from Droid settings entry', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-')); const originalCcsHome = process.env.CCS_HOME; diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts index 50ebbe2a..2396422e 100644 --- a/tests/unit/web-server/droid-dashboard-service.test.ts +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -89,6 +89,26 @@ describe('droid-dashboard-service', () => { expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); }); + it('supports legacy snake_case model fields in summaries', () => { + const summary = summarizeDroidCustomModels([ + { + model_display_name: 'Kimi K2 Thinking Nvidia', + model: 'moonshotai/kimi-k2-thinking', + base_url: 'https://integrate.api.nvidia.com/v1', + api_key: 'legacy-token-1234', + provider: 'generic-chat-completion-api', + max_tokens: 220000, + }, + ]); + + expect(summary.customModelCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(0); + expect(summary.providerBreakdown['generic-chat-completion-api']).toBe(1); + expect(summary.customModels[0].displayName).toBe('Kimi K2 Thinking Nvidia'); + expect(summary.customModels[0].maxOutputTokens).toBe(220000); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + it('returns raw settings payload for missing settings file', async () => { const raw = await getDroidRawSettings(); @@ -124,6 +144,58 @@ describe('droid-dashboard-service', () => { ).toBe(true); }); + it('falls back to legacy config custom_models when settings customModels is absent', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ model: 'custom:legacy' })); + fs.writeFileSync( + path.join(settingsDir, 'config.json'), + JSON.stringify({ + custom_models: [ + { + model_display_name: 'Legacy OpenAI', + model: 'gpt-5.2', + base_url: 'https://api.openai.com/v1', + api_key: 'legacy-openai-1234', + provider: 'openai', + }, + ], + }) + ); + + const diagnostics = await getDroidDashboardDiagnostics(); + + expect(diagnostics.byok.customModelCount).toBe(1); + expect(diagnostics.byok.customModels[0].displayName).toBe('Legacy OpenAI'); + expect(diagnostics.byok.customModels[0].provider).toBe('openai'); + }); + + it('warns when settings.json uses legacy custom_models key', async () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, 'settings.json'), + JSON.stringify({ + custom_models: [ + { + model_display_name: 'Legacy Generic', + model: 'glm-4.7', + base_url: 'https://api.z.ai/api/coding/paas/v4', + api_key: 'legacy-zai-1234', + provider: 'generic-chat-completion-api', + }, + ], + }) + ); + + const diagnostics = await getDroidDashboardDiagnostics(); + + expect( + diagnostics.warnings.some((warning) => warning.includes('legacy "custom_models" key')) + ).toBe(true); + expect(diagnostics.byok.customModelCount).toBe(1); + }); + it('saves valid raw settings content', async () => { const result = await saveDroidRawSettings({ rawText: JSON.stringify({ diff --git a/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx new file mode 100644 index 00000000..c65b9390 --- /dev/null +++ b/ui/src/components/compatible-cli/droid-byok-reasoning-controls-card.tsx @@ -0,0 +1,134 @@ +import { BrainCircuit } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + DROID_REASONING_EFFORT_OPTIONS, + type DroidByokModelView, +} from '@/lib/droid-byok-custom-models'; + +const UNSET_VALUE = '__unset__'; + +interface DroidByokReasoningControlsCardProps { + models: DroidByokModelView[]; + disabled: boolean; + disabledReason?: string | null; + onEffortChange: (modelId: string, effort: string | null) => void; + onAnthropicBudgetChange: (modelId: string, budgetTokens: number | null) => void; +} + +function getReasoningPathHint(model: DroidByokModelView): string { + if (model.providerKind === 'openai') return 'Writes: extraArgs.reasoning.effort'; + if (model.providerKind === 'anthropic') { + return 'Writes: extraArgs.thinking.{type,budget_tokens}'; + } + return 'Writes: extraArgs.reasoning_effort'; +} + +export function DroidByokReasoningControlsCard({ + models, + disabled, + disabledReason, + onEffortChange, + onAnthropicBudgetChange, +}: DroidByokReasoningControlsCardProps) { + return ( + + + + + BYOK Reasoning / Thinking + + customModels + + + + + {disabledReason &&

{disabledReason}

} + + {models.length === 0 ? ( +

+ No BYOK custom models found in settings.json (`customModels` or `custom_models`). +

+ ) : ( +
+ {models.map((model) => ( +
+
+
+

{model.displayName}

+

+ {model.model || '(missing model id)'} +

+
+ + {model.provider} + +
+ +
+
+

Reasoning Effort

+ +
+ + {model.providerKind === 'anthropic' && ( +
+

Thinking Budget Tokens

+ { + const raw = event.target.value.trim(); + if (!raw) { + onAnthropicBudgetChange(model.id, null); + return; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) return; + onAnthropicBudgetChange(model.id, Math.max(1024, parsed)); + }} + /> +
+ )} +
+ +

{getReasoningPathHint(model)}

+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/src/lib/droid-byok-custom-models.ts b/ui/src/lib/droid-byok-custom-models.ts new file mode 100644 index 00000000..c454dcdb --- /dev/null +++ b/ui/src/lib/droid-byok-custom-models.ts @@ -0,0 +1,377 @@ +type DroidCustomModelRootKey = 'customModels' | 'custom_models'; +type DroidCustomModelLocationType = 'array' | 'object'; + +export type DroidByokProviderKind = + | 'anthropic' + | 'openai' + | 'generic-chat-completion-api' + | 'unknown'; + +const DROID_CUSTOM_MODEL_ROOT_KEYS: DroidCustomModelRootKey[] = ['customModels', 'custom_models']; +const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record = { + low: 4000, + medium: 12000, + high: 30000, + max: 50000, + xhigh: 64000, +}; + +export const DROID_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'max', 'xhigh'] as const; + +export interface DroidByokModelView { + id: string; + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + displayName: string; + model: string; + provider: string; + providerKind: DroidByokProviderKind; + effort: string | null; + anthropicBudgetTokens: number | null; +} + +interface DroidByokModelLookup { + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; +} + +interface ExtractedReasoning { + effort: string | null; + anthropicBudgetTokens: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function normalizeProviderKind(provider: string | null): DroidByokProviderKind { + if (!provider) return 'unknown'; + const normalized = provider.toLowerCase(); + if (normalized === 'anthropic') return 'anthropic'; + if (normalized === 'openai') return 'openai'; + if (normalized === 'generic-chat-completion-api') return 'generic-chat-completion-api'; + return 'unknown'; +} + +function buildModelId( + rootKey: DroidCustomModelRootKey, + locationType: DroidCustomModelLocationType, + locationKey: number | string +): string { + return `${rootKey}:${locationType}:${encodeURIComponent(String(locationKey))}`; +} + +function parseModelId(modelId: string): DroidByokModelLookup | null { + const firstSeparator = modelId.indexOf(':'); + const secondSeparator = modelId.indexOf(':', firstSeparator + 1); + if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1) return null; + + const rootKey = modelId.slice(0, firstSeparator); + const locationType = modelId.slice(firstSeparator + 1, secondSeparator); + const encodedLocation = modelId.slice(secondSeparator + 1); + + if (rootKey !== 'customModels' && rootKey !== 'custom_models') return null; + if (locationType !== 'array' && locationType !== 'object') return null; + + const locationValue = decodeURIComponent(encodedLocation); + if (locationType === 'array') { + const parsedIndex = Number.parseInt(locationValue, 10); + if (!Number.isInteger(parsedIndex) || parsedIndex < 0) return null; + return { + rootKey, + locationType, + locationKey: parsedIndex, + }; + } + + return { + rootKey, + locationType, + locationKey: locationValue, + }; +} + +function inferEffortFromAnthropicBudget(budgetTokens: number | null): string | null { + if (!budgetTokens || budgetTokens <= 0) return null; + if (budgetTokens <= 4000) return 'low'; + if (budgetTokens <= 12000) return 'medium'; + if (budgetTokens <= 30000) return 'high'; + if (budgetTokens <= 50000) return 'max'; + return 'xhigh'; +} + +function resolveExtraArgsKey(modelEntry: Record): 'extraArgs' | 'extra_args' { + if (Object.prototype.hasOwnProperty.call(modelEntry, 'extraArgs')) { + return 'extraArgs'; + } + if (Object.prototype.hasOwnProperty.call(modelEntry, 'extra_args')) { + return 'extra_args'; + } + return 'extraArgs'; +} + +function cloneSettings(settings: Record): Record { + return JSON.parse(JSON.stringify(settings)) as Record; +} + +function listEntryRecords(settings: Record): Array<{ + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + entry: Record; +}> { + const rows: Array<{ + rootKey: DroidCustomModelRootKey; + locationType: DroidCustomModelLocationType; + locationKey: number | string; + entry: Record; + }> = []; + + for (const rootKey of DROID_CUSTOM_MODEL_ROOT_KEYS) { + const container = settings[rootKey]; + if (Array.isArray(container)) { + container.forEach((item, index) => { + if (isRecord(item)) { + rows.push({ rootKey, locationType: 'array', locationKey: index, entry: item }); + } + }); + continue; + } + + if (!isRecord(container)) continue; + + for (const [objectKey, item] of Object.entries(container)) { + if (isRecord(item)) { + rows.push({ rootKey, locationType: 'object', locationKey: objectKey, entry: item }); + } + } + } + + return rows; +} + +function lookupEntryById( + settings: Record, + modelId: string +): { entry: Record; providerKind: DroidByokProviderKind } | null { + const parsed = parseModelId(modelId); + if (!parsed) return null; + + const container = settings[parsed.rootKey]; + + if (parsed.locationType === 'array') { + if (!Array.isArray(container)) return null; + const item = container[parsed.locationKey as number]; + if (!isRecord(item)) return null; + + const provider = asNonEmptyString(item.provider); + return { entry: item, providerKind: normalizeProviderKind(provider) }; + } + + if (!isRecord(container)) return null; + const item = container[parsed.locationKey as string]; + if (!isRecord(item)) return null; + + const provider = asNonEmptyString(item.provider); + return { entry: item, providerKind: normalizeProviderKind(provider) }; +} + +function extractReasoningDetails( + providerKind: DroidByokProviderKind, + modelEntry: Record +): ExtractedReasoning { + const extraArgsCandidate = modelEntry.extraArgs ?? modelEntry.extra_args; + const extraArgs = isRecord(extraArgsCandidate) ? extraArgsCandidate : null; + if (!extraArgs) { + return { effort: null, anthropicBudgetTokens: null }; + } + + const flatReasoningEffort = + asNonEmptyString(extraArgs.reasoning_effort) ?? asNonEmptyString(extraArgs.reasoningEffort); + const reasoningConfig = isRecord(extraArgs.reasoning) ? extraArgs.reasoning : null; + const nestedReasoningEffort = reasoningConfig ? asNonEmptyString(reasoningConfig.effort) : null; + const thinkingConfig = isRecord(extraArgs.thinking) ? extraArgs.thinking : null; + const thinkingType = thinkingConfig ? asNonEmptyString(thinkingConfig.type) : null; + const anthropicBudgetTokens = thinkingConfig + ? (asFiniteNumber(thinkingConfig.budget_tokens) ?? asFiniteNumber(thinkingConfig.budgetTokens)) + : null; + + if (providerKind === 'openai') { + return { + effort: nestedReasoningEffort ?? flatReasoningEffort, + anthropicBudgetTokens: null, + }; + } + + if (providerKind === 'anthropic') { + if (thinkingType === 'enabled') { + return { + effort: inferEffortFromAnthropicBudget(anthropicBudgetTokens) ?? 'high', + anthropicBudgetTokens, + }; + } + return { + effort: nestedReasoningEffort ?? flatReasoningEffort, + anthropicBudgetTokens, + }; + } + + return { + effort: flatReasoningEffort ?? nestedReasoningEffort, + anthropicBudgetTokens: null, + }; +} + +function sanitizeEffortInput(value: string | null): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === 'default' || normalized === 'unset') return null; + if (normalized === 'off' || normalized === 'none' || normalized === 'disabled') return null; + return normalized; +} + +function ensureExtraArgs(entry: Record): { + extraArgsKey: 'extraArgs' | 'extra_args'; + extraArgs: Record; +} { + const extraArgsKey = resolveExtraArgsKey(entry); + const currentExtraArgs = entry[extraArgsKey]; + const nextExtraArgs = isRecord(currentExtraArgs) ? { ...currentExtraArgs } : {}; + return { extraArgsKey, extraArgs: nextExtraArgs }; +} + +function commitExtraArgs( + entry: Record, + extraArgsKey: 'extraArgs' | 'extra_args', + extraArgs: Record +): void { + if (Object.keys(extraArgs).length === 0) { + delete entry[extraArgsKey]; + return; + } + entry[extraArgsKey] = extraArgs; +} + +export function extractDroidByokModels(settings: Record): DroidByokModelView[] { + return listEntryRecords(settings).map(({ rootKey, locationType, locationKey, entry }) => { + const displayName = + asNonEmptyString(entry.displayName) ?? + asNonEmptyString(entry.model_display_name) ?? + 'Unnamed model'; + const model = asNonEmptyString(entry.model) ?? ''; + const provider = asNonEmptyString(entry.provider) ?? 'unknown'; + const providerKind = normalizeProviderKind(provider); + const reasoning = extractReasoningDetails(providerKind, entry); + + return { + id: buildModelId(rootKey, locationType, locationKey), + rootKey, + locationType, + locationKey, + displayName, + model, + provider, + providerKind, + effort: reasoning.effort, + anthropicBudgetTokens: reasoning.anthropicBudgetTokens, + }; + }); +} + +export function applyReasoningEffortToDroidByokModel( + settings: Record, + modelId: string, + effort: string | null +): Record | null { + const nextSettings = cloneSettings(settings); + const target = lookupEntryById(nextSettings, modelId); + if (!target) return null; + + const normalizedEffort = sanitizeEffortInput(effort); + const { extraArgsKey, extraArgs } = ensureExtraArgs(target.entry); + + if (target.providerKind === 'openai') { + delete extraArgs.reasoning_effort; + delete extraArgs.reasoningEffort; + + if (!normalizedEffort) { + delete extraArgs.reasoning; + } else { + const existingReasoning = isRecord(extraArgs.reasoning) ? extraArgs.reasoning : {}; + extraArgs.reasoning = { + ...existingReasoning, + effort: normalizedEffort, + }; + } + } else if (target.providerKind === 'anthropic') { + delete extraArgs.reasoning_effort; + delete extraArgs.reasoningEffort; + delete extraArgs.reasoning; + + if (!normalizedEffort) { + delete extraArgs.thinking; + } else { + const existingThinking = isRecord(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + const existingBudget = + asFiniteNumber(existingThinking.budget_tokens) ?? + asFiniteNumber(existingThinking.budgetTokens); + + delete existingThinking.budgetTokens; + extraArgs.thinking = { + ...existingThinking, + type: 'enabled', + budget_tokens: + existingBudget ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalizedEffort] ?? 30000, + }; + } + } else { + delete extraArgs.reasoning; + delete extraArgs.reasoningEffort; + + if (!normalizedEffort) { + delete extraArgs.reasoning_effort; + } else { + extraArgs.reasoning_effort = normalizedEffort; + } + } + + commitExtraArgs(target.entry, extraArgsKey, extraArgs); + return nextSettings; +} + +export function applyAnthropicBudgetTokensToDroidByokModel( + settings: Record, + modelId: string, + budgetTokens: number | null +): Record | null { + const nextSettings = cloneSettings(settings); + const target = lookupEntryById(nextSettings, modelId); + if (!target || target.providerKind !== 'anthropic') return null; + + const { extraArgsKey, extraArgs } = ensureExtraArgs(target.entry); + const thinking = isRecord(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + thinking.type = 'enabled'; + + if (budgetTokens === null) { + delete thinking.budget_tokens; + delete thinking.budgetTokens; + } else { + const normalizedBudget = Math.max(1024, Math.floor(budgetTokens)); + thinking.budget_tokens = normalizedBudget; + delete thinking.budgetTokens; + } + + extraArgs.thinking = thinking; + commitExtraArgs(target.entry, extraArgsKey, extraArgs); + return nextSettings; +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx index 5bef6e73..b3ded797 100644 --- a/ui/src/pages/droid.tsx +++ b/ui/src/pages/droid.tsx @@ -16,6 +16,7 @@ import { import { useDroid } from '@/hooks/use-droid'; import { isApiConflictError } from '@/lib/api-client'; import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel'; +import { DroidByokReasoningControlsCard } from '@/components/compatible-cli/droid-byok-reasoning-controls-card'; import { DroidSettingsQuickControlsCard, type DroidQuickSettingsValues, @@ -25,6 +26,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; +import { + applyAnthropicBudgetTokensToDroidByokModel, + applyReasoningEffortToDroidByokModel, + extractDroidByokModels, +} from '@/lib/droid-byok-custom-models'; const DEFAULT_DROID_FACTORY_DOC_LINKS = [ { @@ -188,6 +194,10 @@ export function DroidPage() { setRawDraftText(nextText); }; + const updateSettingsObject = (nextSettings: Record) => { + setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + }; + const updateSettingsField = (key: string, value: unknown | null) => { if (!rawEditorParsed.valid) { toast.error('Fix JSON syntax before using quick settings controls.'); @@ -200,7 +210,7 @@ export function DroidPage() { } else { nextSettings[key] = value; } - setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n'); + updateSettingsObject(nextSettings); }; const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid @@ -229,6 +239,8 @@ export function DroidPage() { soundEnabled: null, }; + const byokModels = rawEditorParsed.valid ? extractDroidByokModels(rawEditorParsed.value) : []; + const refreshAll = async () => { await Promise.all([refetchDiagnostics(), refetchRawSettings()]); }; @@ -383,6 +395,52 @@ export function DroidPage() { }} /> + { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating BYOK reasoning settings.'); + return; + } + + const nextSettings = applyReasoningEffortToDroidByokModel( + rawEditorParsed.value, + modelId, + effort + ); + if (!nextSettings) { + toast.error('Unable to update selected BYOK model reasoning setting.'); + return; + } + + updateSettingsObject(nextSettings); + }} + onAnthropicBudgetChange={(modelId, budgetTokens) => { + if (!rawEditorParsed.valid) { + toast.error('Fix JSON syntax before updating thinking budget.'); + return; + } + + const nextSettings = applyAnthropicBudgetTokensToDroidByokModel( + rawEditorParsed.value, + modelId, + budgetTokens + ); + if (!nextSettings) { + toast.error('Thinking budget is only available for Anthropic BYOK models.'); + return; + } + + updateSettingsObject(nextSettings); + }} + /> + diff --git a/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts b/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts new file mode 100644 index 00000000..7dea96d5 --- /dev/null +++ b/ui/tests/unit/ui/lib/droid-byok-custom-models.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { + applyAnthropicBudgetTokensToDroidByokModel, + applyReasoningEffortToDroidByokModel, + extractDroidByokModels, +} from '@/lib/droid-byok-custom-models'; + +describe('extractDroidByokModels', () => { + it('extracts modern and legacy custom model key styles', () => { + const settings = { + customModels: [ + { + displayName: 'GPT-5.2 High', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: { + reasoning: { effort: 'high' }, + }, + }, + ], + custom_models: [ + { + model_display_name: 'GLM Legacy', + model: 'glm-4.7', + provider: 'generic-chat-completion-api', + extraArgs: { + reasoning_effort: 'medium', + }, + }, + ], + }; + + const models = extractDroidByokModels(settings); + + expect(models).toHaveLength(2); + expect(models[0].displayName).toBe('GPT-5.2 High'); + expect(models[0].effort).toBe('high'); + expect(models[1].displayName).toBe('GLM Legacy'); + expect(models[1].effort).toBe('medium'); + }); + + it('infers anthropic effort from thinking budget tokens', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Thinking', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: { + thinking: { + type: 'enabled', + budget_tokens: 30000, + }, + }, + }, + ], + }; + + const models = extractDroidByokModels(settings); + + expect(models).toHaveLength(1); + expect(models[0].providerKind).toBe('anthropic'); + expect(models[0].effort).toBe('high'); + expect(models[0].anthropicBudgetTokens).toBe(30000); + }); +}); + +describe('applyReasoningEffortToDroidByokModel', () => { + it('updates generic provider to reasoning_effort', () => { + const settings = { + customModels: [ + { + displayName: 'GLM Profile', + model: 'glm-4.7', + provider: 'generic-chat-completion-api', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + expect((updated.extraArgs as Record).reasoning_effort).toBe('high'); + }); + + it('updates openai provider to reasoning.effort', () => { + const settings = { + customModels: [ + { + displayName: 'GPT Profile', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const extraArgs = updated.extraArgs as Record; + expect((extraArgs.reasoning as Record).effort).toBe('high'); + expect(extraArgs.reasoning_effort).toBeUndefined(); + }); + + it('updates anthropic provider to thinking config with budget', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Profile', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyReasoningEffortToDroidByokModel(settings, modelId, 'high'); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const thinking = ((updated.extraArgs as Record).thinking ?? {}) as Record< + string, + unknown + >; + expect(thinking.type).toBe('enabled'); + expect(thinking.budget_tokens).toBe(30000); + }); +}); + +describe('applyAnthropicBudgetTokensToDroidByokModel', () => { + it('sets anthropic thinking budget tokens', () => { + const settings = { + customModels: [ + { + displayName: 'Claude Profile', + model: 'claude-opus-4.5-thinking', + provider: 'anthropic', + extraArgs: { + thinking: { type: 'enabled', budget_tokens: 30000 }, + }, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyAnthropicBudgetTokensToDroidByokModel(settings, modelId, 40960); + + expect(next).not.toBeNull(); + const updated = (next as { customModels: Array> }).customModels[0]; + const thinking = ((updated.extraArgs as Record).thinking ?? {}) as Record< + string, + unknown + >; + expect(thinking.type).toBe('enabled'); + expect(thinking.budget_tokens).toBe(40960); + }); + + it('returns null for non-anthropic providers', () => { + const settings = { + customModels: [ + { + displayName: 'GPT Profile', + model: 'gpt-5.2', + provider: 'openai', + extraArgs: {}, + }, + ], + }; + const modelId = extractDroidByokModels(settings)[0].id; + + const next = applyAnthropicBudgetTokensToDroidByokModel(settings, modelId, 40960); + + expect(next).toBeNull(); + }); +});