| 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 | */ |
| 15 | import { |
| 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"; |
| 28 | import { |
| 29 | normalizeProviderAccentColor, |
| 30 | resolveProviderInstanceDisplayName, |
| 31 | shouldShowInstanceBadge, |
| 32 | } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 33 | |
| 34 | export { 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 | */ |
| 41 | export 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 | */ |
| 52 | export 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 | |
| 77 | export 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 | */ |
| 85 | export 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 | */ |
| 101 | export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { |
| 102 | return entry.enabled && entry.isAvailable && entry.status === "ready"; |
| 103 | } |
| 104 | |
| 105 | /** Picker rails contain configured, enabled instances only. */ |
| 106 | export 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 | */ |
| 117 | export 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 | */ |
| 155 | export 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 | */ |
| 197 | export 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 | */ |
| 239 | export 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 | */ |
| 268 | export 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 | */ |
| 282 | export 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 | |
| 296 | const 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 | */ |
| 306 | export 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 | */ |
| 328 | export 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 | */ |
| 342 | export 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 | } |