1import {
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";
11import {
12 type CustomModelDefinition,
13 createModelSelection,
14 normalizeCustomModelSlug,
15 readCustomModelEntries,
16 resolveSelectableModel,
17} from "@t3tools/shared/model";
18import { getComposerProviderState } from "./components/chat/composerProviderState";
19import { UnifiedSettings } from "@t3tools/contracts/settings";
20import * as Arr from "effect/Array";
21import * as Result from "effect/Result";
22import {
23 getDefaultServerModel,
24 getProviderModels,
25 resolveSelectableProvider,
26} from "./providerModels";
27import { ModelEsque } from "./components/chat/providerIconUtils";
28import {
29 type ProviderInstanceEntry,
30 deriveProviderInstanceEntries,
31 NO_PROVIDER_MODEL_SELECTION,
32} from "./providerInstances";
33import { sortModelsForProviderInstance } from "./modelOrdering";
34
35const MAX_CUSTOM_MODEL_COUNT = 32;
36export const MAX_CUSTOM_MODEL_LENGTH = 256;
37const 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 */
55function 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
72export 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
85function 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
106function 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
121function 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
133function 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
147function 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
173function 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 */
228export 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
263export 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
277export 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 */
320export 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 */
346export function selectsPlanAgent(options: ModelSelection["options"]): boolean {
347 return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false;
348}
349
350export 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}