1import {
2 type CustomModelSetting,
3 MODEL_SLUG_ALIASES_BY_PROVIDER,
4 ModelCapabilities,
5 type ModelSelection,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 type ProviderOptionDescriptor,
9 type ProviderOptionSelection,
10} from "@t3tools/contracts";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
13import { copySorted } from "./Array.ts";
14
15const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
16
17/** Choose the command for a model change against the thread's current provider instance. */
18export function modelSelectionCommandType(
19 currentInstanceId: ProviderInstanceId,
20 selection: ModelSelection,
21) {
22 return currentInstanceId === selection.instanceId
23 ? ("thread.model-selection.set" as const)
24 : ("provider.switch" as const);
25}
26
27export interface SelectableModelOption {
28 slug: string;
29 name: string;
30 aliases?: ReadonlyArray<string> | undefined;
31}
32
33export function createModelCapabilities(input: {
34 optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
35}): ModelCapabilities {
36 return {
37 optionDescriptors: input.optionDescriptors.map(cloneDescriptor),
38 };
39}
40
41function getRawSelectionValueById(
42 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
43 id: string,
44): string | boolean | undefined {
45 const selection = selections?.find((candidate) => candidate.id === id);
46 return selection?.value;
47}
48
49function getProviderOptionSelectionValue(
50 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
51 id: string,
52): string | boolean | undefined {
53 return getRawSelectionValueById(selections, id);
54}
55
56export function getProviderOptionStringSelectionValue(
57 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
58 id: string,
59): string | undefined {
60 const value = getProviderOptionSelectionValue(selections, id);
61 return typeof value === "string" ? value : undefined;
62}
63
64export function getProviderOptionBooleanSelectionValue(
65 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
66 id: string,
67): boolean | undefined {
68 const value = getProviderOptionSelectionValue(selections, id);
69 return typeof value === "boolean" ? value : undefined;
70}
71
72export function getModelSelectionStringOptionValue(
73 modelSelection: ModelSelection | null | undefined,
74 id: string,
75): string | undefined {
76 return getProviderOptionStringSelectionValue(modelSelection?.options, id);
77}
78
79export function getModelSelectionBooleanOptionValue(
80 modelSelection: ModelSelection | null | undefined,
81 id: string,
82): boolean | undefined {
83 return getProviderOptionBooleanSelectionValue(modelSelection?.options, id);
84}
85
86function canonicalModelSelectionOptions(
87 modelSelection: ModelSelection,
88): ReadonlyArray<readonly [id: string, value: string | boolean]> {
89 return copySorted(
90 (modelSelection.options ?? []).map(
91 (selection): readonly [id: string, value: string | boolean] => [
92 selection.id,
93 selection.value,
94 ],
95 ),
96 (
97 [leftId, leftValue]: readonly [id: string, value: string | boolean],
98 [rightId, rightValue]: readonly [id: string, value: string | boolean],
99 ) => {
100 const idOrder = leftId.localeCompare(rightId);
101 return idOrder !== 0 ? idOrder : String(leftValue).localeCompare(String(rightValue));
102 },
103 );
104}
105
106/**
107 * Compares the complete provider selection while treating option ordering and
108 * an omitted empty option list as presentation details.
109 */
110export function modelSelectionsEqual(left: ModelSelection, right: ModelSelection): boolean {
111 if (left.instanceId !== right.instanceId || left.model !== right.model) {
112 return false;
113 }
114 const leftOptions = canonicalModelSelectionOptions(left);
115 const rightOptions = canonicalModelSelectionOptions(right);
116 return (
117 leftOptions.length === rightOptions.length &&
118 leftOptions.every(
119 ([id, value], index) => id === rightOptions[index]?.[0] && value === rightOptions[index]?.[1],
120 )
121 );
122}
123
124function resolveDescriptorChoiceValue(
125 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
126 raw: string | null | undefined,
127): string | undefined {
128 const trimmed = trimOrNull(raw);
129 if (!trimmed) {
130 return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id;
131 }
132 if (descriptor.options.length === 0) {
133 return trimmed;
134 }
135 if (
136 descriptor.promptInjectedValues?.includes(trimmed) &&
137 descriptor.options.some((option) => option.id === trimmed)
138 ) {
139 return descriptor.options.find((option) => option.isDefault)?.id;
140 }
141 if (descriptor.options.some((option) => option.id === trimmed)) {
142 return trimmed;
143 }
144 return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id;
145}
146
147function cloneDescriptor(descriptor: ProviderOptionDescriptor): ProviderOptionDescriptor {
148 return descriptor.type === "select"
149 ? {
150 ...descriptor,
151 options: [...descriptor.options],
152 ...(descriptor.promptInjectedValues
153 ? { promptInjectedValues: [...descriptor.promptInjectedValues] }
154 : {}),
155 }
156 : { ...descriptor };
157}
158
159function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection {
160 return { ...selection };
161}
162
163function withDescriptorCurrentValue(
164 descriptor: ProviderOptionDescriptor,
165 rawCurrentValue: string | boolean | undefined,
166): ProviderOptionDescriptor {
167 if (descriptor.type === "boolean") {
168 if (typeof rawCurrentValue === "boolean") {
169 return {
170 ...descriptor,
171 currentValue: rawCurrentValue,
172 };
173 }
174 return descriptor;
175 }
176 const currentValue =
177 typeof rawCurrentValue === "string"
178 ? resolveDescriptorChoiceValue(descriptor, rawCurrentValue)
179 : resolveDescriptorChoiceValue(descriptor, descriptor.currentValue);
180 if (!currentValue) {
181 const { currentValue: _unusedCurrentValue, ...rest } = descriptor;
182 return rest;
183 }
184 return {
185 ...descriptor,
186 currentValue,
187 };
188}
189
190export function getProviderOptionDescriptors(input: {
191 caps: ModelCapabilities;
192 selections?: ReadonlyArray<ProviderOptionSelection> | null | undefined;
193}): ReadonlyArray<ProviderOptionDescriptor> {
194 const { caps, selections } = input;
195 const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor);
196
197 return baseDescriptors.map((descriptor) =>
198 withDescriptorCurrentValue(
199 descriptor,
200 getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue,
201 ),
202 );
203}
204
205function getReportedOptionValue(
206 id: string,
207 selection?: ModelSelection | null,
208 reportedSelection?: ModelSelection | null,
209) {
210 if (
211 !selection ||
212 !reportedSelection ||
213 selection.instanceId !== reportedSelection.instanceId ||
214 selection.model !== reportedSelection.model ||
215 selection.options?.some((option) => option.id === id)
216 )
217 return undefined;
218 return getRawSelectionValueById(reportedSelection.options, id);
219}
220
221export function getProviderOptionCurrentValue(
222 descriptor: ProviderOptionDescriptor | null | undefined,
223 selection?: ModelSelection | null,
224 reportedSelection?: ModelSelection | null,
225): string | boolean | undefined {
226 if (!descriptor) {
227 return undefined;
228 }
229 const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id);
230 // Reported values are display-only; callers that build dispatch options omit this context.
231 const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection);
232 if (reportedValue !== undefined) return reportedValue;
233 if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined;
234 if (descriptor.type === "boolean") {
235 return descriptor.currentValue;
236 }
237 if (descriptor.currentValue) {
238 return descriptor.currentValue;
239 }
240 return descriptor.options.find((option) => option.isDefault)?.id;
241}
242
243export function getProviderOptionCurrentLabel(
244 descriptor: ProviderOptionDescriptor | null | undefined,
245 selection?: ModelSelection | null,
246 reportedSelection?: ModelSelection | null,
247): string | undefined {
248 if (!descriptor) {
249 return undefined;
250 }
251 if (descriptor.type === "boolean") {
252 return typeof descriptor.currentValue === "boolean"
253 ? descriptor.currentValue
254 ? "On"
255 : "Off"
256 : undefined;
257 }
258 const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
259 return (
260 descriptor.options.find((option) => option.id === currentValue)?.label ??
261 (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default"
262 ? "Default"
263 : descriptor.id === "variant"
264 ? "Unknown"
265 : undefined)
266 );
267}
268
269export function buildProviderOptionSelectionsFromDescriptors(
270 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
271): Array<ProviderOptionSelection> | undefined {
272 if (!descriptors || descriptors.length === 0) {
273 return undefined;
274 }
275
276 const nextSelections: Array<ProviderOptionSelection> = [];
277
278 for (const descriptor of descriptors) {
279 const value = getProviderOptionCurrentValue(descriptor);
280 if (typeof value === "string" || typeof value === "boolean") {
281 nextSelections.push({ id: descriptor.id, value });
282 }
283 }
284
285 return nextSelections.length > 0 ? nextSelections : undefined;
286}
287
288export function buildExplicitProviderOptionSelectionsFromDescriptors(
289 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
290 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
291): Array<ProviderOptionSelection> | undefined {
292 if (!selections || selections.length === 0) {
293 return undefined;
294 }
295 const explicitIds = new Set(selections.map((selection) => selection.id));
296 const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter(
297 (selection) => explicitIds.has(selection.id),
298 );
299 return normalized && normalized.length > 0 ? normalized : undefined;
300}
301
302export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
303 return typeof text === "string" && /\bultrathink\b/i.test(text);
304}
305
306/** Compare Codex model families without changing provider-owned dispatch identifiers. */
307export function codexModelFamily(slug: string): string {
308 return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug;
309}
310
311export function formatCodexModelName(name: string): string {
312 return name.replace(/^gpt/i, "GPT").replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase());
313}
314
315export function formatModelSlugName(slug: string): string {
316 const separator = slug.lastIndexOf("/") + 1;
317 const prefix = slug.slice(0, separator);
318 const name = slug.slice(separator);
319 if (/^gpt-\d/i.test(name)) return prefix + formatCodexModelName(name);
320 if (!/^(claude-(opus|sonnet|haiku|fable)|gemini|grok|composer)-\d/i.test(name)) return slug;
321 return (
322 prefix +
323 name
324 .replace(/^(claude-[a-z]+-\d+)-(\d{1,2})(?=-|\[|$)/i, "$1.$2")
325 .split("-")
326 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
327 .join(" ")
328 );
329}
330
331export function normalizeModelSlug(
332 model: string | null | undefined,
333 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,
334): string | null {
335 const trimmed = normalizeCustomModelSlug(model);
336 if (!trimmed) {
337 return null;
338 }
339
340 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] ?? {};
341 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
342 ? aliases[trimmed]
343 : undefined;
344 return typeof aliased === "string" ? aliased : trimmed;
345}
346
347/** Custom model identifiers are provider-owned, so only trim them; never expand aliases. */
348export function normalizeCustomModelSlug(model: string | null | undefined): string | null {
349 if (typeof model !== "string") {
350 return null;
351 }
352
353 return model.trim() || null;
354}
355
356/** A custom model setting with its optional fields resolved. */
357export interface CustomModelDefinition {
358 readonly slug: string;
359 readonly name: string;
360 readonly capabilities: ModelCapabilities | null;
361}
362
363const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities);
364
365/**
366 * Read a `customModels` setting into resolved definitions. Accepts the typed
367 * union as well as the opaque `providerInstances[id].config` blob clients see,
368 * so it tolerates bare slugs, malformed rows, and unparseable capabilities
369 * (dropped rather than failing the whole list). Slugs are trimmed and
370 * deduplicated, first occurrence wins; `name` falls back to the slug.
371 */
372export function readCustomModelEntries(value: unknown): CustomModelDefinition[] {
373 if (!Array.isArray(value)) return [];
374 const entries: CustomModelDefinition[] = [];
375 const seen = new Set<string>();
376 for (const raw of value) {
377 const record =
378 typeof raw === "string"
379 ? { slug: raw }
380 : raw !== null && typeof raw === "object"
381 ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown })
382 : null;
383 if (!record) continue;
384 const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null);
385 if (!slug || seen.has(slug)) continue;
386 seen.add(slug);
387 const name =
388 (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug;
389 const capabilities =
390 record.capabilities === undefined || record.capabilities === null
391 ? null
392 : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities));
393 entries.push({
394 slug,
395 name,
396 capabilities: capabilities
397 ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] })
398 : null,
399 });
400 }
401 return entries;
402}
403
404/**
405 * Write a definition back to the compact stored shape: a bare slug when it
406 * carries nothing custom, otherwise an entry with only the set fields.
407 */
408export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting {
409 const descriptors = entry.capabilities?.optionDescriptors ?? [];
410 const name = entry.name !== entry.slug ? entry.name : undefined;
411 if (!name && descriptors.length === 0) return entry.slug;
412 return {
413 slug: entry.slug,
414 ...(name ? { name } : {}),
415 ...(descriptors.length > 0
416 ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) }
417 : {}),
418 };
419}
420
421export function resolveSelectableModel(
422 provider: ProviderDriverKind,
423 value: string | null | undefined,
424 options: ReadonlyArray<SelectableModelOption>,
425): string | null {
426 if (typeof value !== "string") {
427 return null;
428 }
429
430 const trimmed = value.trim();
431 if (!trimmed) {
432 return null;
433 }
434
435 const direct = options.find((option) => option.slug === trimmed);
436 if (direct) {
437 return direct.slug;
438 }
439
440 const byName = options.find((option) => option.name.toLowerCase() === trimmed.toLowerCase());
441 if (byName) {
442 return byName.slug;
443 }
444
445 const byAlias = options.find((option) =>
446 option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()),
447 );
448 if (byAlias) {
449 return byAlias.slug;
450 }
451
452 const normalized = normalizeModelSlug(trimmed, provider);
453 if (!normalized) {
454 return null;
455 }
456
457 const resolved = options.find((option) => option.slug === normalized);
458 return resolved ? resolved.slug : null;
459}
460
461/** Trim a string, returning null for empty/missing values. */
462function trimOrNull<T extends string>(value: T | null | undefined): T | null {
463 if (typeof value !== "string") return null;
464 const trimmed = value.trim() as T;
465 return trimmed || null;
466}
467
468function cloneSelections(
469 selections: ReadonlyArray<ProviderOptionSelection>,
470): Array<ProviderOptionSelection> {
471 return selections.map(cloneSelection);
472}
473
474export function createModelSelection(
475 instanceId: ProviderInstanceId,
476 model: string,
477 options?: ReadonlyArray<ProviderOptionSelection> | null,
478): ModelSelection {
479 const selections = options ? cloneSelections(options) : [];
480 const base: ModelSelection = {
481 instanceId,
482 model,
483 };
484 return selections.length > 0 ? { ...base, options: selections } : base;
485}
486
487/**
488 * Returns the effort value if it is a prompt-injected value according to
489 * any select descriptor in the given capabilities, or null otherwise.
490 *
491 * Unlike a single `find`, this checks every descriptor so that the
492 * correct descriptor's `promptInjectedValues` list is consulted even when
493 * multiple select descriptors exist.
494 */
495export function resolvePromptInjectedEffort(
496 caps: ModelCapabilities,
497 rawEffort: string | null | undefined,
498): string | null {
499 const trimmed = trimOrNull(rawEffort);
500 if (!trimmed) return null;
501 const descriptors = getProviderOptionDescriptors({ caps });
502 for (const descriptor of descriptors) {
503 if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) {
504 return trimmed;
505 }
506 }
507 return null;
508}
509
510export function applyClaudePromptEffortPrefix(
511 text: string,
512 effort: string | null | undefined,
513): string {
514 const trimmed = text.trim();
515 if (!trimmed) {
516 return trimmed;
517 }
518 // Prefixing a slash command turns it into plain prose, so Claude never
519 // runs it. Command names come from arbitrary file names ("/deploy.prod",
520 // "/plugin:skill"), so accept any first token without a second slash;
521 // absolute paths like "/home/theo/app.ts" keep the prefix.
522 if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) {
523 return trimmed;
524 }
525 if (trimmed.startsWith("Ultrathink:")) {
526 return trimmed;
527 }
528 return `Ultrathink:\n${trimmed}`;
529}