1/**
2 * Instance-aware view over the wire `ServerProvider[]`.
3 *
4 * The wire carries one `ServerProvider` per *configured instance* — the
5 * default built-in codex instance, a user-authored `codex_personal`, an
6 * unavailable shadow for a fork driver, etc. Legacy UI code collapsed these
7 * into a single bucket per built-in driver via `.find((p) => p.driver === kind)`,
8 * which silently dropped every custom instance after the first. This module
9 * replaces that pattern with `ProviderInstanceEntry[]`, keyed on
10 * `ProviderInstanceId`, so the model picker, settings list, and composer
11 * can treat built-in and custom instances uniformly.
12 *
13 * @module providerInstances
14 */
15import {
16 DEFAULT_MODEL_BY_PROVIDER,
17 defaultInstanceIdForDriver,
18 resolveProjectProviderInstanceEnabled,
19 type ModelSelection,
20 type ProjectId,
21 type ProviderDriverKind,
22 ProviderInstanceId,
23 type ServerProvider,
24 type ServerProviderModel,
25 type ServerSettings,
26 type ServerProviderState,
27} from "@t3tools/contracts";
28import {
29 normalizeProviderAccentColor,
30 resolveProviderInstanceDisplayName,
31 shouldShowInstanceBadge,
32} from "@t3tools/client-runtime/state/provider-instance-display";
33
34export { normalizeProviderAccentColor, shouldShowInstanceBadge };
35
36/**
37 * Local-only placeholder used while a draft has no provider it can safely
38 * target. It must never be persisted or dispatched; the composer disables
39 * send until a live provider replaces it.
40 */
41export const NO_PROVIDER_MODEL_SELECTION: ModelSelection = {
42 instanceId: ProviderInstanceId.make("t3code_no_provider"),
43 model: "",
44};
45
46/**
47 * UI-facing projection of one configured provider instance. Carries the
48 * snapshot verbatim for callers that need server-side fields we don't
49 * hoist here, plus the precomputed `instanceId` / `driverKind` /
50 * `displayName` used by every picker and settings view.
51 */
52export interface ProviderInstanceEntry {
53 readonly instanceId: ProviderInstanceId;
54 readonly driverKind: ProviderDriverKind;
55 readonly displayName: string;
56 readonly accentColor?: string | undefined;
57 /** Registry identity used to resolve the official icon for generic ACP instances. */
58 readonly acpRegistryAgentId?: string | undefined;
59 /** Catalog-advertised icon URL. The renderer still applies the official-CDN allowlist. */
60 readonly acpRegistryIconUrl?: string | undefined;
61 readonly continuationGroupKey?: string | undefined;
62 readonly enabled: boolean;
63 readonly installed: boolean;
64 readonly status: ServerProviderState;
65 /**
66 * True when this entry is the default instance for its driver kind —
67 * i.e. its instance id equals `defaultInstanceIdForDriver(driverKind)`.
68 * The settings panel and picker sort defaults before customs.
69 */
70 readonly isDefault: boolean;
71 /** True when `availability === "unavailable"` is absent or "available". */
72 readonly isAvailable: boolean;
73 readonly snapshot: ServerProvider;
74 readonly models: ReadonlyArray<ServerProviderModel>;
75}
76
77export type ProviderCatalogAvailability = "loading" | "ready" | "unavailable" | "unconfigured";
78
79/**
80 * Keep a provider catalogue that has not arrived yet distinct from a loaded
81 * catalogue with no usable entries. Environment config streams are reactive:
82 * treating their initial `null` as a final empty list strands otherwise
83 * recoverable threads behind a misleading "no providers" state.
84 */
85export function resolveProviderCatalogAvailability(input: {
86 readonly catalogLoaded: boolean;
87 readonly entries: ReadonlyArray<ProviderInstanceEntry>;
88 readonly selectedEntry: ProviderInstanceEntry | undefined;
89}): ProviderCatalogAvailability {
90 if (!input.catalogLoaded) return "loading";
91 if (input.selectedEntry !== undefined) return "ready";
92 return input.entries.length === 0 ? "unconfigured" : "unavailable";
93}
94
95/**
96 * Whether an instance can currently contribute models to an interactive picker.
97 *
98 * Disabling an instance updates `enabled` independently, while its previous
99 * `ready` probe status can remain in the streamed snapshot until reconciliation.
100 */
101export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean {
102 return entry.enabled && entry.isAvailable && entry.status === "ready";
103}
104
105/** Picker rails contain configured, enabled instances only. */
106export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean {
107 return entry.enabled;
108}
109
110/**
111 * Project the wire `ServerProvider[]` into instance entries, one per
112 * configured instance. Preserves the server's ordering (which sources
113 * from `deriveProviderInstanceConfigMap` — explicit `providerInstances.*`
114 * first, synthesized defaults after) so callers that want "default first"
115 * should sort with `sortProviderInstanceEntries` below.
116 */
117export function deriveProviderInstanceEntries(
118 providers: ReadonlyArray<ServerProvider>,
119): ReadonlyArray<ProviderInstanceEntry> {
120 return providers.map((snapshot) => {
121 const instanceId = snapshot.instanceId;
122 const driverKind = snapshot.driver;
123 const defaultId = defaultInstanceIdForDriver(driverKind);
124 const isDefault = instanceId === defaultId;
125 return {
126 instanceId,
127 driverKind,
128 displayName: resolveProviderInstanceDisplayName(snapshot),
129 accentColor: normalizeProviderAccentColor(snapshot.accentColor),
130 ...(driverKind === "acpRegistry" && snapshot.iconUrl
131 ? { acpRegistryIconUrl: snapshot.iconUrl }
132 : {}),
133 continuationGroupKey: snapshot.continuation?.groupKey,
134 enabled: snapshot.enabled,
135 installed: snapshot.installed,
136 status: snapshot.status,
137 isDefault,
138 isAvailable: snapshot.availability !== "unavailable",
139 snapshot,
140 models: snapshot.models,
141 } satisfies ProviderInstanceEntry;
142 });
143}
144
145/**
146 * Project several environments' `ServerProvider[]` into a nested
147 * `environmentId → instanceId → entry` lookup.
148 *
149 * Instance ids are per-environment routing keys, and `defaultInstanceIdForDriver`
150 * makes the default id literally the driver slug, so every environment running
151 * the same driver reports the same id. Flattening across environments would
152 * clobber entries and mis-resolve accent colors; lookups must stay scoped to
153 * the thread's own environment.
154 */
155export function deriveProviderEntriesByEnvironment(
156 providersByEnvironment: Iterable<
157 readonly [
158 string,
159 ReadonlyArray<ServerProvider>,
160 Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">?,
161 ]
162 >,
163): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> {
164 const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>();
165 for (const [environmentId, providers, settings] of providersByEnvironment) {
166 const derived = deriveProviderInstanceEntries(providers);
167 // One map per environment serves every project with a thread there, so
168 // there is no single project to scope `enabled` to; it stays machine-wide.
169 const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived;
170 byEnvironment.set(
171 environmentId,
172 new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)),
173 );
174 }
175 return byEnvironment;
176}
177
178/**
179 * Overlay the current settings configuration onto streamed provider snapshots.
180 * Provider probes can briefly retain their previous `enabled` value after a
181 * settings write, so picker visibility must follow settings rather than waiting
182 * for probe reconciliation.
183 *
184 * A built-in default instance without a `providerInstances` entry keeps its
185 * streamed state, since the server runs it with default config. Any other
186 * instance missing from `providerInstances` is stale (for example
187 * immediately after deletion) and is treated as disabled.
188 *
189 * `projectId` folds in that project's `providerInstances[id].enabled` override
190 * (enable or disable in either direction over the machine value); `null`
191 * resolves the machine value only. It has no default: a caller that renders
192 * for a single project but forgets to pass it would silently show the
193 * machine's enablement instead of that project's, so passing `null` for a
194 * surface that isn't project-aware must be a deliberate choice, not an
195 * omission.
196 */
197export function applyProviderInstanceSettings(
198 entries: ReadonlyArray<ProviderInstanceEntry>,
199 settings: Pick<ServerSettings, "providerInstances" | "projectSettingsOverrides">,
200 projectId: ProjectId | null,
201): ReadonlyArray<ProviderInstanceEntry> {
202 return entries.map((entry) => {
203 const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId)
204 ? settings.providerInstances[entry.instanceId]
205 : undefined;
206 const enabled = resolveProjectProviderInstanceEnabled(settings, projectId, entry.instanceId);
207 if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) {
208 return enabled === entry.enabled ? entry : { ...entry, enabled };
209 }
210 const config =
211 explicitInstance.config !== null && typeof explicitInstance.config === "object"
212 ? (explicitInstance.config as Readonly<Record<string, unknown>>)
213 : null;
214 if (config?.source === "local") {
215 return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined };
216 }
217 const agentId = config?.agentId;
218 const iconUrl = config?.registryIconUrl;
219 return {
220 ...entry,
221 enabled,
222 ...(typeof agentId === "string" && agentId.trim()
223 ? { acpRegistryAgentId: agentId.trim() }
224 : {}),
225 ...(typeof iconUrl === "string" && iconUrl.trim()
226 ? { acpRegistryIconUrl: iconUrl.trim() }
227 : {}),
228 };
229 });
230}
231
232/**
233 * Sort instance entries so the default instance of each driver kind appears
234 * before any custom instances of the same kind. Within a kind, custom
235 * instances keep their settings-author order (which is how the server
236 * emits them). Stable across kinds: entries retain the server's
237 * cross-driver ordering.
238 */
239export function sortProviderInstanceEntries(
240 entries: ReadonlyArray<ProviderInstanceEntry>,
241): ReadonlyArray<ProviderInstanceEntry> {
242 // Group by driver kind preserving first-appearance order, then emit
243 // default-first within each kind. Using a Map keeps the "first-seen"
244 // semantics for kinds whose default instance is absent (unusual but
245 // possible during the migration).
246 const byKind = new Map<ProviderDriverKind, ProviderInstanceEntry[]>();
247 for (const entry of entries) {
248 const bucket = byKind.get(entry.driverKind);
249 if (bucket) {
250 bucket.push(entry);
251 } else {
252 byKind.set(entry.driverKind, [entry]);
253 }
254 }
255 const sorted: ProviderInstanceEntry[] = [];
256 for (const bucket of byKind.values()) {
257 const defaults = bucket.filter((entry) => entry.isDefault);
258 const customs = bucket.filter((entry) => !entry.isDefault);
259 sorted.push(...defaults, ...customs);
260 }
261 return sorted;
262}
263
264/**
265 * Look up a single instance entry by exact `instanceId`. Missing snapshots
266 * are not inferred from driver kind in UI routing code.
267 */
268export function getProviderInstanceEntry(
269 providers: ReadonlyArray<ServerProvider>,
270 instanceId: ProviderInstanceId,
271): ProviderInstanceEntry | undefined {
272 return deriveProviderInstanceEntries(providers).find((entry) => entry.instanceId === instanceId);
273}
274
275/**
276 * Default model slug for a specific instance: its declared built-in default,
277 * then its first built-in model, then any model it reports, then the driver-level default. Custom
278 * instances can serve a different model list than the default instance of
279 * the same driver kind, so the lookup must be instance-scoped rather than
280 * kind-scoped.
281 */
282export function getDefaultProviderInstanceModel(
283 providers: ReadonlyArray<ServerProvider>,
284 instanceId: ProviderInstanceId,
285): string | undefined {
286 const entry = getProviderInstanceEntry(providers, instanceId);
287 if (!entry) return undefined;
288 return (
289 entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ??
290 entry.models.find((model) => !model.isCustom)?.slug ??
291 entry.models[0]?.slug ??
292 DEFAULT_MODEL_BY_PROVIDER[entry.driverKind]
293 );
294}
295
296const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean =>
297 entry.enabled && entry.isAvailable;
298
299/**
300 * Resolve an exact stored instance when it remains enabled and available.
301 * Otherwise choose a deterministic fallback that can plausibly start now:
302 * ready first, then a non-error probe result. An errored provider is retained
303 * only when it was explicitly requested; it is never invented as a new-user
304 * default.
305 */
306export function resolveSelectableProviderInstanceEntry(
307 entries: ReadonlyArray<ProviderInstanceEntry>,
308 instanceId: ProviderInstanceId | undefined,
309): ProviderInstanceEntry | undefined {
310 if (instanceId !== undefined) {
311 const requested = entries.find((entry) => entry.instanceId === instanceId);
312 if (requested && isSelectableProviderInstanceEntry(requested)) {
313 return requested;
314 }
315 }
316 return (
317 entries.find(isProviderInstancePickerReady) ??
318 entries.find((entry) => isSelectableProviderInstanceEntry(entry) && entry.status !== "error")
319 );
320}
321
322/**
323 * Resolve the routing key for a selection that may reference an instance
324 * id that no longer exists (e.g. a persisted thread selection after the
325 * user deleted the custom instance). Returns a ready or non-error fallback,
326 * or `undefined` when no provider can safely become a new selection.
327 */
328export function resolveSelectableProviderInstance(
329 providers: ReadonlyArray<ServerProvider>,
330 instanceId: ProviderInstanceId | undefined,
331): ProviderInstanceId | undefined {
332 const entries = deriveProviderInstanceEntries(providers);
333 return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId;
334}
335
336/**
337 * Resolve the model selection persisted for a project or new thread. A valid
338 * stored selection is preserved byte-for-byte. Falling back to another
339 * instance also resets the model to that instance's own default, avoiding
340 * cross-provider instance/model pairs.
341 */
342export function resolveDefaultProviderModelSelection(
343 providers: ReadonlyArray<ServerProvider>,
344 selection: ModelSelection | null | undefined,
345): ModelSelection | null {
346 const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId);
347 if (instanceId === undefined) return null;
348 if (selection?.instanceId === instanceId) return selection;
349 const model = getDefaultProviderInstanceModel(providers, instanceId);
350 return model ? { instanceId, model } : null;
351}