| 1 | import 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"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as PlatformError from "effect/PlatformError"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 20 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 21 | import { readCustomModelEntries } from "@t3tools/shared/model"; |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | import { createProviderVersionAdvisory } from "./maintenanceResolver.ts"; |
| 24 | import { collectUint8StreamText } from "./collectStreamText.ts"; |
| 25 | |
| 26 | // Localized cmd.exe "not recognized" messages for a missing executable. |
| 27 | const 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. */ |
| 37 | export 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 | |
| 47 | export const DEFAULT_TIMEOUT_MS = 4_000; |
| 48 | // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) |
| 49 | export const AUTH_PROBE_TIMEOUT_MS = 10_000; |
| 50 | |
| 51 | export const COMPACT_SLASH_COMMAND = { |
| 52 | name: "compact", |
| 53 | description: "Summarize the conversation and reduce context usage", |
| 54 | } satisfies ServerProviderSlashCommand; |
| 55 | |
| 56 | export interface CommandResult { |
| 57 | readonly stdout: string; |
| 58 | readonly stderr: string; |
| 59 | readonly code: number; |
| 60 | } |
| 61 | |
| 62 | export 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 | |
| 76 | const isProviderCommandNotFoundError = Schema.is(ProviderCommandNotFoundError); |
| 77 | |
| 78 | export 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 | |
| 87 | export 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 | |
| 97 | export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">; |
| 98 | |
| 99 | export 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 | |
| 105 | export function isCommandMissingCause(error: unknown): boolean { |
| 106 | if (isProviderCommandNotFoundError(error)) return true; |
| 107 | return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; |
| 108 | } |
| 109 | |
| 110 | export 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 | */ |
| 139 | export 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 | */ |
| 153 | export 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 | |
| 190 | export 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 | |
| 224 | export 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 | |
| 239 | export 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 | |
| 293 | export 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)); |