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
26// Localized cmd.exe "not recognized" messages for a missing executable.
27const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [
28 /is not recognized as an internal or external command/i,
29 /n.o . reconhecido como um comando interno/i,
30 /non . riconosciuto come comando interno o esterno/i,
31 /n.est pas reconnu en tant que commande interne/i,
32 /no se reconoce como un comando interno o externo/i,
33 /wird nicht als interner oder externer befehl/i,
34] as const;
35
36/** Whether a Windows exit means the command was not found rather than that it failed. */
37export const isWindowsCommandNotFound = Effect.fn("isWindowsCommandNotFound")(function* (
38 code: number | null,
39 stderr: string,
40) {
41 const platform = yield* HostProcess.Platform;
42 if (platform !== "win32") return false;
43 if (code === 9009) return true;
44 return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(stderr));
45});
46
47export const DEFAULT_TIMEOUT_MS = 4_000;
48// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
49export const AUTH_PROBE_TIMEOUT_MS = 10_000;
50
51export const COMPACT_SLASH_COMMAND = {
52 name: "compact",
53 description: "Summarize the conversation and reduce context usage",
54} satisfies ServerProviderSlashCommand;
55
56export interface CommandResult {
57 readonly stdout: string;
58 readonly stderr: string;
59 readonly code: number;
60}
61
62export class ProviderCommandNotFoundError extends Schema.TaggedError<ProviderCommandNotFoundError>()(
63 "ProviderCommandNotFoundError",
64 {
65 binaryPath: Schema.String,
66 exitCode: Schema.Number,
67 stdoutLength: Schema.Number,
68 stderrLength: Schema.Number,
69 },
70) {
71 override get message(): string {
72 return `Provider command ${this.binaryPath} was not found (exit code ${this.exitCode}).`;
73 }
74}
75
76const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError);
77
78export interface ProviderProbeResult {
79 readonly installed: boolean;
80 readonly version: string | null;
81 readonly status: Exclude<ServerProviderState, "disabled">;
82 readonly auth: ServerProviderAuth;
83 readonly message?: string;
84 readonly usageLimits?: ServerProviderUsageLimits;
85}
86
87export interface ServerProviderPresentation {
88 readonly displayName: string;
89 readonly badgeLabel?: string;
90 readonly showInteractionModeToggle?: boolean;
91 readonly reportsContextWindow?: boolean;
92 readonly supportedRuntimeModes?: ReadonlyArray<RuntimeMode>;
93 readonly requiresNewThreadForModelChange?: boolean;
94 readonly supportsConversationRollback?: boolean;
95}
96
97export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
98
99export function nonEmptyTrimmed(value: string | undefined): string | undefined {
100 if (!value) return undefined;
101 const trimmed = value.trim();
102 return trimmed.length > 0 ? trimmed : undefined;
103}
104
105export function isCommandMissingCause(error: unknown): boolean {
106 if (isProviderCommandNotFoundError(error)) return true;
107 return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound";
108}
109
110export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) =>
111 Effect.gen(function* () {
112 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
113 const child = yield* spawner.spawn(command);
114 const [stdout, stderr, exitCode] = yield* Effect.all(
115 [
116 collectStreamAsString(child.stdout),
117 collectStreamAsString(child.stderr),
118 child.exitCode.pipe(Effect.map(Number)),
119 ],
120 { concurrency: "unbounded" },
121 );
122
123 const result: CommandResult = { stdout, stderr, code: exitCode };
124 if (yield* isWindowsCommandNotFound(exitCode, stderr)) {
125 return yield* new ProviderCommandNotFoundError({
126 binaryPath,
127 exitCode,
128 stdoutLength: stdout.length,
129 stderrLength: stderr.length,
130 });
131 }
132 return result;
133 }).pipe(Effect.scoped);
134
135/**
136 * Return the first semantic version found in CLI output, or null. Accepts a
137 * leading "v" (for example `opencode v2.0.3`).
138 */
139export function parseGenericCliVersion(output: string): string | null {
140 // "opencode v2.0.3"-style output: the optional "v" has to be consumed first,
141 // since "v2" itself contains no word boundary.
142 const match = output.match(/\bv?(\d+\.\d+\.\d+)\b/);
143 return match?.[1] ?? null;
144}
145
146/**
147 * Append the user's custom models after the built-ins. A custom entry that
148 * declares its own capabilities keeps them; a bare slug gets the driver's
149 * default set. A named entry whose slug matches a built-in renames that
150 * built-in instead (for example `{ "slug": "gpt-5.5", "name": "GPT" }` in
151 * `settings.json`); other fields on such an entry are ignored.
152 */
153export function providerModelsFromSettings(
154 builtInModels: ReadonlyArray<ServerProviderModel>,
155 customModels: ReadonlyArray<CustomModelSetting>,
156 customModelCapabilities: ModelCapabilities | null,
157): ReadonlyArray<ServerProviderModel> {
158 const entries = readCustomModelEntries(customModels);
159 if (entries.length === 0) return builtInModels;
160
161 const nameOverrides = new Map(
162 entries.filter((entry) => entry.name !== entry.slug).map((entry) => [entry.slug, entry.name]),
163 );
164 const resolvedBuiltInModels = builtInModels.map((model) => {
165 const name = nameOverrides.get(model.slug);
166 if (!name) return model;
167 // The built-in short name would undo the override in compact UI.
168 const { shortName: _shortName, ...rest } = model;
169 return { ...rest, name };
170 });
171 const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));
172 const customEntries: ServerProviderModel[] = [];
173
174 for (const entry of entries) {
175 if (seen.has(entry.slug)) {
176 continue;
177 }
178 seen.add(entry.slug);
179 customEntries.push({
180 slug: entry.slug,
181 name: entry.name,
182 isCustom: true,
183 capabilities: entry.capabilities ?? customModelCapabilities,
184 });
185 }
186
187 return [...resolvedBuiltInModels, ...customEntries];
188}
189
190export function buildSelectOptionDescriptor(input: {
191 readonly id: string;
192 readonly label: string;
193 readonly options:
194 | ReadonlyArray<{
195 value: string;
196 label: string;
197 description?: string | undefined;
198 isDefault?: boolean | undefined;
199 }>
200 | undefined;
201 readonly description?: string;
202 readonly promptInjectedValues?: ReadonlyArray<string>;
203}) {
204 const options = (input.options ?? []).map((option) => ({
205 id: option.value,
206 label: option.label,
207 ...(option.description ? { description: option.description } : {}),
208 ...(option.isDefault ? { isDefault: true } : {}),
209 }));
210 const currentValue = options.find((option) => option.isDefault)?.id;
211 return {
212 id: input.id,
213 label: input.label,
214 type: "select" as const,
215 options,
216 ...(currentValue ? { currentValue } : {}),
217 ...(input.description ? { description: input.description } : {}),
218 ...(input.promptInjectedValues && input.promptInjectedValues.length > 0
219 ? { promptInjectedValues: [...input.promptInjectedValues] }
220 : {}),
221 };
222}
223
224export function buildBooleanOptionDescriptor(input: {
225 readonly id: string;
226 readonly label: string;
227 readonly currentValue?: boolean;
228 readonly description?: string;
229}) {
230 return {
231 id: input.id,
232 label: input.label,
233 type: "boolean" as const,
234 ...(input.description ? { description: input.description } : {}),
235 ...(typeof input.currentValue === "boolean" ? { currentValue: input.currentValue } : {}),
236 };
237}
238
239export function buildServerProvider(input: {
240 driver?: ProviderDriverKind;
241 presentation: ServerProviderPresentation;
242 enabled: boolean;
243 checkedAt: string;
244 models: ReadonlyArray<ServerProviderModel>;
245 updateRequiredModels?: ReadonlyArray<ServerProviderUpdateRequiredModel>;
246 slashCommands?: ReadonlyArray<ServerProviderSlashCommand>;
247 skills?: ReadonlyArray<ServerProviderSkill>;
248 probe: ProviderProbeResult;
249}): ServerProviderDraft {
250 const versionAdvisory = input.driver
251 ? createProviderVersionAdvisory({
252 driver: input.driver,
253 currentVersion: input.probe.version,
254 checkedAt: input.checkedAt,
255 })
256 : undefined;
257 return {
258 displayName: input.presentation.displayName,
259 ...(typeof input.presentation.supportsConversationRollback === "boolean"
260 ? { supportsConversationRollback: input.presentation.supportsConversationRollback }
261 : {}),
262 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
263 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
264 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
265 : {}),
266 ...(typeof input.presentation.reportsContextWindow === "boolean"
267 ? { reportsContextWindow: input.presentation.reportsContextWindow }
268 : {}),
269 ...(input.presentation.supportedRuntimeModes === undefined
270 ? {}
271 : { supportedRuntimeModes: [...input.presentation.supportedRuntimeModes] }),
272 ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
273 ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
274 : {}),
275 enabled: input.enabled,
276 installed: input.probe.installed,
277 version: input.probe.version,
278 status: input.enabled ? input.probe.status : "disabled",
279 auth: input.probe.auth,
280 checkedAt: input.checkedAt,
281 ...(input.probe.message ? { message: input.probe.message } : {}),
282 models: input.models,
283 ...(input.updateRequiredModels?.length
284 ? { updateRequiredModels: input.updateRequiredModels }
285 : {}),
286 slashCommands: [...(input.slashCommands ?? [])],
287 skills: [...(input.skills ?? [])],
288 ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}),
289 ...(versionAdvisory ? { versionAdvisory } : {}),
290 };
291}
292
293export const collectStreamAsString = <E>(
294 stream: Stream.Stream<Uint8Array, E>,
295 options?: { readonly maxBytes?: number | undefined },
296): Effect.Effect<string, E> =>
297 collectUint8StreamText({ stream, ...options }).pipe(Effect.map((collected) => collected.text));