1import type {
2 CustomModelSetting,
3 ProviderDriverKind,
4 ModelCapabilities,
5 RuntimeMode,
6 ServerProvider,
7 ServerProviderAuth,
8 ServerProviderSkill,
9 ServerProviderSlashCommand,
10 ServerProviderModel,
11 ServerProviderUpdateRequiredModel,
12 ServerProviderState,
13 ServerProviderUsageLimits,
14} from "@t3tools/contracts";
15import * as Effect from "effect/Effect";
16import * as PlatformError from "effect/PlatformError";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as ChildProcess from "effect/process/ChildProcess";
20import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
21import { readCustomModelEntries } from "@t3tools/shared/model";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import { createProviderVersionAdvisory } from "./maintenanceResolver.ts";
24import { collectUint8StreamText } from "./collectStreamText.ts";
25
26const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [
27 /is not recognized as an internal or external command/i,
28 /n.o . reconhecido como um comando interno/i,
29 /non . riconosciuto come comando interno o esterno/i,
30 /n.est pas reconnu en tant que commande interne/i,
31 /no se reconoce como un comando interno o externo/i,
32 /wird nicht als interner oder externer befehl/i,
33] as const;
34
35export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(function* (
36 code: number | null,
37 stderr: string,
38) {
39 const platform = yield* HostProcess.Platform;
40 if (platform !== "win32") return false;
41 if (code === 9009) return true;
42 return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(stderr));
43});
44
45export const DEFAULT_TIMEOUT_MS = 4_000;
46export const AUTH_PROBE_TIMEOUT_MS = 10_000;
47
48export const resolveProviderProbeTimeoutMs = (
49 provider: string,
50 defaultTimeoutMs: number,
51 environment: NodeJS.ProcessEnv = process.env,
52): number => {
53 const timeoutMs = Number(environment[`T3CODE_${provider.toUpperCase()}_PROBE_TIMEOUT_MS`]);
54 // Overrides only extend: callers nest these deadlines and size optional work inside them
55 // against the defaults. Node timers overflow above a signed 32-bit delay.
56 return Number.isInteger(timeoutMs) && timeoutMs > defaultTimeoutMs && timeoutMs <= 2_147_483_647
57 ? timeoutMs
58 : defaultTimeoutMs;
59};
60
61export const COMPACT_SLASH_COMMAND = {
62 name: "compact",
63 description: "Summarize the conversation and reduce context usage",
64} satisfies ServerProviderSlashCommand;
65
66export interface CommandResult {
67 readonly stdout: string;
68 readonly stderr: string;
69 readonly code: number;
70}
71
72export class ProviderCommandNotFoundError extends Schema.TaggedError<ProviderCommandNotFoundError>()(
73 "ProviderCommandNotFoundError",
74 {
75 binaryPath: Schema.String,
76 exitCode: Schema.Number,
77 stdoutLength: Schema.Number,
78 stderrLength: Schema.Number,
79 },
80) {
81 override get message(): string {
82 return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`;
83 }
84}
85
86const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError);
87
88export interface ProviderProbeResult {
89 readonly installed: boolean;
90 readonly version: string | null;
91 readonly status: Exclude<ServerProviderState, "disabled">;
92 readonly auth: ServerProviderAuth;
93 readonly message?: string;
94 readonly usageLimits?: ServerProviderUsageLimits;
95}
96
97export interface ServerProviderPresentation {
98 readonly displayName: string;
99 readonly badgeLabel?: string;
100 readonly showInteractionModeToggle?: boolean;
101 readonly reportsContextWindow?: boolean;
102 readonly supportedRuntimeModes?: ReadonlyArray<RuntimeMode>;
103 readonly supportsConversationRollback?: boolean;
104}
105
106export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
107
108export function nonEmptyTrimmed(value: string | undefined): string | undefined {
109 if (!value) return undefined;
110 const trimmed = value.trim();
111 return trimmed.length > 0 ? trimmed : undefined;
112}
113
114export function isCommandMissingCause(error: unknown): boolean {
115 if (isProviderCommandNotFoundError(error)) return true;
116 return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound";
117}
118
119/**
120 * Environment for background status probes. A mise shim for a missing tool would otherwise
121 * download it on every refresh and leave a partial download behind when the probe times out.
122 * Sessions and user-started commands keep the plain environment, so mise still installs there.
123 */
124export function providerProbeEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
125 return { ...environment, MISE_OFFLINE: "1" };
126}
127
128// A timed-out probe interrupts the child with SIGTERM and then waits for it to exit,
129// so a CLI that ignores SIGTERM would hold the caller's timeout open without this.
130const PROBE_FORCE_KILL_AFTER = "1 second";
131
132function withProbeForceKill(command: ChildProcess.Command): ChildProcess.Command {
133 if (!ChildProcess.isStandardCommand(command) || command.options.forceKillAfter !== undefined) {
134 return command;
135 }
136 return ChildProcess.make(command.command, command.args, {
137 ...command.options,
138 forceKillAfter: PROBE_FORCE_KILL_AFTER,
139 });
140}
141
142export const spawnAndCollect = Effect.fn("snapshotProbe.spawnAndCollect")(function* (
143 binaryPath: string,
144 command: ChildProcess.Command,
145) {
146 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
147 const child = yield* spawner.spawn(withProbeForceKill(command));
148 const [stdout, stderr, exitCode] = yield* Effect.all(
149 [
150 collectStreamAsString(child.stdout),
151 collectStreamAsString(child.stderr),
152 child.exitCode.pipe(Effect.map(Number)),
153 ],
154 { concurrency: "unbounded" },
155 );
156
157 const result: CommandResult = { stdout, stderr, code: exitCode };
158 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
159 return yield* new ProviderCommandNotFoundError({
160 binaryPath,
161 exitCode,
162 stdoutLength: stdout.length,
163 stderrLength: stderr.length,
164 });
165 }
166 return result;
167}, Effect.scoped);
168
169export function parseGenericCliVersion(output: string): string | null {
170 // Consume v before the digits; v2 has no internal word boundary.
171 const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/);
172 return match?.[1] ?? null;
173}
174
175/** A failed `--version` run reports no version: its output can be a wrapper's, such as mise's. */
176export function parseCliVersionResult(
177 result: CommandResult,
178 parse: (output: string) => string | null = parseGenericCliVersion,
179): string | null {
180 return result.code === 0 ? parse(`${result.stdout}\n${result.stderr}`) : null;
181}
182
183export function providerModelsFromSettings(
184 builtInModels: ReadonlyArray<ServerProviderModel>,
185 customModels: ReadonlyArray<CustomModelSetting>,
186 customModelCapabilities: ModelCapabilities | null,
187): ReadonlyArray<ServerProviderModel> {
188 const entries = readCustomModelEntries(customModels);
189 if (entries.length === 0) return builtInModels;
190
191 const nameOverrides = new Map(
192 entries.filter((entry) => entry.name !== entry.slug).map((entry) => [entry.slug, entry.name]),
193 );
194 const resolvedBuiltInModels = builtInModels.map((model) => {
195 const name = nameOverrides.get(model.slug);
196 if (!name) return model;
197 const { shortName: _shortName, ...rest } = model;
198 return { ...rest, name };
199 });
200 const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));
201 const customEntries: ServerProviderModel[] = [];
202
203 for (const entry of entries) {
204 if (seen.has(entry.slug)) {
205 continue;
206 }
207 seen.add(entry.slug);
208 customEntries.push({
209 slug: entry.slug,
210 name: entry.name,
211 isCustom: true,
212 capabilities: entry.capabilities ?? customModelCapabilities,
213 });
214 }
215
216 return [...resolvedBuiltInModels, ...customEntries];
217}
218
219export function buildSelectOptionDescriptor(input: {
220 readonly id: string;
221 readonly label: string;
222 readonly options:
223 | ReadonlyArray<{
224 value: string;
225 label: string;
226 description?: string | undefined;
227 isDefault?: boolean | undefined;
228 }>
229 | undefined;
230 readonly description?: string;
231 readonly promptInjectedValues?: ReadonlyArray<string>;
232}) {
233 const options = (input.options ?? []).map((option) => ({
234 id: option.value,
235 label: option.label,
236 ...(option.description ? { description: option.description } : {}),
237 ...(option.isDefault ? { isDefault: true } : {}),
238 }));
239 const currentValue = options.find((option) => option.isDefault)?.id;
240 return {
241 id: input.id,
242 label: input.label,
243 type: "select" as const,
244 options,
245 ...(currentValue ? { currentValue } : {}),
246 ...(input.description ? { description: input.description } : {}),
247 ...(input.promptInjectedValues && input.promptInjectedValues.length > 0
248 ? { promptInjectedValues: [...input.promptInjectedValues] }
249 : {}),
250 };
251}
252
253export function buildBooleanOptionDescriptor(input: {
254 readonly id: string;
255 readonly label: string;
256 readonly currentValue?: boolean;
257 readonly description?: string;
258}) {
259 return {
260 id: input.id,
261 label: input.label,
262 type: "boolean" as const,
263 ...(input.description ? { description: input.description } : {}),
264 ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}),
265 };
266}
267
268export function buildServerProvider(input: {
269 driver?: ProviderDriverKind;
270 presentation: ServerProviderPresentation;
271 enabled: boolean;
272 checkedAt: string;
273 models: ReadonlyArray<ServerProviderModel>;
274 updateRequiredModels?: ReadonlyArray<ServerProviderUpdateRequiredModel>;
275 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
276 skills?: ReadonlyArray<ServerProviderSkill>;
277 probe: ProviderProbeResult;
278}): ServerProviderDraft {
279 const versionAdvisory = input.driver
280 ? createProviderVersionAdvisory({
281 driver: input.driver,
282 currentVersion: input.probe.version,
283 checkedAt: input.checkedAt,
284 })
285 : undefined;
286 return {
287 displayName: input.presentation.displayName,
288 ...(typeof input.presentation.supportsConversationRollback === "boolean"
289 ? { supportsConversationRollback: input.presentation.supportsConversationRollback }
290 : {}),
291 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
292 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
293 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
294 : {}),
295 ...(typeof input.presentation.reportsContextWindow === "boolean"
296 ? { reportsContextWindow: input.presentation.reportsContextWindow }
297 : {}),
298 ...(input.presentation.supportedRuntimeModes === undefined
299 ? {}
300 : { supportedRuntimeModes: [...input.presentation.supportedRuntimeModes] }),
301 enabled: input.enabled,
302 installed: input.probe.installed,
303 version: input.probe.version,
304 status: input.enabled ? input.probe.status : "disabled",
305 auth: input.probe.auth,
306 // Drivers with in-app setup replace these CLI-owned defaults.
307 setup: { canAuthenticate: false, canInstall: false },
308 checkedAt: input.checkedAt,
309 ...(input.probe.message ? { message: input.probe.message } : {}),
310 models: input.models,
311 ...(input.updateRequiredModels?.length
312 ? { updateRequiredModels: input.updateRequiredModels }
313 : {}),
314 slashCommands: [...(input.slashCommands ?? [])],
315 skills: [...(input.skills ?? [])],
316 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),
317 ...(versionAdvisory ? { versionAdvisory } : {}),
318 };
319}
320
321export const collectStreamAsString = <E>(
322 stream: Stream.Stream<Uint8Array, E>,
323 options?: { readonly maxBytes?: number | undefined },
324): Effect.Effect<string, E> =>
325 collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text));