import { DEFAULT_GIT_TEXT_GENERATION_MODEL, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, ProviderDriverKind, ProviderInstanceId, type ServerProvider,} from "@t3tools/contracts";import { createModelSelection, normalizeCustomModelSlug, resolveSelectableModel,} from "@t3tools/shared/model";import { getComposerProviderState } from "./components/chat/composerProviderState";import { UnifiedSettings } from "@t3tools/contracts/settings";import * as Arr from "effect/Array";import * as Result from "effect/Result";import { getDefaultServerModel, getProviderModels, resolveSelectableProvider,} from "./providerModels";import { ModelEsque } from "./components/chat/providerIconUtils";import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";import { sortModelsForProviderInstance } from "./modelOrdering"; const MAX_CUSTOM_MODEL_COUNT = 32;export const MAX_CUSTOM_MODEL_LENGTH = 256;const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); /** * Resolve the custom-model list for a given instance, preferring the * instance's own `providerInstances[id].config.customModels` blob when * present and falling back to the legacy per-kind * `settings.providers[kind].customModels` bucket for default instances only. * * The Settings UI promotes the legacy bucket into an explicit * `providerInstances[defaultId]` entry on every edit (the "migrate on * first write" scheme documented in * `ProviderInstanceRegistryHydration`), so this helper exists primarily * so readers pick up that promotion immediately — and so first-time * viewers on pre-migration settings still see their legacy list on * default slots. Custom instances intentionally do not read the legacy * per-driver bucket; otherwise one custom model added to `claude_openrouter` * can appear on the stock `claudeAgent` instance. */function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind,): ReadonlyArray<string> { const instance = settings.providerInstances?.[instanceId]; const config = instance?.config; if (config !== null && typeof config === "object") { const value = (config as Record<string, unknown>).customModels; if (Array.isArray(value)) { return value.filter((entry): entry is string => typeof entry === "string"); } } const defaultInstanceId = defaultInstanceIdForDriver(driverKind); if (instanceId !== defaultInstanceId) { return []; } const legacyProviders = settings.providers as Record< string, { readonly customModels: ReadonlyArray<string> } | undefined >; return legacyProviders[driverKind]?.customModels ?? [];} export interface AppModelOption { slug: string; name: string; shortName?: string; subProvider?: string; isCustom: boolean; isDefault?: boolean;} function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { const option: AppModelOption = { slug: model.slug, name: model.name, isCustom: model.isCustom, }; if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; if (model.isDefault) option.isDefault = true; return option;} function readInstanceModelPreferences( settings: UnifiedSettings, instanceId: ProviderInstanceId,): { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> } { return ( settings.providerModelPreferences?.[instanceId] ?? { hiddenModels: [], modelOrder: [], } );} function applyInstanceModelPreferences( options: ReadonlyArray<AppModelOption>, preferences: { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string>; },): AppModelOption[] { const hiddenModels = new Set(preferences.hiddenModels); return sortModelsForProviderInstance( options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)), { modelOrder: preferences.modelOrder }, );} export function normalizeCustomModelSlugs( models: Iterable<string | null | undefined>, builtInModelSlugs: ReadonlySet<string>,): string[] { const normalizedModels: string[] = []; const seen = new Set<string>(); for (const candidate of models) { const normalized = normalizeCustomModelSlug(candidate); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || builtInModelSlugs.has(normalized) || seen.has(normalized) ) { continue; } seen.add(normalized); normalizedModels.push(normalized); if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { break; } } return normalizedModels;} export function getAppModelOptions( settings: UnifiedSettings, providers: ReadonlyArray<ServerProvider>, provider: ProviderDriverKind, _selectedModel?: string | null,): AppModelOption[] { const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(getProviderModels(providers, provider), (model) => model.isCustom ? Result.failVoid : Result.succeed(model.slug), ), ); // Read from the default instance's config first (that's where edits // now land), falling back to the legacy per-kind bucket so unmigrated // settings and the initial render before the first write both still // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; } seen.add(slug); options.push({ slug, name: slug, isCustom: true, }); } return applyInstanceModelPreferences( options, readInstanceModelPreferences(settings, defaultInstanceId), );} /** * Instance-scoped variant of {@link getAppModelOptions}. Built-in models * come from the instance's own `entry.models` snapshot (rather than the * first-matching-kind fallback in `getProviderModels`), so each custom * instance gets the precise model list its driver reported. Custom model * slugs come from the instance's own `providerInstances[id].config.customModels` * when present, falling back to the legacy per-kind * `settings.providers[driverKind].customModels` bucket for default * instances only. This keeps two instances of the same kind from leaking * custom slugs into each other. */export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry,): AppModelOption[] { const options: AppModelOption[] = entry.models.map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(entry.models, (model) => model.isCustom ? Result.failVoid : Result.succeed(model.slug), ), ); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { if (seen.has(slug)) { continue; } seen.add(slug); options.push({ slug, name: slug, isCustom: true }); } return applyInstanceModelPreferences( options, readInstanceModelPreferences(settings, entry.instanceId), );} export function resolveAppModelSelection( provider: ProviderDriverKind, settings: UnifiedSettings, providers: ReadonlyArray<ServerProvider>, selectedModel: string | null | undefined,): string { const resolvedProvider = resolveSelectableProvider(providers, provider); const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); return ( resolveSelectableModel(resolvedProvider, selectedModel, options) ?? getDefaultServerModel(providers, resolvedProvider) );} export function resolveAppModelSelectionForInstance( instanceId: ProviderInstanceId, settings: UnifiedSettings, providers: ReadonlyArray<ServerProvider>, selectedModel: string | null | undefined,): string | null { const entry = deriveProviderInstanceEntries(providers).find( (candidate) => candidate.instanceId === instanceId, ); if (!entry) return null; const options = getAppModelOptionsForInstance(settings, entry); return ( resolveSelectableModel(entry.driverKind, selectedModel, options) ?? options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? entry.models.find((model) => model.isDefault)?.slug ?? entry.models[0]?.slug ?? null );} /** * Instance-keyed model options map. Each configured instance gets its own * option list so the model picker can show the same driver's built-in and * custom instances side by side without collapsing them. */export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray<ServerProvider>, _selectedInstanceId?: ProviderInstanceId | null, _selectedModel?: string | null,): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> { const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>(); for (const entry of deriveProviderInstanceEntries(providers)) { out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); } return out;} export function resolveAppModelSelectionState( settings: UnifiedSettings, providers: ReadonlyArray<ServerProvider>,): ModelSelection { const selection = settings.textGenerationModelSelection ?? { instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID, model: DEFAULT_GIT_TEXT_GENERATION_MODEL, }; const entries = deriveProviderInstanceEntries(providers); const selectedEntry = entries.find( (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable, ); const entry = selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable); if (entry) { // When the instance changed due to fallback (e.g. selected instance was disabled), // don't carry over the old instance's model — use the fallback instance's default. const selectedModel = selectedEntry ? selection.model : null; const model = resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ?? entry.models[0]?.slug ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; if (!model) { return createModelSelection(entry.instanceId, "", []); } const provider = entry.driverKind; const { modelOptionsForDispatch } = getComposerProviderState({ provider, model, models: entry.models, modelOptions: selectedEntry ? selection.options : undefined, }); return createModelSelection(entry.instanceId, model, modelOptionsForDispatch); } const provider = resolveSelectableProvider(providers, null); const keptSelectedProvider = false; // When the provider changed due to fallback (e.g. selected provider was disabled), // don't carry over the old provider's model — use the fallback provider's default. const selectedModel = keptSelectedProvider ? selection.model : null; const model = resolveAppModelSelection(provider, settings, providers, selectedModel); const { modelOptionsForDispatch } = getComposerProviderState({ provider, model, models: getProviderModels(providers, provider), modelOptions: keptSelectedProvider ? selection.options : undefined, }); return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);}
import {
DEFAULT_GIT_TEXT_GENERATION_MODEL,
DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
defaultInstanceIdForDriver,
type ModelSelection,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
import {
createModelSelection,
normalizeCustomModelSlug,
resolveSelectableModel,
} from "@t3tools/shared/model";
import { getComposerProviderState } from "./components/chat/composerProviderState";
import { UnifiedSettings } from "@t3tools/contracts/settings";
import * as Arr from "effect/Array";
import * as Result from "effect/Result";
import {
getDefaultServerModel,
getProviderModels,
resolveSelectableProvider,
} from "./providerModels";
import { ModelEsque } from "./components/chat/providerIconUtils";
import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";
import { sortModelsForProviderInstance } from "./modelOrdering";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex");
/**
* Resolve the custom-model list for a given instance, preferring the
* instance's own `providerInstances[id].config.customModels` blob when
* present and falling back to the legacy per-kind
* `settings.providers[kind].customModels` bucket for default instances only.
*
* The Settings UI promotes the legacy bucket into an explicit
* `providerInstances[defaultId]` entry on every edit (the "migrate on
* first write" scheme documented in
* `ProviderInstanceRegistryHydration`), so this helper exists primarily
* so readers pick up that promotion immediately — and so first-time
* viewers on pre-migration settings still see their legacy list on
* default slots. Custom instances intentionally do not read the legacy
* per-driver bucket; otherwise one custom model added to `claude_openrouter`
* can appear on the stock `claudeAgent` instance.
*/
function readInstanceCustomModels(
settings: UnifiedSettings,
instanceId: ProviderInstanceId,
driverKind: ProviderDriverKind,
): ReadonlyArray<string> {
const instance = settings.providerInstances?.[instanceId];
const config = instance?.config;
if (config !== null && typeof config === "object") {
const value = (config as Record<string, unknown>).customModels;
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === "string");
}
}
const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
if (instanceId !== defaultInstanceId) {
return [];
}
const legacyProviders = settings.providers as Record<
string,
{ readonly customModels: ReadonlyArray<string> } | undefined
>;
return legacyProviders[driverKind]?.customModels ?? [];
}
export interface AppModelOption {
slug: string;
name: string;
shortName?: string;
subProvider?: string;
isCustom: boolean;
isDefault?: boolean;
}
function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption {
const option: AppModelOption = {
slug: model.slug,
name: model.name,
isCustom: model.isCustom,
};
if (model.shortName) option.shortName = model.shortName;
if (model.subProvider) option.subProvider = model.subProvider;
if (model.isDefault) option.isDefault = true;
return option;
}
function readInstanceModelPreferences(
settings: UnifiedSettings,
instanceId: ProviderInstanceId,
): { readonly hiddenModels: ReadonlyArray<string>; readonly modelOrder: ReadonlyArray<string> } {
return (
settings.providerModelPreferences?.[instanceId] ?? {
hiddenModels: [],
modelOrder: [],
}
);
}
function applyInstanceModelPreferences(
options: ReadonlyArray<AppModelOption>,
preferences: {
readonly hiddenModels: ReadonlyArray<string>;
readonly modelOrder: ReadonlyArray<string>;
},
): AppModelOption[] {
const hiddenModels = new Set(preferences.hiddenModels);
return sortModelsForProviderInstance(
options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)),
{ modelOrder: preferences.modelOrder },
);
}
export function normalizeCustomModelSlugs(
models: Iterable<string | null | undefined>,
builtInModelSlugs: ReadonlySet<string>,
): string[] {
const normalizedModels: string[] = [];
const seen = new Set<string>();
for (const candidate of models) {
const normalized = normalizeCustomModelSlug(candidate);
if (
!normalized ||
normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
builtInModelSlugs.has(normalized) ||
seen.has(normalized)
) {
continue;
}
seen.add(normalized);
normalizedModels.push(normalized);
if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
break;
}
}
return normalizedModels;
}
export function getAppModelOptions(
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
provider: ProviderDriverKind,
_selectedModel?: string | null,
): AppModelOption[] {
const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption);
const seen = new Set(options.map((option) => option.slug));
const builtInModelSlugs = new Set(
Arr.filterMap(getProviderModels(providers, provider), (model) =>
model.isCustom ? Result.failVoid : Result.succeed(model.slug),
),
);
// Read from the default instance's config first (that's where edits
// now land), falling back to the legacy per-kind bucket so unmigrated
// settings and the initial render before the first write both still
// see the user's authored custom models.
const defaultInstanceId = defaultInstanceIdForDriver(provider);
const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
if (seen.has(slug)) {
continue;
}
seen.add(slug);
options.push({
slug,
name: slug,
isCustom: true,
});
}
return applyInstanceModelPreferences(
options,
readInstanceModelPreferences(settings, defaultInstanceId),
);
}
/**
* Instance-scoped variant of {@link getAppModelOptions}. Built-in models
* come from the instance's own `entry.models` snapshot (rather than the
* first-matching-kind fallback in `getProviderModels`), so each custom
* instance gets the precise model list its driver reported. Custom model
* slugs come from the instance's own `providerInstances[id].config.customModels`
* when present, falling back to the legacy per-kind
* `settings.providers[driverKind].customModels` bucket for default
* instances only. This keeps two instances of the same kind from leaking
* custom slugs into each other.
*/
export function getAppModelOptionsForInstance(
settings: UnifiedSettings,
entry: ProviderInstanceEntry,
): AppModelOption[] {
const options: AppModelOption[] = entry.models.map(toAppModelOption);
const seen = new Set(options.map((option) => option.slug));
const builtInModelSlugs = new Set(
Arr.filterMap(entry.models, (model) =>
model.isCustom ? Result.failVoid : Result.succeed(model.slug),
),
);
const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
if (seen.has(slug)) {
continue;
}
seen.add(slug);
options.push({ slug, name: slug, isCustom: true });
}
return applyInstanceModelPreferences(
options,
readInstanceModelPreferences(settings, entry.instanceId),
);
}
export function resolveAppModelSelection(
provider: ProviderDriverKind,
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
selectedModel: string | null | undefined,
): string {
const resolvedProvider = resolveSelectableProvider(providers, provider);
const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel);
return (
resolveSelectableModel(resolvedProvider, selectedModel, options) ??
getDefaultServerModel(providers, resolvedProvider)
);
}
export function resolveAppModelSelectionForInstance(
instanceId: ProviderInstanceId,
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
selectedModel: string | null | undefined,
): string | null {
const entry = deriveProviderInstanceEntries(providers).find(
(candidate) => candidate.instanceId === instanceId,
);
if (!entry) return null;
const options = getAppModelOptionsForInstance(settings, entry);
return (
resolveSelectableModel(entry.driverKind, selectedModel, options) ??
options.find((option) => option.isDefault)?.slug ??
options[0]?.slug ??
entry.models.find((model) => model.isDefault)?.slug ??
entry.models[0]?.slug ??
null
);
}
/**
* Instance-keyed model options map. Each configured instance gets its own
* option list so the model picker can show the same driver's built-in and
* custom instances side by side without collapsing them.
*/
export function getCustomModelOptionsByInstance(
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
_selectedInstanceId?: ProviderInstanceId | null,
_selectedModel?: string | null,
): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
const out = new Map<ProviderInstanceId, ReadonlyArray<ModelEsque>>();
for (const entry of deriveProviderInstanceEntries(providers)) {
out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry));
}
return out;
}
export function resolveAppModelSelectionState(
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
): ModelSelection {
const selection = settings.textGenerationModelSelection ?? {
instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
};
const entries = deriveProviderInstanceEntries(providers);
const selectedEntry = entries.find(
(entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
);
const entry =
selectedEntry ?? entries.find((candidate) => candidate.enabled && candidate.isAvailable);
if (entry) {
// When the instance changed due to fallback (e.g. selected instance was disabled),
// don't carry over the old instance's model — use the fallback instance's default.
const selectedModel = selectedEntry ? selection.model : null;
const model =
resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??
entry.models[0]?.slug ??
DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
if (!model) {
return createModelSelection(entry.instanceId, "", []);
}
const provider = entry.driverKind;
const { modelOptionsForDispatch } = getComposerProviderState({
provider,
model,
models: entry.models,
modelOptions: selectedEntry ? selection.options : undefined,
});
return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
}
const provider = resolveSelectableProvider(providers, null);
const keptSelectedProvider = false;
// When the provider changed due to fallback (e.g. selected provider was disabled),
// don't carry over the old provider's model — use the fallback provider's default.
const selectedModel = keptSelectedProvider ? selection.model : null;
const model = resolveAppModelSelection(provider, settings, providers, selectedModel);
const { modelOptionsForDispatch } = getComposerProviderState({
provider,
model,
models: getProviderModels(providers, provider),
modelOptions: keptSelectedProvider ? selection.options : undefined,
});
return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);
}