1import { useAtomValue } from "@effect/atom-react";
2import { providerClients } from "@t3tools/client-runtime/provider-clients";
3import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
4import {
5 type EnvironmentId,
6 type ModelSelection,
7 type ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 type ServerProvider,
11 type ServerProviderModel,
12 type ServerSettings,
13 type TextGeneratorConfig,
14 type TextGeneratorKind,
15 type TextGeneratorModel,
16} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import { AsyncResult, Atom } from "effect/reactivity";
20
21import {
22 deriveProviderInstanceEntries,
23 type ProviderInstanceEntry,
24 providerInstanceEntriesFor,
25} from "./providerInstances";
26import { appAtomRegistry } from "./rpc/atomRegistry";
27import { formatEnvironmentQueryError } from "./state/query";
28import { serverEnvironment } from "./state/server";
29
30export interface TextGeneratorPreset {
31 readonly id: "openai" | "openrouter" | "anthropic" | "custom";
32 readonly label: string;
33 readonly kind: TextGeneratorKind;
34 /** Empty for a custom server, whose URL the user enters. */
35 readonly baseUrl: string;
36}
37
38export const TEXT_GENERATOR_PRESETS: ReadonlyArray<TextGeneratorPreset> = [
39 { id: "openai", label: "OpenAI", kind: "openaiCompatible", baseUrl: "https://api.openai.com/v1" },
40 {
41 id: "openrouter",
42 label: "OpenRouter",
43 kind: "openaiCompatible",
44 baseUrl: "https://openrouter.ai/api/v1",
45 },
46 {
47 id: "anthropic",
48 label: "Anthropic",
49 kind: "anthropic",
50 baseUrl: "https://api.anthropic.com/v1",
51 },
52 { id: "custom", label: "Custom (OpenAI-compatible)", kind: "openaiCompatible", baseUrl: "" },
53];
54
55export function textGeneratorDisplayName(config: TextGeneratorConfig): string {
56 if (config.label) return config.label;
57 const preset = TEXT_GENERATOR_PRESETS.find(
58 (candidate) => candidate.baseUrl === config.baseUrl && candidate.kind === config.kind,
59 );
60 if (preset) return preset.label;
61 try {
62 return new URL(config.baseUrl).host;
63 } catch {
64 return config.baseUrl;
65 }
66}
67
68/**
69 * The service a text generator reaches, which picks its picker icon. It stands in for the driver
70 * kind in picker entries only, so generators of one service share an icon and get instance badges.
71 */
72export function textGeneratorBrand(config: Pick<TextGeneratorConfig, "kind" | "baseUrl">) {
73 if (config.kind === "anthropic") return ProviderDriverKind.make("anthropic");
74 let url: URL | null = null;
75 try {
76 url = new URL(config.baseUrl);
77 } catch {}
78 const host = url?.hostname ?? "";
79 if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) {
80 return ProviderDriverKind.make("openrouter");
81 }
82 if (host === "api.openai.com") return ProviderDriverKind.make("openai");
83 if (url?.port === "11434" || host.includes("ollama")) return ProviderDriverKind.make("ollama");
84 return ProviderDriverKind.make("openaiCompatible");
85}
86
87/**
88 * A new text generator id. Selections name a text generator by the same id as a provider
89 * instance, so it must not match a configured instance, a driver's default instance, or
90 * another text generator.
91 */
92export function nextTextGeneratorId(
93 base: string,
94 settings: Pick<ServerSettings, "providerInstances" | "textGenerators">,
95 providers: ReadonlyArray<Pick<ServerProvider, "instanceId">>,
96): ProviderInstanceId {
97 const taken = new Set<string>([
98 ...Object.keys(settings.providerInstances),
99 ...Object.keys(settings.textGenerators),
100 ...providers.map((provider) => provider.instanceId),
101 ...providerClients.definitions.map((definition) => definition.driverKind),
102 ]);
103 let id = base;
104 for (let suffix = 2; taken.has(id); suffix += 1) {
105 id = `${base}-${suffix}`;
106 }
107 return ProviderInstanceId.make(id);
108}
109
110type ModelsKey = readonly [
111 environmentId: EnvironmentId,
112 id: ProviderInstanceId,
113 // The connection settings, so editing them asks again instead of reusing a stale list.
114 kind: TextGeneratorKind,
115 baseUrl: string,
116 apiKey: TextGeneratorConfig["apiKey"],
117];
118
119const refreshCountAtom = Atom.family((key: string) =>
120 Atom.make(0).pipe(Atom.keepAlive, Atom.withLabel(`text-generator-refresh:${key}`)),
121);
122
123const textGeneratorModelsAtom = Atom.family((key: string) => {
124 const [environmentId, id] = JSON.parse(key) as ModelsKey;
125 return Atom.make((get) => {
126 // Read again after a refresh; the server's cache already holds the new list by then.
127 get(refreshCountAtom(`${environmentId}:${id}`));
128 return Effect.promise(() =>
129 runAtomCommand(
130 get.registry,
131 serverEnvironment.listTextGeneratorModels,
132 { environmentId, input: { id } },
133 { reportFailure: false },
134 ),
135 ).pipe(
136 Effect.flatMap((result) =>
137 result._tag === "Success"
138 ? Effect.succeed(result.value.models)
139 : Effect.fail(formatEnvironmentQueryError(result.cause)),
140 ),
141 );
142 }).pipe(Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`text-generator-models:${key}`));
143});
144
145const textGeneratorCatalogAtom = Atom.family((keys: string) =>
146 Atom.make((get) =>
147 (JSON.parse(keys) as ReadonlyArray<string>).map((key) => get(textGeneratorModelsAtom(key))),
148 ).pipe(Atom.withLabel(`text-generator-catalog:${keys}`)),
149);
150
151/**
152 * Has open pickers ask the server again. Needed after a saved key changes: clients only see the
153 * redaction marker, so the cache key does not change with it.
154 */
155export function rereadTextGeneratorModels(environmentId: EnvironmentId, id: ProviderInstanceId) {
156 const atom = refreshCountAtom(`${environmentId}:${id}`);
157 appAtomRegistry.set(atom, appAtomRegistry.get(atom) + 1);
158}
159
160/**
161 * Asks the API for its model list again, skipping the server's cache, then has open pickers
162 * read it. Resolves to the model count, or the error to show.
163 */
164export async function refreshTextGeneratorModels(
165 environmentId: EnvironmentId,
166 id: ProviderInstanceId,
167): Promise<{ readonly count: number } | { readonly error: string }> {
168 const result = await runAtomCommand(
169 appAtomRegistry,
170 serverEnvironment.listTextGeneratorModels,
171 { environmentId, input: { id, refresh: true } },
172 { reportFailure: false },
173 );
174 rereadTextGeneratorModels(environmentId, id);
175 return result._tag === "Success"
176 ? { count: result.value.models.length }
177 : { error: formatEnvironmentQueryError(result.cause) };
178}
179
180/** Lists the models unsaved settings reach, to check them before saving. */
181export async function checkTextGeneratorConfig(
182 environmentId: EnvironmentId,
183 id: ProviderInstanceId,
184 config: TextGeneratorConfig,
185): Promise<{ readonly models: ReadonlyArray<TextGeneratorModel> } | { readonly error: string }> {
186 const result = await runAtomCommand(
187 appAtomRegistry,
188 serverEnvironment.listTextGeneratorModels,
189 { environmentId, input: { id, config } },
190 { reportFailure: false },
191 );
192 return result._tag === "Success"
193 ? { models: result.value.models }
194 : { error: formatEnvironmentQueryError(result.cause) };
195}
196
197/**
198 * Has a saved generator's model answer a tiny prompt. Some APIs list models without checking the
199 * key, so only this proves the key, endpoint, and model together. Resolves to the error, if any.
200 */
201export async function testTextGeneratorModel(
202 environmentId: EnvironmentId,
203 id: ProviderInstanceId,
204 model: string,
205): Promise<string | null> {
206 const result = await runAtomCommand(
207 appAtomRegistry,
208 serverEnvironment.listTextGeneratorModels,
209 { environmentId, input: { id, test: true, model } },
210 { reportFailure: false },
211 );
212 return result._tag === "Success" ? null : formatEnvironmentQueryError(result.cause);
213}
214
215/** A saved text generator's model list, shared with the pickers. */
216export function useTextGeneratorModels(
217 environmentId: EnvironmentId,
218 id: ProviderInstanceId,
219 config: TextGeneratorConfig,
220): AsyncResult.AsyncResult<ReadonlyArray<TextGeneratorModel>, string> {
221 return useAtomValue(
222 textGeneratorModelsAtom(
223 JSON.stringify([
224 environmentId,
225 id,
226 config.kind,
227 config.baseUrl,
228 config.apiKey,
229 ] satisfies ModelsKey),
230 ),
231 );
232}
233
234function toProviderModel(model: TextGeneratorModel): ServerProviderModel {
235 return { slug: model.id, name: model.name, isCustom: false, capabilities: null };
236}
237
238/** Picker stand-in for a text generator. It never leaves the settings pickers. */
239function textGeneratorSnapshot(
240 id: ProviderInstanceId,
241 config: TextGeneratorConfig,
242 result: AsyncResult.AsyncResult<ReadonlyArray<TextGeneratorModel>, string>,
243 selectedModels: ReadonlyArray<string>,
244): ServerProvider {
245 const discovered = AsyncResult.isSuccess(result) ? result.value.map(toProviderModel) : [];
246 const missing = [...new Set(selectedModels)]
247 .filter((slug) => !discovered.some((model) => model.slug === slug))
248 .map((slug): ServerProviderModel => ({
249 slug,
250 name: slug,
251 isCustom: false,
252 capabilities: null,
253 }));
254 const failure = Option.getOrUndefined(AsyncResult.error(result));
255 return {
256 instanceId: id,
257 driver: textGeneratorBrand(config),
258 displayName: textGeneratorDisplayName(config),
259 supportsTextGeneration: true,
260 enabled: config.enabled,
261 installed: true,
262 version: null,
263 status: failure === undefined ? "ready" : "error",
264 ...(failure === undefined ? {} : { message: failure }),
265 auth: { status: "unknown" },
266 checkedAt: new Date(0).toISOString(),
267 models: [...discovered, ...missing],
268 slashCommands: [],
269 skills: [],
270 };
271}
272
273/**
274 * The environment's text generation providers plus its enabled text generators, for the pickers
275 * that may write text. Text generators replace a provider instance with the same id, as the
276 * server does. Selections listed in `selections` keep their model while the list loads.
277 */
278export function useTextGenerationTargets(input: {
279 readonly environmentId: EnvironmentId | null;
280 readonly settings: Pick<
281 ServerSettings,
282 "providerInstances" | "projectSettingsOverrides" | "textGenerators"
283 >;
284 readonly providers: ReadonlyArray<ServerProvider>;
285 readonly projectId: ProjectId | null;
286 readonly selections: ReadonlyArray<ModelSelection | null | undefined>;
287}): {
288 readonly providers: ReadonlyArray<ServerProvider>;
289 readonly entries: ReadonlyArray<ProviderInstanceEntry>;
290} {
291 const { environmentId, settings } = input;
292 const generators =
293 environmentId === null
294 ? []
295 : (
296 Object.entries(settings.textGenerators) as Array<
297 [ProviderInstanceId, TextGeneratorConfig]
298 >
299 )
300 .filter(([, config]) => config.enabled)
301 .map(([id, config]) => ({
302 id,
303 config,
304 key: JSON.stringify([
305 environmentId,
306 id,
307 config.kind,
308 config.baseUrl,
309 config.apiKey,
310 ] satisfies ModelsKey),
311 }));
312 const results = useAtomValue(
313 textGeneratorCatalogAtom(JSON.stringify(generators.map((generator) => generator.key))),
314 );
315 const generatorProviders = generators.map(({ id, config }, index) =>
316 textGeneratorSnapshot(
317 id,
318 config,
319 results[index] ?? AsyncResult.initial(),
320 input.selections.flatMap((selection) =>
321 selection?.instanceId === id && selection.model ? [selection.model] : [],
322 ),
323 ),
324 );
325 const providers = input.providers.filter(
326 (provider) => !Object.hasOwn(settings.textGenerators, provider.instanceId),
327 );
328 return {
329 providers: [...providers, ...generatorProviders],
330 // Project provider overrides do not apply to text generators, so their entries skip them.
331 entries: [
332 ...providerInstanceEntriesFor(providers, settings, input.projectId),
333 ...deriveProviderInstanceEntries(generatorProviders),
334 ],
335 };
336}