1import {
2 type ProviderInstanceId,
3 type ServerProvider,
4 ServerProvider as ServerProviderSchema,
5} from "@t3tools/contracts";
6import { causeErrorTag } from "@t3tools/shared/observability";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11
12import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
13
14const decodeProviderStatusCache = Schema.decodeUnknownEffect(
15 Schema.fromJsonString(ServerProviderSchema),
16);
17
18const 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 */
36const BUILT_IN_DRIVER_ORDER: ReadonlyArray<string> = [
37 "codex",
38 "claudeAgent",
39 "cursor",
40 "grok",
41 "opencode",
42 "antigravity",
43];
44
45const 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
50export 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
61export 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
68export 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 */
115export 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
125export 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
151export 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};