1import {
2 type ModelCapabilities,
3 ProviderDriverKind,
4 type ProviderOptionDescriptor,
5} from "@t3tools/contracts";
6import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model";
7
8/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */
9export interface EditorChoice {
10 readonly key: string;
11 readonly id: string;
12 readonly label: string;
13 readonly isDefault: boolean;
14 readonly description?: string;
15}
16
17/** Editable mirror of a `ProviderOptionDescriptor`. `key` is only a React key. */
18export interface EditorDescriptor {
19 readonly key: string;
20 readonly type: "select" | "boolean";
21 readonly id: string;
22 readonly label: string;
23 readonly choices: ReadonlyArray<EditorChoice>;
24 readonly currentBooleanValue?: boolean | undefined;
25 readonly description?: string | undefined;
26}
27
28export interface CustomModelDraft {
29 readonly slug: string;
30 readonly name: string;
31 readonly descriptors: ReadonlyArray<EditorDescriptor>;
32}
33
34export interface DescriptorPreset {
35 readonly id: string;
36 readonly label: string;
37 readonly type: "select" | "boolean";
38 readonly choices?: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>;
39}
40
41const EFFORT_CHOICES = [
42 { id: "low", label: "Low" },
43 { id: "medium", label: "Medium", isDefault: true },
44 { id: "high", label: "High" },
45 { id: "xhigh", label: "Extra High" },
46] as const;
47
48/**
49 * Option ids each adapter actually reads off a turn's model selection, with
50 * the usual choices pre-filled. Anything else the user types is stored
51 * verbatim but will be ignored by the driver.
52 */
53export const DESCRIPTOR_PRESETS_BY_KIND: Partial<
54 Record<ProviderDriverKind, ReadonlyArray<DescriptorPreset>>
55> = {
56 [ProviderDriverKind.make("codex")]: [
57 { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
58 {
59 id: "serviceTier",
60 label: "Speed",
61 type: "select",
62 choices: [
63 { id: "default", label: "Standard", isDefault: true },
64 { id: "fast", label: "Fast" },
65 ],
66 },
67 ],
68 [ProviderDriverKind.make("claudeAgent")]: [
69 {
70 id: "effort",
71 label: "Reasoning",
72 type: "select",
73 choices: [
74 { id: "low", label: "Low" },
75 { id: "medium", label: "Medium" },
76 { id: "high", label: "High", isDefault: true },
77 { id: "xhigh", label: "Extra High" },
78 { id: "max", label: "Max" },
79 ],
80 },
81 { id: "fastMode", label: "Fast Mode", type: "boolean" },
82 { id: "thinking", label: "Thinking", type: "boolean" },
83 ],
84 [ProviderDriverKind.make("cursor")]: [
85 { id: "reasoning", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
86 { id: "fastMode", label: "Fast Mode", type: "boolean" },
87 { id: "thinking", label: "Thinking", type: "boolean" },
88 ],
89 [ProviderDriverKind.make("grok")]: [
90 { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
91 ],
92 [ProviderDriverKind.make("pi")]: [
93 {
94 id: "thinking",
95 label: "Thinking",
96 type: "select",
97 choices: [
98 { id: "off", label: "Off" },
99 { id: "minimal", label: "Minimal" },
100 { id: "low", label: "Low" },
101 { id: "medium", label: "Medium", isDefault: true },
102 { id: "high", label: "High" },
103 { id: "xhigh", label: "Extra High" },
104 { id: "max", label: "Max" },
105 ],
106 },
107 ],
108 [ProviderDriverKind.make("opencode")]: [
109 { id: "variant", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
110 {
111 id: "agent",
112 label: "Agent",
113 type: "select",
114 choices: [
115 { id: "build", label: "Build", isDefault: true },
116 { id: "plan", label: "Plan" },
117 ],
118 },
119 ],
120};
121
122let nextKey = 0;
123function newEditorKey(): string {
124 nextKey += 1;
125 return `k${nextKey}`;
126}
127
128export function choiceFromPreset(choice: {
129 id: string;
130 label: string;
131 isDefault?: boolean;
132}): EditorChoice {
133 return { key: newEditorKey(), id: choice.id, label: choice.label, isDefault: !!choice.isDefault };
134}
135
136export function descriptorFromPreset(preset: DescriptorPreset): EditorDescriptor {
137 return {
138 key: newEditorKey(),
139 type: preset.type,
140 id: preset.id,
141 label: preset.label,
142 choices: (preset.choices ?? []).map(choiceFromPreset),
143 };
144}
145
146export function emptyEditorDescriptor(): EditorDescriptor {
147 return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] };
148}
149
150export function emptyEditorChoice(): EditorChoice {
151 return { key: newEditorKey(), id: "", label: "", isDefault: false };
152}
153
154/**
155 * Prompt-injected choices (Claude's `ultrathink`) are delivered as prompt text
156 * by built-in runtime profiles a custom entry does not have, so they are
157 * dropped rather than stored as a plain option value.
158 */
159function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor {
160 const promptInjected = new Set(
161 descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [],
162 );
163 const choices =
164 descriptor.type === "select"
165 ? descriptor.options.filter((option) => !promptInjected.has(option.id))
166 : [];
167 const defaultChoice =
168 choices.find((option) => option.id === descriptor.currentValue) ??
169 choices.find((option) => option.isDefault);
170 return {
171 key: newEditorKey(),
172 type: descriptor.type,
173 id: descriptor.id,
174 label: descriptor.label,
175 ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
176 ...(descriptor.type === "boolean" && descriptor.currentValue !== undefined
177 ? { currentBooleanValue: descriptor.currentValue }
178 : {}),
179 choices: choices.map((option) => ({
180 key: newEditorKey(),
181 id: option.id,
182 label: option.label,
183 ...(option.description !== undefined ? { description: option.description } : {}),
184 isDefault: option === defaultChoice,
185 })),
186 };
187}
188
189export function draftFromDefinition(entry: CustomModelDefinition): CustomModelDraft {
190 return {
191 slug: entry.slug,
192 name: entry.name === entry.slug ? "" : entry.name,
193 descriptors: (entry.capabilities?.optionDescriptors ?? []).map(descriptorToEditor),
194 };
195}
196
197/** Claude context choices require runtime suffix mappings that custom entries do not carry. */
198export function descriptorsFromCapabilities(
199 capabilities: ModelCapabilities | null | undefined,
200 driverKind: ProviderDriverKind | null,
201): EditorDescriptor[] {
202 return (capabilities?.optionDescriptors ?? [])
203 .filter((descriptor) => driverKind !== "claudeAgent" || descriptor.id !== "contextWindow")
204 .map(descriptorToEditor);
205}
206
207/**
208 * Validate the draft before saving. Returns the first problem in reading
209 * order so the message is actionable, or `null` when the draft is sound.
210 */
211export function validateDraft(draft: CustomModelDraft): string | null {
212 const seenIds = new Set<string>();
213 for (const [index, descriptor] of draft.descriptors.entries()) {
214 const position = `Option ${index + 1}`;
215 const id = descriptor.id.trim();
216 if (!id) return `${position} needs an id.`;
217 if (seenIds.has(id)) return `${position}: id "${id}" is used twice.`;
218 seenIds.add(id);
219 if (!descriptor.label.trim()) return `${position} needs a label.`;
220 if (descriptor.type !== "select") continue;
221 if (descriptor.choices.length === 0) return `${position} needs at least one choice.`;
222 const seenChoices = new Set<string>();
223 for (const choice of descriptor.choices) {
224 const choiceId = choice.id.trim();
225 if (!choiceId) return `${position} has a choice without a value.`;
226 if (seenChoices.has(choiceId)) {
227 return `${position}: choice "${choiceId}" is used twice.`;
228 }
229 seenChoices.add(choiceId);
230 }
231 }
232 return null;
233}
234
235/** Convert a validated draft back into a definition. Blank name → slug. */
236export function definitionFromDraft(draft: CustomModelDraft): CustomModelDefinition {
237 const descriptors: ProviderOptionDescriptor[] = draft.descriptors.map((descriptor) => {
238 const id = descriptor.id.trim();
239 const label = descriptor.label.trim();
240 if (descriptor.type === "boolean") {
241 return {
242 id,
243 label,
244 type: "boolean",
245 ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
246 ...(descriptor.currentBooleanValue !== undefined
247 ? { currentValue: descriptor.currentBooleanValue }
248 : {}),
249 };
250 }
251 const options = descriptor.choices.map((choice) => ({
252 id: choice.id.trim(),
253 label: choice.label.trim() || choice.id.trim(),
254 ...(choice.description !== undefined ? { description: choice.description } : {}),
255 ...(choice.isDefault ? { isDefault: true } : {}),
256 }));
257 const currentValue = options.find((option) => option.isDefault)?.id;
258 return {
259 id,
260 label,
261 type: "select",
262 ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
263 options,
264 ...(currentValue ? { currentValue } : {}),
265 };
266 });
267 const name = draft.name.trim();
268 return {
269 slug: draft.slug,
270 name: name || draft.slug,
271 capabilities:
272 descriptors.length > 0 ? createModelCapabilities({ optionDescriptors: descriptors }) : null,
273 };
274}