Skip to content
Source
Sign inSign up
Open History to see the last commit

model.ts

572 lines18.2 KB

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​
17export function modelSelectionCommandType(
18 currentInstanceId: ProviderInstanceId,
19 selection: ModelSelection,
20) {
21 return currentInstanceId === selection.instanceId
22 ? ("thread.model-selection.set" as const)
23 : ("provider.switch" as const);
25​
26export interface SelectableModelOption {
27 slug: string;
28 name: string;
29 aliases?: ReadonlyArray<string> | undefined;
31​
32export function createModelCapabilities(input: {
33 optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
34}): ModelCapabilities {
35 return {
36 optionDescriptors: input.optionDescriptors.map(cloneDescriptor),
37 };
39​
40function getRawSelectionValueById(
41 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
42 id: string,
43): string | boolean | undefined {
44 const selection = selections?.find((candidate) => candidate.id === id);
45 return selection?.value;
47​
48function getProviderOptionSelectionValue(
49 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
50 id: string,
51): string | boolean | undefined {
52 return getRawSelectionValueById(selections, id);
54​
55function getProviderOptionStringSelectionValue(
56 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
57 id: string,
58): string | undefined {
59 const value = getProviderOptionSelectionValue(selections, id);
60 return typeof value === "string" ? value : undefined;
62​
63function getProviderOptionBooleanSelectionValue(
64 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
65 id: string,
66): boolean | undefined {
67 const value = getProviderOptionSelectionValue(selections, id);
68 return typeof value === "boolean" ? value : undefined;
70​
71export function getModelSelectionStringOptionValue(
72 modelSelection: ModelSelection | null | undefined,
73 id: string,
74): string | undefined {
75 return getProviderOptionStringSelectionValue(modelSelection?.options, id);
77​
78export function getModelSelectionBooleanOptionValue(
79 modelSelection: ModelSelection | null | undefined,
80 id: string,
81): boolean | undefined {
82 return getProviderOptionBooleanSelectionValue(modelSelection?.options, id);
84​
85function canonicalModelSelectionOptions(
86 modelSelection: ModelSelection,
87): ReadonlyArray<readonly [id: string, value: string | boolean]> {
88 return copySorted(
89 (modelSelection.options ?? []).map(
90 (selection): readonly [id: string, value: string | boolean] => [
91 selection.id,
92 selection.value,
93 ],
94 ),
95 (
96 [leftId, leftValue]: readonly [id: string, value: string | boolean],
97 [rightId, rightValue]: readonly [id: string, value: string | boolean],
98 ) => {
99 const idOrder = leftId.localeCompare(rightId);
100 return idOrder !== 0 ? idOrder : String(leftValue).localeCompare(String(rightValue));
101 },
102 );
104​
105export function modelSelectionsEqual(left: ModelSelection, right: ModelSelection): boolean {
106 if (left.instanceId !== right.instanceId || left.model !== right.model) {
107 return false;
108 }
109 const leftOptions = canonicalModelSelectionOptions(left);
110 const rightOptions = canonicalModelSelectionOptions(right);
111 return (
112 leftOptions.length === rightOptions.length &&
113 leftOptions.every(
114 ([id, value], index) => id === rightOptions[index]?.[0] && value === rightOptions[index]?.[1],
115 )
116 );
118​
119/**
120 * Whether a composer's draft model pick is what the thread already runs. Web sends
121 * `fastMode: false` for models with a fast toggle while mobile omits it, so off counts as unset.
122 */
123export function draftModelPickMatchesThread(pick: ModelSelection, thread: ModelSelection): boolean {
124 const withoutFastModeOff = (selection: ModelSelection) =>
125 createModelSelection(
126 selection.instanceId,
127 selection.model,
128 selection.options?.filter((option) => option.id !== "fastMode" || option.value !== false),
129 );
130 return modelSelectionsEqual(withoutFastModeOff(pick), withoutFastModeOff(thread));
132​
133/** Cursor ACP can default Fast to true; T3 requires a user selection. */
134export function withImplicitFastModeDefault(
135 caps: ModelCapabilities,
136 modelOptions: ReadonlyArray<ProviderOptionSelection> | null | undefined,
137): ReadonlyArray<ProviderOptionSelection> | undefined {
138 const hasExplicitFastMode = modelOptions?.some((selection) => selection.id === "fastMode");
139 if (hasExplicitFastMode) {
140 return modelOptions ?? undefined;
141 }
142 const hasFastModeDescriptor = caps.optionDescriptors?.some(
143 (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode",
144 );
145 if (!hasFastModeDescriptor) {
146 return modelOptions ?? undefined;
147 }
148 return [...(modelOptions ?? []), { id: "fastMode", value: false }];
150​
151function resolveDescriptorChoiceValue(
152 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
153 raw: string | null | undefined,
154): string | undefined {
155 const trimmed = trimOrNull(raw);
156 if (!trimmed) {
157 return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id;
158 }
159 if (descriptor.options.length === 0) {
160 return trimmed;
161 }
162 if (
163 descriptor.promptInjectedValues?.includes(trimmed) &&
164 descriptor.options.some((option) => option.id === trimmed)
165 ) {
166 return descriptor.options.find((option) => option.isDefault)?.id;
167 }
168 if (descriptor.options.some((option) => option.id === trimmed)) {
169 return trimmed;
170 }
171 return descriptor.currentValue ?? descriptor.options.find((option) => option.isDefault)?.id;
173​
174function cloneDescriptor(descriptor: ProviderOptionDescriptor): ProviderOptionDescriptor {
175 return descriptor.type === "select"
176 ? {
177 ...descriptor,
178 options: [...descriptor.options],
179 ...(descriptor.promptInjectedValues
180 ? { promptInjectedValues: [...descriptor.promptInjectedValues] }
181 : {}),
182 }
183 : { ...descriptor };
185​
186function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection {
187 return { ...selection };
189​
190function withDescriptorCurrentValue(
191 descriptor: ProviderOptionDescriptor,
192 rawCurrentValue: string | boolean | undefined,
193): ProviderOptionDescriptor {
194 if (descriptor.type === "boolean") {
195 if (typeof rawCurrentValue === "boolean") {
196 return {
197 ...descriptor,
198 currentValue: rawCurrentValue,
199 };
200 }
201 return descriptor;
202 }
203 const currentValue =
204 typeof rawCurrentValue === "string"
205 ? resolveDescriptorChoiceValue(descriptor, rawCurrentValue)
206 : resolveDescriptorChoiceValue(descriptor, descriptor.currentValue);
207 if (!currentValue) {
208 const { currentValue: _unusedCurrentValue, ...rest } = descriptor;
209 return rest;
210 }
211 return {
212 ...descriptor,
213 currentValue,
214 };
216​
217// Ultracode was a Claude effort level before it became its own toggle; that level ran at xhigh.
218export function withLegacyUltracodeEffort<
219 Selections extends ReadonlyArray<ProviderOptionSelection> | null | undefined,
220>(
221 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
222 selections: Selections,
223): Selections | Array<ProviderOptionSelection> {
224 if (
225 getRawSelectionValueById(selections, "effort") !== "ultracode" ||
226 !descriptors?.some(
227 (descriptor) => descriptor.id === "ultracode" && descriptor.type === "boolean",
228 )
229 ) {
230 return selections;
231 }
232 return [
233 ...(selections ?? []).map((selection) =>
234 selection.id === "effort" && selection.value === "ultracode"
235 ? { id: "effort", value: "xhigh" }
236 : selection,
237 ),
238 ...(getRawSelectionValueById(selections, "ultracode") === undefined
239 ? [{ id: "ultracode", value: true }]
240 : []),
241 ];
243​
244export function getProviderOptionDescriptors(input: {
245 caps: ModelCapabilities;
246 selections?: ReadonlyArray<ProviderOptionSelection> | null | undefined;
247}): ReadonlyArray<ProviderOptionDescriptor> {
248 const { caps } = input;
249 const baseDescriptors = (caps.optionDescriptors ?? []).map(cloneDescriptor);
250 const selections = withLegacyUltracodeEffort(baseDescriptors, input.selections);
251​
252 return baseDescriptors.map((descriptor) =>
253 withDescriptorCurrentValue(
254 descriptor,
255 getRawSelectionValueById(selections, descriptor.id) ?? descriptor.currentValue,
256 ),
257 );
259​
260function getReportedOptionValue(
261 id: string,
262 selection?: ModelSelection | null,
263 reportedSelection?: ModelSelection | null,
264) {
265 if (
266 !selection ||
267 !reportedSelection ||
268 selection.instanceId !== reportedSelection.instanceId ||
269 selection.model !== reportedSelection.model ||
270 selection.options?.some((option) => option.id === id)
271 )
272 return undefined;
273 return getRawSelectionValueById(reportedSelection.options, id);
275​
276export function getProviderOptionStatedValue(
277 id: string,
278 selection?: ModelSelection | null,
279 reportedSelection?: ModelSelection | null,
280) {
281 return (
282 getRawSelectionValueById(selection?.options, id) ??
283 getReportedOptionValue(id, selection, reportedSelection)
284 );
286​
287export function getProviderOptionCurrentValue(
288 descriptor: ProviderOptionDescriptor | null | undefined,
289 selection?: ModelSelection | null,
290 reportedSelection?: ModelSelection | null,
291): string | boolean | undefined {
292 if (!descriptor) {
293 return undefined;
294 }
295 const hasExplicitOption = selection?.options?.some((option) => option.id === descriptor.id);
296 // Reported values are display-only; callers that build dispatch options omit this context.
297 const reportedValue = getReportedOptionValue(descriptor.id, selection, reportedSelection);
298 if (reportedValue !== undefined) return reportedValue;
299 if (descriptor.id === "variant" && selection && !hasExplicitOption) return undefined;
300 if (descriptor.type === "boolean") {
301 return descriptor.currentValue;
302 }
303 if (descriptor.currentValue) {
304 return descriptor.currentValue;
305 }
306 return descriptor.options.find((option) => option.isDefault)?.id;
308​
309export function getProviderOptionCurrentLabel(
310 descriptor: ProviderOptionDescriptor | null | undefined,
311 selection?: ModelSelection | null,
312 reportedSelection?: ModelSelection | null,
313): string | undefined {
314 if (!descriptor) {
315 return undefined;
316 }
317 if (descriptor.type === "boolean") {
318 return typeof descriptor.currentValue === "boolean"
319 ? descriptor.currentValue
320 ? "On"
321 : "Off"
322 : undefined;
323 }
324 const currentValue = getProviderOptionCurrentValue(descriptor, selection, reportedSelection);
325 return (
326 descriptor.options.find((option) => option.id === currentValue)?.label ??
327 (getReportedOptionValue(descriptor.id, selection, reportedSelection) === "default"
328 ? "Default"
329 : descriptor.id === "variant"
330 ? "Unknown"
331 : undefined)
332 );
334​
335export function buildProviderOptionSelectionsFromDescriptors(
336 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
337): Array<ProviderOptionSelection> | undefined {
338 if (!descriptors || descriptors.length === 0) {
339 return undefined;
340 }
341​
342 const nextSelections: Array<ProviderOptionSelection> = [];
343​
344 for (const descriptor of descriptors) {
345 const value = getProviderOptionCurrentValue(descriptor);
346 if (typeof value === "string" || typeof value === "boolean") {
347 nextSelections.push({ id: descriptor.id, value });
348 }
349 }
350​
351 return nextSelections.length > 0 ? nextSelections : undefined;
353​
354export function buildExplicitProviderOptionSelectionsFromDescriptors(
355 descriptors: ReadonlyArray<ProviderOptionDescriptor> | null | undefined,
356 selections: ReadonlyArray<ProviderOptionSelection> | null | undefined,
357): Array<ProviderOptionSelection> | undefined {
358 if (!selections || selections.length === 0) {
359 return undefined;
360 }
361 const explicitIds = new Set(
362 withLegacyUltracodeEffort(descriptors, selections).map(({ id }) => id),
363 );
364 const normalized = buildProviderOptionSelectionsFromDescriptors(descriptors)?.filter(
365 (selection) => explicitIds.has(selection.id),
366 );
367 return normalized && normalized.length > 0 ? normalized : undefined;
369​
370export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
371 return typeof text === "string" && /\bultrathink\b/i.test(text);
373​
374export function codexModelFamily(slug: string): string {
375 return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug;
377​
378export function formatCodexModelName(name: string): string {
379 return name.replace(/^gpt/i, "GPT").replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase());
381​
382export function formatModelSlugName(slug: string): string {
383 const separator = slug.lastIndexOf("/") + 1;
384 const prefix = slug.slice(0, separator);
385 const name = slug.slice(separator);
386 if (/^gpt-\d/i.test(name)) return prefix + formatCodexModelName(name);
387 if (!/^(claude-(opus|sonnet|haiku|fable)|gemini|grok|composer)-\d/i.test(name)) return slug;
388 return (
389 prefix +
390 name
391 .replace(/^(claude-[a-z]+-\d+)-(\d{1,2})(?=-|\[|$)/i, "$1.$2")
392 .split("-")
393 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
394 .join(" ")
395 );
397​
398export function normalizeModelSlug(
399 model: string | null | undefined,
400 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,
401): string | null {
402 const trimmed = normalizeCustomModelSlug(model);
403 if (!trimmed) {
404 return null;
405 }
406​
407 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] ?? {};
408 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
409 ? aliases[trimmed]
410 : undefined;
411 return typeof aliased === "string" ? aliased : trimmed;
413​
414/** Custom model identifiers are provider-owned, so only trim them; never expand aliases. */
415export function normalizeCustomModelSlug(model: string | null | undefined): string | null {
416 if (typeof model !== "string") {
417 return null;
418 }
419​
420 return model.trim() || null;
422​
423export interface CustomModelDefinition {
424 readonly slug: string;
425 readonly name: string;
426 readonly capabilities: ModelCapabilities | null;
428​
429const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities);
430​
431export function readCustomModelEntries(value: unknown): CustomModelDefinition[] {
432 if (!Array.isArray(value)) return [];
433 const entries: CustomModelDefinition[] = [];
434 const seen = new Set<string>();
435 for (const raw of value) {
436 const record =
437 typeof raw === "string"
438 ? { slug: raw }
439 : raw !== null && typeof raw === "object"
440 ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown })
441 : null;
442 if (!record) continue;
443 const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null);
444 if (!slug || seen.has(slug)) continue;
445 seen.add(slug);
446 const name =
447 (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug;
448 const capabilities =
449 record.capabilities === undefined || record.capabilities === null
450 ? null
451 : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities));
452 entries.push({
453 slug,
454 name,
455 capabilities: capabilities
456 ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] })
457 : null,
458 });
459 }
460 return entries;
462​
463export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting {
464 const descriptors = entry.capabilities?.optionDescriptors ?? [];
465 const name = entry.name !== entry.slug ? entry.name : undefined;
466 if (!name && descriptors.length === 0) return entry.slug;
467 return {
468 slug: entry.slug,
469 ...(name ? { name } : {}),
470 ...(descriptors.length > 0
471 ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) }
472 : {}),
473 };
475​
476export function resolveSelectableModel(
477 provider: ProviderDriverKind,
478 value: string | null | undefined,
479 options: ReadonlyArray<SelectableModelOption>,
480): string | null {
481 if (typeof value !== "string") {
482 return null;
483 }
484​
485 const trimmed = value.trim();
486 if (!trimmed) {
487 return null;
488 }
489​
490 const direct = options.find((option) => option.slug === trimmed);
491 if (direct) {
492 return direct.slug;
493 }
494​
495 const byName = options.find((option) => option.name.toLowerCase() === trimmed.toLowerCase());
496 if (byName) {
497 return byName.slug;
498 }
499​
500 const byAlias = options.find((option) =>
501 option.aliases?.some((alias) => alias.toLowerCase() === trimmed.toLowerCase()),
502 );
503 if (byAlias) {
504 return byAlias.slug;
505 }
506​
507 const normalized = normalizeModelSlug(trimmed, provider);
508 if (!normalized) {
509 return null;
510 }
511​
512 const resolved = options.find((option) => option.slug === normalized);
513 return resolved ? resolved.slug : null;
515​
516function trimOrNull<T extends string>(value: T | null | undefined): T | null {
517 if (typeof value !== "string") return null;
518 const trimmed = value.trim() as T;
519 return trimmed || null;
521​
522function cloneSelections(
523 selections: ReadonlyArray<ProviderOptionSelection>,
524): Array<ProviderOptionSelection> {
525 return selections.map(cloneSelection);
527​
528export function createModelSelection(
529 instanceId: ProviderInstanceId,
530 model: string,
531 options?: ReadonlyArray<ProviderOptionSelection> | null,
532): ModelSelection {
533 const selections = options ? cloneSelections(options) : [];
534 const base: ModelSelection = {
535 instanceId,
536 model,
537 };
538 return selections.length > 0 ? { ...base, options: selections } : base;
540​
541export function resolvePromptInjectedEffort(
542 caps: ModelCapabilities,
543 rawEffort: string | null | undefined,
544): string | null {
545 const trimmed = trimOrNull(rawEffort);
546 if (!trimmed) return null;
547 const descriptors = getProviderOptionDescriptors({ caps });
548 for (const descriptor of descriptors) {
549 if (descriptor.type === "select" && descriptor.promptInjectedValues?.includes(trimmed)) {
550 return trimmed;
551 }
552 }
553 return null;
555​
556export function applyClaudePromptEffortPrefix(
557 text: string,
558 effort: string | null | undefined,
559): string {
560 const trimmed = text.trim();
561 if (!trimmed) {
562 return trimmed;
563 }
564 // Prefixing slash commands prevents Claude invocation; command names allow any token without a second slash.
565 if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) {
566 return trimmed;
567 }
568 if (trimmed.startsWith("Ultrathink:")) {
569 return trimmed;
570 }
571 return `Ultrathink:\n${trimmed}`;