| 1 | import { |
| 2 | type ProviderInstanceId, |
| 3 | type ServerProvider, |
| 4 | ServerProvider as ServerProviderSchema, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | |
| 12 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; |
| 13 | |
| 14 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( |
| 15 | Schema.fromJsonString(ServerProviderSchema), |
| 16 | ); |
| 17 | |
| 18 | const mergeProviderModels = ( |
| 19 | fallbackModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 20 | cachedModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 21 | ): ReadonlyArray<ServerProvider["models"][number]> => { |
| 22 | const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); |
| 23 | // The fallback snapshot is built from current settings and already carries |
| 24 | // every custom model, so cached custom rows that are not in it were removed |
| 25 | // while the cache was stale and must not come back. |
| 26 | return [ |
| 27 | ...fallbackModels, |
| 28 | ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), |
| 29 | ]; |
| 30 | }; |
| 31 | |
| 32 | /** |
| 33 | * Built-in drivers in presentation order. Codex and Claude lead, the opt-in |
| 34 | * providers follow, and unknown or fork drivers sort after every built-in. |
| 35 | */ |
| 36 | const BUILT_IN_DRIVER_ORDER: ReadonlyArray<string> = [ |
| 37 | "codex", |
| 38 | "claudeAgent", |
| 39 | "cursor", |
| 40 | "grok", |
| 41 | "opencode", |
| 42 | "antigravity", |
| 43 | ]; |
| 44 | |
| 45 | const driverRank = (driver: string): number => { |
| 46 | const index = BUILT_IN_DRIVER_ORDER.indexOf(driver); |
| 47 | return index === -1 ? BUILT_IN_DRIVER_ORDER.length : index; |
| 48 | }; |
| 49 | |
| 50 | export const orderProviderSnapshots = ( |
| 51 | providers: ReadonlyArray<ServerProvider>, |
| 52 | ): ReadonlyArray<ServerProvider> => |
| 53 | [...providers].toSorted( |
| 54 | (left, right) => |
| 55 | driverRank(left.driver) - driverRank(right.driver) || |
| 56 | left.driver.localeCompare(right.driver) || |
| 57 | (left.displayName ?? "").localeCompare(right.displayName ?? "") || |
| 58 | left.instanceId.localeCompare(right.instanceId), |
| 59 | ); |
| 60 | |
| 61 | export const isCachedProviderCorrelated = (input: { |
| 62 | readonly cachedProvider: ServerProvider; |
| 63 | readonly fallbackProvider: ServerProvider; |
| 64 | }): boolean => |
| 65 | input.cachedProvider.instanceId === input.fallbackProvider.instanceId && |
| 66 | input.cachedProvider.driver === input.fallbackProvider.driver; |
| 67 | |
| 68 | export const hydrateCachedProvider = (input: { |
| 69 | readonly cachedProvider: ServerProvider; |
| 70 | readonly fallbackProvider: ServerProvider; |
| 71 | }): ServerProvider => { |
| 72 | if (!isCachedProviderCorrelated(input)) { |
| 73 | return input.fallbackProvider; |
| 74 | } |
| 75 | |
| 76 | if ( |
| 77 | !input.fallbackProvider.enabled || |
| 78 | input.cachedProvider.enabled !== input.fallbackProvider.enabled |
| 79 | ) { |
| 80 | return input.fallbackProvider; |
| 81 | } |
| 82 | |
| 83 | const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; |
| 84 | const hydratedProvider: ServerProvider = { |
| 85 | ...fallbackWithoutMessage, |
| 86 | models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), |
| 87 | installed: input.cachedProvider.installed, |
| 88 | version: input.cachedProvider.version, |
| 89 | status: input.cachedProvider.status, |
| 90 | auth: input.cachedProvider.auth, |
| 91 | checkedAt: input.cachedProvider.checkedAt, |
| 92 | slashCommands: input.cachedProvider.slashCommands, |
| 93 | skills: input.cachedProvider.skills, |
| 94 | }; |
| 95 | |
| 96 | return input.cachedProvider.message |
| 97 | ? { ...hydratedProvider, message: input.cachedProvider.message } |
| 98 | : hydratedProvider; |
| 99 | }; |
| 100 | |
| 101 | /** |
| 102 | * Resolve the on-disk cache path for a provider instance snapshot. |
| 103 | * |
| 104 | * File naming: `<cacheDir>/<instanceId>.json`. For the default instance of |
| 105 | * a built-in kind this equals the legacy `<kind>.json` path (because |
| 106 | * `defaultInstanceIdForDriver(kind).toString() === kind`), so existing |
| 107 | * cached snapshots remain readable without any rename step. |
| 108 | * |
| 109 | * Non-default instances (e.g. `codex_personal`) land in their own files and |
| 110 | * never collide with other instances. |
| 111 | * |
| 112 | * Cache contents must still carry matching `instanceId` + `driver` identity |
| 113 | * before hydration. The filename alone is not trusted as a routing key. |
| 114 | */ |
| 115 | export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( |
| 116 | function* (input: { |
| 117 | readonly cacheDir: string; |
| 118 | readonly instanceId: ProviderInstanceId; |
| 119 | }): Effect.fn.Return<string, never, Path.Path> { |
| 120 | const path = yield* Path.Path; |
| 121 | return path.join(input.cacheDir, `${input.instanceId}.json`); |
| 122 | }, |
| 123 | ); |
| 124 | |
| 125 | export const readProviderStatusCache = (filePath: string) => |
| 126 | Effect.gen(function* () { |
| 127 | const fs = yield* FileSystem.FileSystem; |
| 128 | const exists = yield* fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)); |
| 129 | if (!exists) { |
| 130 | return undefined; |
| 131 | } |
| 132 | |
| 133 | const raw = yield* fs.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); |
| 134 | const trimmed = raw.trim(); |
| 135 | if (trimmed.length === 0) { |
| 136 | return undefined; |
| 137 | } |
| 138 | |
| 139 | return yield* decodeProviderStatusCache(trimmed).pipe( |
| 140 | Effect.matchCauseEffect({ |
| 141 | onFailure: (cause) => |
| 142 | Effect.logWarning("failed to parse provider status cache, ignoring", { |
| 143 | path: filePath, |
| 144 | errorTag: causeErrorTag(cause), |
| 145 | }).pipe(Effect.as(undefined)), |
| 146 | onSuccess: Effect.succeed, |
| 147 | }), |
| 148 | ); |
| 149 | }); |
| 150 | |
| 151 | export const writeProviderStatusCache = (input: { |
| 152 | readonly filePath: string; |
| 153 | readonly provider: ServerProvider; |
| 154 | }) => { |
| 155 | const { updateState: _updateState, ...cacheableProvider } = input.provider; |
| 156 | return writeFileStringAtomically({ |
| 157 | filePath: input.filePath, |
| 158 | contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`, |
| 159 | }); |
| 160 | }; |