mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 18:18:21 +00:00
feat(droid): sync reasoning effort across CLI and dashboard
This commit is contained in:
1 parent
1f3db584eb
commit
eedb53b49e
15 files changed
+1228
-18
No files matched your search
@@ -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 { 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<string, unknown>) => {
|
||||
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() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<CardHeader className="pb-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