| 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, |
| 3 | DEFAULT_TEXT_GENERATION_MODEL, |
| 4 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 5 | defaultInstanceIdForDriver, |
| 6 | type ModelSelection, |
| 7 | ProviderDriverKind, |
| 8 | ProviderInstanceId, |
| 9 | type ServerProvider, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | type CustomModelDefinition, |
| 13 | createModelSelection, |
| 14 | normalizeCustomModelSlug, |
| 15 | readCustomModelEntries, |
| 16 | resolveSelectableModel, |
| 17 | } from "@t3tools/shared/model"; |
| 18 | import { getComposerProviderState } from "./components/chat/composerProviderState"; |
| 19 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 20 | import * as Arr from "effect/Array"; |
| 21 | import * as Result from "effect/Result"; |
| 22 | import { |
| 23 | getDefaultServerModel, |
| 24 | getProviderModels, |
| 25 | resolveSelectableProvider, |
| 26 | } from "./providerModels"; |
| 27 | import { ModelEsque } from "./components/chat/providerIconUtils"; |
| 28 | import { |
| 29 | type ProviderInstanceEntry, |
| 30 | deriveProviderInstanceEntries, |
| 31 | NO_PROVIDER_MODEL_SELECTION, |
| 32 | } from "./providerInstances"; |
| 33 | import { sortModelsForProviderInstance } from "./modelOrdering"; |
| 34 | |
| 35 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 36 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 37 | const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); |
| 38 | |
| 39 | /** |
| 40 | * Resolve the custom-model list for a given instance, preferring the |
| 41 | * instance's own `providerInstances[id].config.customModels` blob when |
| 42 | * present and falling back to the legacy per-kind |
| 43 | * `settings.providers[kind].customModels` bucket for default instances only. |
| 44 | * |
| 45 | * The Settings UI promotes the legacy bucket into an explicit |
| 46 | * `providerInstances[defaultId]` entry on every edit (the "migrate on |
| 47 | * first write" scheme documented in |
| 48 | * `ProviderInstanceRegistryHydration`), so this helper exists primarily |
| 49 | * so readers pick up that promotion immediately — and so first-time |
| 50 | * viewers on pre-migration settings still see their legacy list on |
| 51 | * default slots. Custom instances intentionally do not read the legacy |
| 52 | * per-driver bucket; otherwise one custom model added to `claude_openrouter` |
| 53 | * can appear on the stock `claudeAgent` instance. |
| 54 | */ |
| 55 | function readInstanceCustomModels( |
| 56 | settings: UnifiedSettings, |
| 57 | instanceId: ProviderInstanceId, |
| 58 | driverKind: ProviderDriverKind, |
| 59 | ): ReadonlyArray<CustomModelDefinition> { |
| 60 | if (driverKind === "antigravity") return []; |
| 61 | const instance = settings.providerInstances?.[instanceId]; |
| 62 | const config = instance?.config; |
| 63 | if (config !== null && typeof config === "object") { |
| 64 | const value = (config as Record<string, unknown>).customModels; |
| 65 | if (Array.isArray(value)) { |
| 66 | return readCustomModelEntries(value); |
| 67 | } |
| 68 | } |
| 69 | return []; |
| 70 | } |
| 71 | |
| 72 | export interface AppModelOption { |
| 73 | slug: string; |
| 74 | name: string; |
| 75 | shortName?: string; |
| 76 | subProvider?: string; |
| 77 | aliases?: ReadonlyArray<string>; |
| 78 | badge?: "new"; |
| 79 | isCustom: boolean; |
| 80 | isDefault?: boolean; |
| 81 | isLegacy?: boolean; |
| 82 | isUnavailable?: boolean; |
| 83 | } |
| 84 | |
| 85 | function appendUnavailableDynamicModelSelection( |
| 86 | options: AppModelOption[], |
| 87 | rawModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 88 | provider: ProviderDriverKind, |
| 89 | selectedModel: string | null | undefined, |
| 90 | hiddenModels: ReadonlyArray<string>, |
| 91 | ): AppModelOption[] { |
| 92 | if (provider !== "opencode" && provider !== "antigravity") return options; |
| 93 | const slug = normalizeCustomModelSlug(selectedModel); |
| 94 | if (!slug) return options; |
| 95 | if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options; |
| 96 | |
| 97 | // A model that exists in the raw catalog can be absent from `options` |
| 98 | // because the user hid it. Keep that preference authoritative. |
| 99 | if (resolveSelectableModel(provider, slug, rawModels) !== null) return options; |
| 100 | if (hiddenModels.includes(slug)) return options; |
| 101 | if (options.some((option) => option.slug === slug)) return options; |
| 102 | |
| 103 | return [...options, { slug, name: slug, isCustom: false, isUnavailable: true }]; |
| 104 | } |
| 105 | |
| 106 | function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { |
| 107 | const option: AppModelOption = { |
| 108 | slug: model.slug, |
| 109 | name: model.name, |
| 110 | isCustom: model.isCustom, |
| 111 | }; |
| 112 | if (model.shortName) option.shortName = model.shortName; |
| 113 | if (model.subProvider) option.subProvider = model.subProvider; |
| 114 | if (model.aliases) option.aliases = model.aliases; |
| 115 | if (model.badge) option.badge = model.badge; |
| 116 | if (model.isDefault) option.isDefault = true; |
| 117 | if (model.isLegacy) option.isLegacy = true; |
| 118 | return option; |
| 119 | } |
| 120 | |
| 121 | function readInstanceModelPreferences( |
| 122 | settings: UnifiedSettings, |
| 123 | instanceId: ProviderInstanceId, |
| 124 | ): { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> } { |
| 125 | return ( |
| 126 | settings.providerModelPreferences?.[instanceId] ?? { |
| 127 | hiddenModels: [], |
| 128 | modelOrder: [], |
| 129 | } |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | function applyInstanceModelPreferences( |
| 134 | options: ReadonlyArray<AppModelOption>, |
| 135 | preferences: { |
| 136 | readonly hiddenModels: ReadonlyArray<string>; |
| 137 | readonly modelOrder: ReadonlyArray<string>; |
| 138 | }, |
| 139 | ): AppModelOption[] { |
| 140 | const hiddenModels = new Set(preferences.hiddenModels); |
| 141 | return sortModelsForProviderInstance( |
| 142 | options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)), |
| 143 | { modelOrder: preferences.modelOrder }, |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | function normalizeCustomModelEntries( |
| 148 | models: ReadonlyArray<CustomModelDefinition>, |
| 149 | builtInModelSlugs: ReadonlySet<string>, |
| 150 | ): CustomModelDefinition[] { |
| 151 | const normalizedModels: CustomModelDefinition[] = []; |
| 152 | const seen = new Set<string>(); |
| 153 | |
| 154 | for (const candidate of models) { |
| 155 | if ( |
| 156 | candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH || |
| 157 | builtInModelSlugs.has(candidate.slug) || |
| 158 | seen.has(candidate.slug) |
| 159 | ) { |
| 160 | continue; |
| 161 | } |
| 162 | |
| 163 | seen.add(candidate.slug); |
| 164 | normalizedModels.push(candidate); |
| 165 | if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { |
| 166 | break; |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | return normalizedModels; |
| 171 | } |
| 172 | |
| 173 | function getAppModelOptions( |
| 174 | settings: UnifiedSettings, |
| 175 | providers: ReadonlyArray<ServerProvider>, |
| 176 | provider: ProviderDriverKind, |
| 177 | selectedModel?: string | null, |
| 178 | ): AppModelOption[] { |
| 179 | const rawModels = getProviderModels(providers, provider); |
| 180 | // Server-reported custom rows mirror settings and can lag a removal, so |
| 181 | // only built-ins are taken from the snapshot; custom rows are rebuilt from |
| 182 | // settings below. |
| 183 | const options: AppModelOption[] = rawModels |
| 184 | .filter((model) => !model.isCustom) |
| 185 | .map(toAppModelOption); |
| 186 | const seen = new Set(options.map((option) => option.slug)); |
| 187 | const builtInModelSlugs = new Set( |
| 188 | Arr.filterMap(getProviderModels(providers, provider), (model) => |
| 189 | model.isCustom ? Result.failVoid : Result.succeed(model.slug), |
| 190 | ), |
| 191 | ); |
| 192 | |
| 193 | // Read from the default instance's config first (that's where edits |
| 194 | // now land), falling back to the legacy per-kind bucket so unmigrated |
| 195 | // settings and the initial render before the first write both still |
| 196 | // see the user's authored custom models. |
| 197 | const defaultInstanceId = defaultInstanceIdForDriver(provider); |
| 198 | const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); |
| 199 | for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { |
| 200 | if (seen.has(entry.slug)) { |
| 201 | continue; |
| 202 | } |
| 203 | |
| 204 | seen.add(entry.slug); |
| 205 | options.push({ slug: entry.slug, name: entry.name, isCustom: true }); |
| 206 | } |
| 207 | |
| 208 | const preferences = readInstanceModelPreferences(settings, defaultInstanceId); |
| 209 | return appendUnavailableDynamicModelSelection( |
| 210 | applyInstanceModelPreferences(options, preferences), |
| 211 | rawModels, |
| 212 | provider, |
| 213 | selectedModel, |
| 214 | preferences.hiddenModels, |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * Instance-scoped variant of {@link getAppModelOptions}. Built-in models |
| 220 | * come from the instance's own `entry.models` snapshot (rather than the |
| 221 | * first-matching-kind fallback in `getProviderModels`), so each custom |
| 222 | * instance gets the precise model list its driver reported. Custom model |
| 223 | * slugs come from the instance's own `providerInstances[id].config.customModels`, |
| 224 | * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are |
| 225 | * ignored so a slug removed in Settings disappears without waiting for the |
| 226 | * next provider probe. |
| 227 | */ |
| 228 | export function getAppModelOptionsForInstance( |
| 229 | settings: UnifiedSettings, |
| 230 | entry: ProviderInstanceEntry, |
| 231 | selectedModel?: string | null, |
| 232 | ): AppModelOption[] { |
| 233 | const options: AppModelOption[] = entry.models |
| 234 | .filter((model) => !model.isCustom) |
| 235 | .map(toAppModelOption); |
| 236 | const seen = new Set(options.map((option) => option.slug)); |
| 237 | const builtInModelSlugs = new Set( |
| 238 | Arr.filterMap(entry.models, (model) => |
| 239 | model.isCustom ? Result.failVoid : Result.succeed(model.slug), |
| 240 | ), |
| 241 | ); |
| 242 | |
| 243 | const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); |
| 244 | for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { |
| 245 | if (seen.has(custom.slug)) { |
| 246 | continue; |
| 247 | } |
| 248 | |
| 249 | seen.add(custom.slug); |
| 250 | options.push({ slug: custom.slug, name: custom.name, isCustom: true }); |
| 251 | } |
| 252 | |
| 253 | const preferences = readInstanceModelPreferences(settings, entry.instanceId); |
| 254 | return appendUnavailableDynamicModelSelection( |
| 255 | applyInstanceModelPreferences(options, preferences), |
| 256 | entry.models, |
| 257 | entry.driverKind, |
| 258 | selectedModel, |
| 259 | preferences.hiddenModels, |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | export function resolveAppModelSelection( |
| 264 | provider: ProviderDriverKind, |
| 265 | settings: UnifiedSettings, |
| 266 | providers: ReadonlyArray<ServerProvider>, |
| 267 | selectedModel: string | null | undefined, |
| 268 | ): string { |
| 269 | const resolvedProvider = resolveSelectableProvider(providers, provider); |
| 270 | const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); |
| 271 | return ( |
| 272 | resolveSelectableModel(resolvedProvider, selectedModel, options) ?? |
| 273 | getDefaultServerModel(providers, resolvedProvider) |
| 274 | ); |
| 275 | } |
| 276 | |
| 277 | export function resolveAppModelSelectionForInstance( |
| 278 | instanceId: ProviderInstanceId, |
| 279 | settings: UnifiedSettings, |
| 280 | providers: ReadonlyArray<ServerProvider>, |
| 281 | selectedModel: string | null | undefined, |
| 282 | resolutionOptions?: { readonly preserveUnavailableSelection?: boolean }, |
| 283 | ): string | null { |
| 284 | const entry = deriveProviderInstanceEntries(providers).find( |
| 285 | (candidate) => candidate.instanceId === instanceId, |
| 286 | ); |
| 287 | if (!entry) return null; |
| 288 | const options = getAppModelOptionsForInstance( |
| 289 | settings, |
| 290 | entry, |
| 291 | resolutionOptions?.preserveUnavailableSelection ? selectedModel : null, |
| 292 | ); |
| 293 | const resolvedSelection = resolveSelectableModel(entry.driverKind, selectedModel, options); |
| 294 | if (resolvedSelection) { |
| 295 | return resolvedSelection; |
| 296 | } |
| 297 | if ( |
| 298 | resolutionOptions?.preserveUnavailableSelection && |
| 299 | (entry.driverKind === "opencode" || entry.driverKind === "antigravity") |
| 300 | ) { |
| 301 | const unavailableSelection = normalizeCustomModelSlug(selectedModel); |
| 302 | const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels; |
| 303 | if ( |
| 304 | unavailableSelection && |
| 305 | !hiddenModels.includes(unavailableSelection) && |
| 306 | resolveSelectableModel(entry.driverKind, selectedModel, entry.models) === null && |
| 307 | (entry.driverKind !== "antigravity" || unavailableSelection !== ANTIGRAVITY_DEFAULT_MODEL) |
| 308 | ) { |
| 309 | return unavailableSelection; |
| 310 | } |
| 311 | } |
| 312 | return options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? null; |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Instance-keyed model options map. Each configured instance gets its own |
| 317 | * option list so the model picker can show the same driver's built-in and |
| 318 | * custom instances side by side without collapsing them. |
| 319 | */ |
| 320 | export function getCustomModelOptionsByInstance( |
| 321 | settings: UnifiedSettings, |
| 322 | providers: ReadonlyArray<ServerProvider>, |
| 323 | selectedInstanceId?: ProviderInstanceId | null, |
| 324 | selectedModel?: string | null, |
| 325 | ): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> { |
| 326 | const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>(); |
| 327 | for (const entry of deriveProviderInstanceEntries(providers)) { |
| 328 | out.set( |
| 329 | entry.instanceId, |
| 330 | getAppModelOptionsForInstance( |
| 331 | settings, |
| 332 | entry, |
| 333 | entry.instanceId === selectedInstanceId ? selectedModel : null, |
| 334 | ), |
| 335 | ); |
| 336 | } |
| 337 | return out; |
| 338 | } |
| 339 | |
| 340 | /** |
| 341 | * Whether stored model options pick the opencode "plan" agent. Shared settings |
| 342 | * pickers keep and show such a value even while this device's legacy plan |
| 343 | * mode is off: another device may have chosen it, and this device's filter |
| 344 | * only applies to picks made here. |
| 345 | */ |
| 346 | export function selectsPlanAgent(options: ModelSelection["options"]): boolean { |
| 347 | return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false; |
| 348 | } |
| 349 | |
| 350 | export function resolveAppModelSelectionState( |
| 351 | settings: UnifiedSettings, |
| 352 | providers: ReadonlyArray<ServerProvider>, |
| 353 | ): ModelSelection { |
| 354 | const selection = settings.textGenerationModelSelection ?? { |
| 355 | instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, |
| 356 | model: DEFAULT_TEXT_GENERATION_MODEL, |
| 357 | }; |
| 358 | const supportedProviders = providers.filter( |
| 359 | (provider) => provider.supportsTextGeneration !== false, |
| 360 | ); |
| 361 | const entries = deriveProviderInstanceEntries(supportedProviders); |
| 362 | const selectedEntry = entries.find( |
| 363 | (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, |
| 364 | ); |
| 365 | const entry = |
| 366 | selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); |
| 367 | if (entry) { |
| 368 | // When the instance changed due to fallback (e.g. selected instance was disabled), |
| 369 | // don't carry over the old instance's model — use the fallback instance's default. |
| 370 | const selectedModel = selectedEntry ? selection.model : null; |
| 371 | const model = |
| 372 | resolveAppModelSelectionForInstance( |
| 373 | entry.instanceId, |
| 374 | settings, |
| 375 | supportedProviders, |
| 376 | selectedModel, |
| 377 | ) ?? |
| 378 | entry.models[0]?.slug ?? |
| 379 | DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; |
| 380 | if (!model) { |
| 381 | return createModelSelection(entry.instanceId, "", []); |
| 382 | } |
| 383 | const provider = entry.driverKind; |
| 384 | const { modelOptionsForDispatch } = getComposerProviderState({ |
| 385 | provider, |
| 386 | model, |
| 387 | models: entry.models, |
| 388 | modelOptions: selectedEntry ? selection.options : undefined, |
| 389 | planModeEnabled: settings.planModeEnabled || selectsPlanAgent(selection.options), |
| 390 | }); |
| 391 | |
| 392 | return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); |
| 393 | } |
| 394 | |
| 395 | return NO_PROVIDER_MODEL_SELECTION; |
| 396 | } |