| 1 | import { |
| 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"; |
| 11 | import * as Option from "effect/Option"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import { copySorted } from "./Array.ts"; |
| 14 | |
| 15 | const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 16 | |
| 17 | /** Choose the command for a model change against the thread's current provider instance. */ |
| 18 | export 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 | |
| 27 | export interface SelectableModelOption { |
| 28 | slug: string; |
| 29 | name: string; |
| 30 | aliases?: ReadonlyArray<string> | undefined; |
| 31 | } |
| 32 | |
| 33 | export function createModelCapabilities(input: { |
| 34 | optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>; |
| 35 | }): ModelCapabilities { |
| 36 | return { |
| 37 | optionDescriptors: input.optionDescriptors.map(cloneDescriptor), |
| 38 | }; |
| 39 | } |
| 40 | |
| 41 | function 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 | |
| 49 | function getProviderOptionSelectionValue( |
| 50 | selections: ReadonlyArray<ProviderOptionSelection> | null | undefined, |
| 51 | id: string, |
| 52 | ): string | boolean | undefined { |
| 53 | return getRawSelectionValueById(selections, id); |
| 54 | } |
| 55 | |
| 56 | export 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 | |
| 64 | export 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 | |
| 72 | export function getModelSelectionStringOptionValue( |
| 73 | modelSelection: ModelSelection | null | undefined, |
| 74 | id: string, |
| 75 | ): string | undefined { |
| 76 | return getProviderOptionStringSelectionValue(modelSelection?.options, id); |
| 77 | } |
| 78 | |
| 79 | export function getModelSelectionBooleanOptionValue( |
| 80 | modelSelection: ModelSelection | null | undefined, |
| 81 | id: string, |
| 82 | ): boolean | undefined { |
| 83 | return getProviderOptionBooleanSelectionValue(modelSelection?.options, id); |
| 84 | } |
| 85 | |
| 86 | function 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 | */ |
| 110 | export 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 | |
| 124 | function 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 | |
| 147 | function 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 | |
| 159 | function cloneSelection(selection: ProviderOptionSelection): ProviderOptionSelection { |
| 160 | return { ...selection }; |
| 161 | } |
| 162 | |
| 163 | function 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 | |
| 190 | export 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 | |
| 205 | function 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 | |
| 221 | export 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 | |
| 243 | export 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 | |
| 269 | export 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 | |
| 288 | export 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 | |
| 302 | export 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. */ |
| 307 | export function codexModelFamily(slug: string): string { |
| 308 | return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; |
| 309 | } |
| 310 | |
| 311 | export function formatCodexModelName(name: string): string { |
| 312 | return name.replace(/^gpt/i, "GPT").replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase()); |
| 313 | } |
| 314 | |
| 315 | export 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 | |
| 331 | export 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. */ |
| 348 | export 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. */ |
| 357 | export interface CustomModelDefinition { |
| 358 | readonly slug: string; |
| 359 | readonly name: string; |
| 360 | readonly capabilities: ModelCapabilities | null; |
| 361 | } |
| 362 | |
| 363 | const 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 | */ |
| 372 | export 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 | */ |
| 408 | export 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 | |
| 421 | export 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. */ |
| 462 | function 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 | |
| 468 | function cloneSelections( |
| 469 | selections: ReadonlyArray<ProviderOptionSelection>, |
| 470 | ): Array<ProviderOptionSelection> { |
| 471 | return selections.map(cloneSelection); |
| 472 | } |
| 473 | |
| 474 | export 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 | */ |
| 495 | export 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 | |
| 510 | export 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 | } |