mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
feat(droid): sync reasoning effort across CLI and dashboard
This commit is contained in:
+39
-7
@@ -58,6 +58,10 @@ import {
|
|||||||
type TargetCredentials,
|
type TargetCredentials,
|
||||||
} from './targets';
|
} from './targets';
|
||||||
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
||||||
|
import {
|
||||||
|
DroidReasoningFlagError,
|
||||||
|
resolveDroidReasoningRuntime,
|
||||||
|
} from './targets/droid-reasoning-runtime';
|
||||||
|
|
||||||
// Version and Update check utilities
|
// Version and Update check utilities
|
||||||
import { getVersion } from './utils/version';
|
import { getVersion } from './utils/version';
|
||||||
@@ -711,6 +715,32 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 || '<first-flag>'}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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)
|
// Special case: headless delegation (-p/--prompt)
|
||||||
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
||||||
// through normal adapter dispatch logic.
|
// through normal adapter dispatch logic.
|
||||||
@@ -772,14 +802,13 @@ async function main(): Promise<void> {
|
|||||||
'--remote-only',
|
'--remote-only',
|
||||||
'--no-fallback',
|
'--no-fallback',
|
||||||
'--allow-self-signed',
|
'--allow-self-signed',
|
||||||
'--thinking',
|
|
||||||
'--effort',
|
|
||||||
'--1m',
|
'--1m',
|
||||||
'--no-1m',
|
'--no-1m',
|
||||||
];
|
];
|
||||||
const providedUnsupportedFlag = unsupportedCliproxyFlags.find(
|
const providedUnsupportedFlag = unsupportedCliproxyFlags.find(
|
||||||
(flag) =>
|
(flag) =>
|
||||||
remainingArgs.includes(flag) || remainingArgs.some((arg) => arg.startsWith(`${flag}=`))
|
targetRemainingArgs.includes(flag) ||
|
||||||
|
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
|
||||||
);
|
);
|
||||||
if (providedUnsupportedFlag) {
|
if (providedUnsupportedFlag) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -817,7 +846,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const ensureServiceResult = await ensureCliproxyService(
|
const ensureServiceResult = await ensureCliproxyService(
|
||||||
cliproxyPort,
|
cliproxyPort,
|
||||||
remainingArgs.includes('--verbose') || remainingArgs.includes('-v')
|
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
|
||||||
);
|
);
|
||||||
if (!ensureServiceResult.started) {
|
if (!ensureServiceResult.started) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -847,6 +876,7 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
baseUrl: envVars['ANTHROPIC_BASE_URL'],
|
||||||
model: envVars['ANTHROPIC_MODEL'],
|
model: envVars['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
|
reasoningOverride: droidReasoningOverride,
|
||||||
envVars,
|
envVars,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -864,7 +894,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await adapter.prepareCredentials(creds);
|
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);
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
@@ -1020,9 +1050,10 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
|
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
|
||||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
|
reasoningOverride: droidReasoningOverride,
|
||||||
};
|
};
|
||||||
await adapter.prepareCredentials(creds);
|
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);
|
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
@@ -1091,6 +1122,7 @@ async function main(): Promise<void> {
|
|||||||
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
baseUrl: process.env['ANTHROPIC_BASE_URL'],
|
||||||
model: process.env['ANTHROPIC_MODEL'],
|
model: process.env['ANTHROPIC_MODEL'],
|
||||||
}),
|
}),
|
||||||
|
reasoningOverride: droidReasoningOverride,
|
||||||
};
|
};
|
||||||
if (!creds.baseUrl || !creds.apiKey) {
|
if (!creds.baseUrl || !creds.apiKey) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1102,7 +1134,7 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
await adapter.prepareCredentials(creds);
|
await adapter.prepareCredentials(creds);
|
||||||
const targetArgs = adapter.buildArgs('default', remainingArgs);
|
const targetArgs = adapter.buildArgs('default', targetRemainingArgs);
|
||||||
const targetEnv = adapter.buildEnv(creds, 'default');
|
const targetEnv = adapter.buildEnv(creds, 'default');
|
||||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export class DroidAdapter implements TargetAdapter {
|
|||||||
baseUrl: creds.baseUrl,
|
baseUrl: creds.baseUrl,
|
||||||
apiKey: creds.apiKey,
|
apiKey: creds.apiKey,
|
||||||
provider,
|
provider,
|
||||||
|
reasoningOverride: creds.reasoningOverride,
|
||||||
});
|
});
|
||||||
if (!modelRef.selector) {
|
if (!modelRef.selector) {
|
||||||
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
|
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface DroidCustomModel {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
provider: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
provider: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||||
maxOutputTokens?: number;
|
maxOutputTokens?: number;
|
||||||
|
reasoningOverride?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DroidManagedModelRef {
|
export interface DroidManagedModelRef {
|
||||||
@@ -64,13 +65,31 @@ interface DroidCustomModelEntry {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
maxOutputTokens?: number;
|
maxOutputTokens?: number;
|
||||||
|
extraArgs?: Record<string, unknown>;
|
||||||
|
extra_args?: Record<string, unknown>;
|
||||||
/** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */
|
/** 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<string, number> = {
|
||||||
|
minimal: 4000,
|
||||||
|
low: 4000,
|
||||||
|
medium: 12000,
|
||||||
|
high: 30000,
|
||||||
|
max: 50000,
|
||||||
|
xhigh: 64000,
|
||||||
|
auto: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
function isSupportedProvider(value: string): value is DroidCustomModel['provider'] {
|
function isSupportedProvider(value: string): value is DroidCustomModel['provider'] {
|
||||||
return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api';
|
return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry {
|
function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry {
|
||||||
if (!value || typeof value !== 'object') return false;
|
if (!value || typeof value !== 'object') return false;
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
@@ -106,6 +125,100 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null {
|
|||||||
return isDroidCustomModelEntry(value) ? value : 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 {
|
function buildSelectorAlias(displayName: string, index: number): string {
|
||||||
const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-');
|
const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-');
|
||||||
return `${normalizedDisplayName}-${index}`;
|
return `${normalizedDisplayName}-${index}`;
|
||||||
@@ -331,16 +444,22 @@ export async function upsertCcsModel(
|
|||||||
const settings = readDroidSettings();
|
const settings = readDroidSettings();
|
||||||
settings.customModels = normalizeCustomModels(settings.customModels);
|
settings.customModels = normalizeCustomModels(settings.customModels);
|
||||||
|
|
||||||
const entry: DroidCustomModelEntry = {
|
|
||||||
...model,
|
|
||||||
displayName: `CCS ${profile}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find existing current or legacy entry for this profile.
|
// Find existing current or legacy entry for this profile.
|
||||||
const idx = settings.customModels.findIndex(
|
const idx = settings.customModels.findIndex(
|
||||||
(m) => parseManagedProfile(m.displayName) === profile
|
(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) {
|
if (idx >= 0) {
|
||||||
settings.customModels[idx] = entry;
|
settings.customModels[idx] = entry;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ export interface TargetCredentials {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
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.) */
|
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
|
||||||
envVars?: NodeJS.ProcessEnv;
|
envVars?: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
|
|||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
'BYOK custom models are read from ~/.factory/settings.json customModels[]',
|
'BYOK custom models are read from ~/.factory/settings.json customModels[]',
|
||||||
|
'Legacy key style (custom_models, model_display_name, base_url, api_key, max_tokens) remains in circulation',
|
||||||
'Factory docs mention legacy support for ~/.factory/config.json',
|
'Factory docs mention legacy support for ~/.factory/config.json',
|
||||||
'Interactive model selection uses settings.model (custom:<alias>)',
|
'Interactive model selection uses settings.model (custom:<alias>)',
|
||||||
|
'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',
|
'droid exec supports --model for one-off execution mode',
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -42,10 +42,18 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
return isObject(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string | null {
|
function asString(value: unknown): string | null {
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : 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 {
|
function parseHost(value: string): string | null {
|
||||||
try {
|
try {
|
||||||
return new URL(value).host || null;
|
return new URL(value).host || null;
|
||||||
@@ -118,11 +126,11 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = asString(item.displayName);
|
const displayName = asString(item.displayName) ?? asString(item.model_display_name);
|
||||||
const model = asString(item.model);
|
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 providerRaw = asString(item.provider);
|
||||||
const apiKey = asString(item.apiKey);
|
const apiKey = asString(item.apiKey) ?? asString(item.api_key);
|
||||||
|
|
||||||
if (!displayName || !model || !baseUrl || !providerRaw) {
|
if (!displayName || !model || !baseUrl || !providerRaw) {
|
||||||
invalidModelEntryCount += 1;
|
invalidModelEntryCount += 1;
|
||||||
@@ -138,7 +146,7 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo
|
|||||||
provider,
|
provider,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
host: parseHost(baseUrl),
|
host: parseHost(baseUrl),
|
||||||
maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null,
|
maxOutputTokens: asNumber(item.maxOutputTokens) ?? asNumber(item.max_tokens),
|
||||||
isCcsManaged: isCcsManagedDisplayName(displayName),
|
isCcsManaged: isCcsManagedDisplayName(displayName),
|
||||||
apiKeyState: apiKey ? 'set' : 'missing',
|
apiKeyState: apiKey ? 'set' : 'missing',
|
||||||
apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null,
|
apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null,
|
||||||
@@ -158,6 +166,25 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCustomModelsValue(settings: Record<string, unknown> | 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<string, unknown> | 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<DroidDashboardDiagnostics> {
|
export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
||||||
const paths = resolveDroidConfigPaths();
|
const paths = resolveDroidConfigPaths();
|
||||||
const binaryPath = detectDroidCli();
|
const binaryPath = detectDroidCli();
|
||||||
@@ -176,7 +203,11 @@ export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiag
|
|||||||
paths.legacyConfigDisplayPath
|
paths.legacyConfigDisplayPath
|
||||||
);
|
);
|
||||||
|
|
||||||
const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels);
|
const settingsJson = asObject(settingsProbe.json);
|
||||||
|
const legacyJson = asObject(legacyConfigProbe.json);
|
||||||
|
const settingsCustomModels = resolveCustomModelsValue(settingsJson);
|
||||||
|
const legacyCustomModels = resolveCustomModelsValue(legacyJson);
|
||||||
|
const byok = summarizeDroidCustomModels(settingsCustomModels ?? legacyCustomModels);
|
||||||
byok.activeModelSelector = asString(settingsProbe.json?.model);
|
byok.activeModelSelector = asString(settingsProbe.json?.model);
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
@@ -190,6 +221,11 @@ export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiag
|
|||||||
if (legacyConfigProbe.diagnostics.parseError) {
|
if (legacyConfigProbe.diagnostics.parseError) {
|
||||||
warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.');
|
warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.');
|
||||||
}
|
}
|
||||||
|
if (usesLegacyCustomModelsKey(settingsJson)) {
|
||||||
|
warnings.push(
|
||||||
|
'settings.json uses legacy "custom_models" key; prefer "customModels" for forward compatibility.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binary: {
|
binary: {
|
||||||
|
|||||||
@@ -94,6 +94,79 @@ describe('droid-config-manager', () => {
|
|||||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
|
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 () => {
|
it('should preserve user entries', async () => {
|
||||||
// Create existing settings with user's own custom model
|
// Create existing settings with user's own custom model
|
||||||
const factoryDir = path.join(tmpDir, '.factory');
|
const factoryDir = path.join(tmpDir, '.factory');
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
it('buildArgs should use selector returned from Droid settings entry', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-'));
|
||||||
const originalCcsHome = process.env.CCS_HOME;
|
const originalCcsHome = process.env.CCS_HOME;
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ describe('droid-dashboard-service', () => {
|
|||||||
expect(summary.customModels[0].apiKeyPreview).toBe('***1234');
|
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 () => {
|
it('returns raw settings payload for missing settings file', async () => {
|
||||||
const raw = await getDroidRawSettings();
|
const raw = await getDroidRawSettings();
|
||||||
|
|
||||||
@@ -124,6 +144,58 @@ describe('droid-dashboard-service', () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it('saves valid raw settings content', async () => {
|
||||||
const result = await saveDroidRawSettings({
|
const result = await saveDroidRawSettings({
|
||||||
rawText: JSON.stringify({
|
rawText: JSON.stringify({
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<BrainCircuit className="h-4 w-4" />
|
||||||
|
BYOK Reasoning / Thinking
|
||||||
|
<Badge variant="outline" className="text-[10px] font-normal">
|
||||||
|
customModels
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{disabledReason && <p className="text-xs text-amber-600">{disabledReason}</p>}
|
||||||
|
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No BYOK custom models found in settings.json (`customModels` or `custom_models`).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{models.map((model) => (
|
||||||
|
<div key={model.id} className="rounded-md border p-3 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium truncate">{model.displayName}</p>
|
||||||
|
<p className="text-[11px] font-mono text-muted-foreground truncate">
|
||||||
|
{model.model || '(missing model id)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px] shrink-0">
|
||||||
|
{model.provider}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium">Reasoning Effort</p>
|
||||||
|
<Select
|
||||||
|
value={model.effort ?? UNSET_VALUE}
|
||||||
|
onValueChange={(next) =>
|
||||||
|
onEffortChange(model.id, next === UNSET_VALUE ? null : next)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Use provider default" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={UNSET_VALUE}>Use provider default</SelectItem>
|
||||||
|
{DROID_REASONING_EFFORT_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{model.providerKind === 'anthropic' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[11px] font-medium">Thinking Budget Tokens</p>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1024}
|
||||||
|
step={1024}
|
||||||
|
value={model.anthropicBudgetTokens ?? ''}
|
||||||
|
placeholder="auto"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
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));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-muted-foreground">{getReasoningPathHint(model)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, number> = {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>): '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<string, unknown>): Record<string, unknown> {
|
||||||
|
return JSON.parse(JSON.stringify(settings)) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listEntryRecords(settings: Record<string, unknown>): Array<{
|
||||||
|
rootKey: DroidCustomModelRootKey;
|
||||||
|
locationType: DroidCustomModelLocationType;
|
||||||
|
locationKey: number | string;
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
const rows: Array<{
|
||||||
|
rootKey: DroidCustomModelRootKey;
|
||||||
|
locationType: DroidCustomModelLocationType;
|
||||||
|
locationKey: number | string;
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
modelId: string
|
||||||
|
): { entry: Record<string, unknown>; 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<string, unknown>
|
||||||
|
): 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<string, unknown>): {
|
||||||
|
extraArgsKey: 'extraArgs' | 'extra_args';
|
||||||
|
extraArgs: Record<string, unknown>;
|
||||||
|
} {
|
||||||
|
const extraArgsKey = resolveExtraArgsKey(entry);
|
||||||
|
const currentExtraArgs = entry[extraArgsKey];
|
||||||
|
const nextExtraArgs = isRecord(currentExtraArgs) ? { ...currentExtraArgs } : {};
|
||||||
|
return { extraArgsKey, extraArgs: nextExtraArgs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitExtraArgs(
|
||||||
|
entry: Record<string, unknown>,
|
||||||
|
extraArgsKey: 'extraArgs' | 'extra_args',
|
||||||
|
extraArgs: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (Object.keys(extraArgs).length === 0) {
|
||||||
|
delete entry[extraArgsKey];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry[extraArgsKey] = extraArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDroidByokModels(settings: Record<string, unknown>): 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<string, unknown>,
|
||||||
|
modelId: string,
|
||||||
|
effort: string | null
|
||||||
|
): Record<string, unknown> | 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<string, unknown>,
|
||||||
|
modelId: string,
|
||||||
|
budgetTokens: number | null
|
||||||
|
): Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
+59
-1
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useDroid } from '@/hooks/use-droid';
|
import { useDroid } from '@/hooks/use-droid';
|
||||||
import { isApiConflictError } from '@/lib/api-client';
|
import { isApiConflictError } from '@/lib/api-client';
|
||||||
import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||||
|
import { DroidByokReasoningControlsCard } from '@/components/compatible-cli/droid-byok-reasoning-controls-card';
|
||||||
import {
|
import {
|
||||||
DroidSettingsQuickControlsCard,
|
DroidSettingsQuickControlsCard,
|
||||||
type DroidQuickSettingsValues,
|
type DroidQuickSettingsValues,
|
||||||
@@ -25,6 +26,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
applyAnthropicBudgetTokensToDroidByokModel,
|
||||||
|
applyReasoningEffortToDroidByokModel,
|
||||||
|
extractDroidByokModels,
|
||||||
|
} from '@/lib/droid-byok-custom-models';
|
||||||
|
|
||||||
const DEFAULT_DROID_FACTORY_DOC_LINKS = [
|
const DEFAULT_DROID_FACTORY_DOC_LINKS = [
|
||||||
{
|
{
|
||||||
@@ -188,6 +194,10 @@ export function DroidPage() {
|
|||||||
setRawDraftText(nextText);
|
setRawDraftText(nextText);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSettingsObject = (nextSettings: Record<string, unknown>) => {
|
||||||
|
setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n');
|
||||||
|
};
|
||||||
|
|
||||||
const updateSettingsField = (key: string, value: unknown | null) => {
|
const updateSettingsField = (key: string, value: unknown | null) => {
|
||||||
if (!rawEditorParsed.valid) {
|
if (!rawEditorParsed.valid) {
|
||||||
toast.error('Fix JSON syntax before using quick settings controls.');
|
toast.error('Fix JSON syntax before using quick settings controls.');
|
||||||
@@ -200,7 +210,7 @@ export function DroidPage() {
|
|||||||
} else {
|
} else {
|
||||||
nextSettings[key] = value;
|
nextSettings[key] = value;
|
||||||
}
|
}
|
||||||
setRawEditorDraftText(JSON.stringify(nextSettings, null, 2) + '\n');
|
updateSettingsObject(nextSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid
|
const quickSettingsValues: DroidQuickSettingsValues = rawEditorParsed.valid
|
||||||
@@ -229,6 +239,8 @@ export function DroidPage() {
|
|||||||
soundEnabled: null,
|
soundEnabled: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const byokModels = rawEditorParsed.valid ? extractDroidByokModels(rawEditorParsed.value) : [];
|
||||||
|
|
||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
|
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
|
||||||
};
|
};
|
||||||
@@ -383,6 +395,52 @@ export function DroidPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DroidByokReasoningControlsCard
|
||||||
|
models={byokModels}
|
||||||
|
disabled={rawSettingsLoading || !rawEditorParsed.valid}
|
||||||
|
disabledReason={
|
||||||
|
rawEditorParsed.valid
|
||||||
|
? null
|
||||||
|
: `BYOK reasoning controls disabled: ${rawEditorParsed.error}`
|
||||||
|
}
|
||||||
|
onEffortChange={(modelId, effort) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
|||||||
@@ -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<Record<string, unknown>> }).customModels[0];
|
||||||
|
expect((updated.extraArgs as Record<string, unknown>).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<Record<string, unknown>> }).customModels[0];
|
||||||
|
const extraArgs = updated.extraArgs as Record<string, unknown>;
|
||||||
|
expect((extraArgs.reasoning as Record<string, unknown>).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<Record<string, unknown>> }).customModels[0];
|
||||||
|
const thinking = ((updated.extraArgs as Record<string, unknown>).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<Record<string, unknown>> }).customModels[0];
|
||||||
|
const thinking = ((updated.extraArgs as Record<string, unknown>).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user