Skip to content
Source
Sign inSign up
Open History to see the last commit

status.ts

949 lines31.0 KB

1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 type ModelCapabilities,
4 type ServerProviderModel,
5 type ServerProviderSkill,
6 type ServerProviderSlashCommand,
7 settingsSecretLiteral,
8} from "@t3tools/contracts";
9import type { OpenCodeSettings } from "../settings.ts";
10import * as Cause from "effect/Cause";
11import * as Schema from "effect/Schema";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Schedule from "effect/Schedule";
16​
17import { createModelCapabilities } from "@t3tools/shared/model";
18import { compareSemverVersions } from "@t3tools/shared/semver";
19import {
20 buildServerProvider,
21 COMPACT_SLASH_COMMAND,
22 nonEmptyTrimmed,
23 providerModelsFromSettings,
24 type ServerProviderDraft,
25} from "@t3tools/provider-core/server/snapshotProbe";
26import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
27import type { ProbedOpenCode } from "./versionProbe.ts";
28import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
29import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
30​
31const OPENCODE_PRESENTATION = {
32 displayName: "OpenCode",
33 showInteractionModeToggle: false,
34} as const;
35​
36class OpenCodeProbeError extends Schema.TaggedError<OpenCodeProbeError>()("OpenCodeProbeError", {
37 cause: Schema.optional(Schema.Defect()),
38 detail: Schema.String,
39}) {}
40​
41const isOpenCodeProbeError = Schema.is(OpenCodeProbeError);
42​
43function normalizeProbeMessage(message: string): string | undefined {
44 const trimmed = message.trim();
45 if (trimmed.length === 0) {
46 return undefined;
47 }
48 if (
49 trimmed === "An error occurred in Effect.tryPromise" ||
50 trimmed === "An error occurred in Effect.try"
51 ) {
52 return undefined;
53 }
54 return trimmed;
56​
57function normalizedErrorMessage(cause: unknown): string | undefined {
58 if (isOpenCodeProbeError(cause)) {
59 return normalizeProbeMessage(cause.detail);
60 }
61​
62 if (!(cause instanceof Error)) {
63 return undefined;
64 }
65​
66 return normalizeProbeMessage(cause.message);
68​
69function formatOpenCodeProbeError(input: {
70 readonly cause: unknown;
71 readonly isExternalServer: boolean;
72 readonly phase: "version" | "inventory";
73 readonly serverUrl: string;
74}): {
75 readonly installed: boolean;
76 readonly message: string;
77 readonly authStatus?: "unauthenticated";
78} {
79 const detail = normalizedErrorMessage(input.cause);
80 const lower = detail?.toLowerCase() ?? "";
81​
82 if (input.isExternalServer) {
83 // Bare auth-code digits can be part of the endpoint's port or path.
84 if (
85 /(?:^|\s)(?:status(?:\s+code)?(?:\s*[:=]\s*|\s+)|http(?:\/\d(?:\.\d)?)?\s+)(?:401|403)\b/.test(
86 lower,
87 ) ||
88 /\b(?:unauthorized|forbidden)\b/.test(lower)
89 ) {
90 return {
91 installed: true,
92 authStatus: "unauthenticated",
93 message: "OpenCode server rejected authentication. Check the server URL and password.",
94 };
95 }
96​
97 if (
98 lower.includes("econnrefused") ||
99 lower.includes("enotfound") ||
100 lower.includes("fetch failed") ||
101 lower.includes("networkerror") ||
102 lower.includes("timed out") ||
103 lower.includes("timeout") ||
104 lower.includes("socket hang up")
105 ) {
106 return {
107 installed: true,
108 message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
109 };
110 }
111​
112 return {
113 installed: true,
114 message: detail ?? "Failed to connect to the configured OpenCode server.",
115 };
116 }
117​
118 if (lower.includes("enoent") || lower.includes("notfound")) {
119 return {
120 installed: false,
121 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
122 };
123 }
124​
125 if (lower.includes("quarantine")) {
126 return {
127 installed: true,
128 message:
129 "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
130 };
131 }
132​
133 if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
134 return {
135 installed: true,
136 message:
137 "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted. Try reinstalling OpenCode.",
138 };
139 }
140​
141 const failureLabel =
142 input.phase === "inventory"
143 ? "Failed to load OpenCode provider inventory"
144 : "Failed to execute OpenCode CLI health check";
145 return {
146 installed: true,
147 message: detail ? `${failureLabel}: ${detail}` : `${failureLabel}.`,
148 };
150​
151// The status check owns the span; keep handled failures visible without adding another span.
152const logDiscoveryFailure = Effect.fnUntraced(function* (input: {
153 readonly phase: "version" | "inventory" | "models";
154 readonly isExternalServer: boolean;
155 readonly version?: string;
156 readonly cause?: Cause.Cause<unknown>;
157}) {
158 const outcome = input.cause ? "failed" : "empty";
159 const attributes = {
160 provider: "opencode",
161 phase: input.phase,
162 outcome,
163 serverMode: input.isExternalServer ? "external" : "managed",
164 ...(input.version === undefined ? {} : { version: input.version }),
165 };
166 yield* Effect.annotateCurrentSpan({
167 "discovery.phase": input.phase,
168 "discovery.outcome": outcome,
169 });
170 yield* (
171 input.cause
172 ? Effect.logWarning("OpenCode discovery failed", input.cause)
173 : Effect.logWarning("OpenCode discovery failed")
174 ).pipe(Effect.annotateLogs(attributes));
175});
176​
177function titleCaseSlug(value: string): string {
178 const segments: Array<string> = [];
179 for (const segment of value.split(/[-_/]+/)) {
180 if (segment.length > 0) {
181 segments.push(segment.charAt(0).toUpperCase() + segment.slice(1));
182 }
183 }
184 return segments.join(" ");
186​
187function inferDefaultVariant(
188 providerID: string,
189 variants: ReadonlyArray<string>,
190): string | undefined {
191 if (variants.length === 1) {
192 return variants[0];
193 }
194 if (providerID === "anthropic" || providerID.startsWith("google")) {
195 return variants.includes("high") ? "high" : undefined;
196 }
197 if (providerID === "openai" || providerID === "opencode" || providerID === "opencode-go") {
198 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
199 }
200 return undefined;
202​
203function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
204 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
206​
207const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
208 optionDescriptors: [
209 {
210 id: "variant",
211 label: "Reasoning",
212 type: "select",
213 options: [
214 { id: "low", label: "Low" },
215 { id: "medium", label: "Medium", isDefault: true },
216 { id: "high", label: "High" },
217 { id: "xhigh", label: "Extra High" },
218 ],
219 currentValue: "medium",
220 },
221 {
222 id: "agent",
223 label: "Agent",
224 type: "select",
225 options: [
226 { id: "build", label: "Build", isDefault: true },
227 { id: "plan", label: "Plan" },
228 ],
229 currentValue: "build",
230 },
231 ],
232});
233​
234function openCodeCapabilitiesForModel(input: {
235 readonly providerID: string;
236 readonly model: ProviderListResponse["all"][number]["models"][string];
237 readonly agents: ReadonlyArray<Agent>;
238}): ModelCapabilities {
239 const variantValues = Object.keys(input.model.variants ?? {});
240 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
241 const variantOptions = variantValues.map((value) =>
242 defaultVariant === value
243 ? { id: value, label: titleCaseSlug(value), isDefault: true as const }
244 : { id: value, label: titleCaseSlug(value) },
245 );
246 const primaryAgents = input.agents.filter(
247 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
248 );
249 const defaultAgent = inferDefaultAgent(primaryAgents);
250 const agentOptions = primaryAgents.map((agent) =>
251 defaultAgent === agent.name
252 ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const }
253 : { id: agent.name, label: titleCaseSlug(agent.name) },
254 );
255 return createModelCapabilities({
256 optionDescriptors: [
257 ...(variantOptions.length > 0
258 ? [
259 {
260 id: "variant",
261 label: "Reasoning",
262 type: "select" as const,
263 options: variantOptions,
264 ...(defaultVariant ? { currentValue: defaultVariant } : {}),
265 },
266 ]
267 : []),
268 ...(agentOptions.length > 0
269 ? [
270 {
271 id: "agent",
272 label: "Agent",
273 type: "select" as const,
274 options: agentOptions,
275 ...(defaultAgent ? { currentValue: defaultAgent } : {}),
276 },
277 ]
278 : []),
279 ],
280 });
282​
283interface OpenCodeRelease {
284 readonly providerID: string;
285 readonly family?: string | undefined;
286 readonly released?: number | undefined;
287 readonly deprecated?: boolean | undefined;
289​
290/**
291 * OpenCode catalogs keep every release of a family, such as Claude Opus 4.5
292 * through 5.5. Only the newest active release of each family within a
293 * sub-provider stays current; older releases and deprecated models are legacy.
294 */
295function markOpenCodeLegacy(
296 entries: ReadonlyArray<{
297 readonly model: ServerProviderModel;
298 readonly release: OpenCodeRelease;
299 }>,
300): Array<ServerProviderModel> {
301 const familyKey = ({ providerID, family }: OpenCodeRelease) =>
302 family ? `${providerID}/${family}` : undefined;
303 const newest = new Map<string, number>();
304 for (const { release } of entries) {
305 const key = familyKey(release);
306 if (key === undefined || release.released === undefined || release.deprecated) continue;
307 newest.set(key, Math.max(newest.get(key) ?? release.released, release.released));
308 }
309 return entries.map(({ model, release }) => {
310 const key = familyKey(release);
311 const latest = key === undefined ? undefined : newest.get(key);
312 const superseded =
313 latest !== undefined && release.released !== undefined && release.released < latest;
314 return release.deprecated || superseded ? { ...model, isLegacy: true } : model;
315 });
317​
318function flattenOpenCodeModels(
319 input: Pick<OpenCodeRuntime.OpenCodeInventory, "providerList" | "agents">,
320): ReadonlyArray<ServerProviderModel> {
321 const connected = new Set(input.providerList.connected);
322 const entries: Array<{ model: ServerProviderModel; release: OpenCodeRelease }> = [];
323​
324 for (const provider of input.providerList.all) {
325 if (!connected.has(provider.id)) {
326 continue;
327 }
328​
329 for (const model of Object.values(provider.models)) {
330 const name = nonEmptyTrimmed(model.name);
331 if (!name) {
332 continue;
333 }
334​
335 const subProvider = nonEmptyTrimmed(provider.name);
336 const released = Date.parse(model.release_date);
337 entries.push({
338 model: {
339 slug: `${provider.id}/${model.id}`,
340 name,
341 ...(subProvider ? { subProvider } : {}),
342 isCustom: false,
343 capabilities: openCodeCapabilitiesForModel({
344 providerID: provider.id,
345 model,
346 agents: input.agents,
347 }),
348 },
349 release: {
350 providerID: provider.id,
351 family: model.family,
352 released: Number.isNaN(released) ? undefined : released,
353 deprecated: model.status === "deprecated",
354 },
355 });
356 }
357 }
358​
359 return markOpenCodeLegacy(entries).toSorted((left, right) => left.name.localeCompare(right.name));
361​
362/** Undefined when OpenCode lists no models, so callers can keep the catalog they have. */
363export function openCodeProviderModels(
364 settings: Pick<OpenCodeSettings, "customModels">,
365 catalog: Pick<OpenCodeRuntime.OpenCodeInventory, "providerList" | "agents">,
366): ReadonlyArray<ServerProviderModel> | undefined {
367 const models = flattenOpenCodeModels(catalog);
368 return models.length === 0
369 ? undefined
370 : providerModelsFromSettings(
371 models,
372 settings.customModels,
373 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
374 );
376​
377function trimOptional(value: string | null | undefined): string | undefined {
378 const trimmed = value?.trim();
379 return trimmed && trimmed.length > 0 ? trimmed : undefined;
381​
382export function openCodeSkillsToServerProviderSkills(
383 input: OpenCodeRuntime.OpenCodeInventory["skills"] | undefined,
384): ReadonlyArray<ServerProviderSkill> {
385 const skills: ServerProviderSkill[] = [];
386 for (const skill of input ?? []) {
387 const name = trimOptional(skill.name);
388 const path = trimOptional(skill.location);
389 if (!name || !path) {
390 continue;
391 }
392​
393 const description = trimOptional(skill.description);
394 skills.push({
395 name,
396 path,
397 enabled: true,
398 ...(description ? { description, shortDescription: description } : {}),
399 });
400 }
401​
402 return skills.toSorted((left, right) => left.name.localeCompare(right.name));
404​
405export function openCodeCommandsToServerProviderSlashCommands(
406 input: OpenCodeRuntime.OpenCodeInventory["commands"],
407): ReadonlyArray<ServerProviderSlashCommand> {
408 const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
409 const names = new Set([COMPACT_SLASH_COMMAND.name]);
410 for (const command of input ?? []) {
411 const name = trimOptional(command.name);
412 if (!name || names.has(name) || command.source === "skill") continue;
413 names.add(name);
414 const description = trimOptional(command.description);
415 const hint = trimOptional(command.hints.join(" "));
416 commands.push({
417 name,
418 ...(description ? { description } : {}),
419 ...(hint ? { input: { hint } } : {}),
420 });
421 }
422 return commands;
424​
425export const makePendingOpenCodeProvider = Effect.fn(
426 "OpencodeProviderStatus.makePendingOpenCodeProvider",
427)(function* (openCodeSettings: OpenCodeSettings): Effect.fn.Return<ServerProviderDraft> {
428 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
429 const models = providerModelsFromSettings(
430 [],
431 openCodeSettings.customModels,
432 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
433 );
434​
435 if (!openCodeSettings.enabled) {
436 return buildServerProvider({
437 presentation: OPENCODE_PRESENTATION,
438 enabled: false,
439 checkedAt,
440 models,
441 probe: {
442 installed: false,
443 version: null,
444 status: "warning",
445 auth: { status: "unknown" },
446 message:
447 openCodeSettings.serverUrl.trim().length > 0
448 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
449 : "OpenCode is disabled in T3 Code settings.",
450 },
451 });
452 }
453​
454 return buildServerProvider({
455 presentation: OPENCODE_PRESENTATION,
456 enabled: true,
457 checkedAt,
458 models,
459 probe: {
460 installed: false,
461 version: null,
462 status: "warning",
463 auth: { status: "unknown" },
464 message: "OpenCode provider status has not been checked in this session yet.",
465 },
466 });
467});
468​
469export interface OpenCode2Model extends OpenCodeRelease {
470 readonly id: string;
471 readonly name: string;
472 readonly providerName?: string;
473 readonly variants: ReadonlyArray<{ readonly id: string }>;
475​
476// Sub-provider names distinguish overlapping catalog entries.
477function subProviderLabelFor(input: {
478 readonly providerID: string;
479 readonly providerName?: string;
480}): string | undefined {
481 const label = nonEmptyTrimmed(input.providerName) ?? input.providerID;
482 return label.toLowerCase() === OPENCODE_PRESENTATION.displayName.toLowerCase()
483 ? undefined
484 : label;
486​
487const catalogFingerprint = (models: ReadonlyArray<OpenCode2Model>) =>
488 models
489 .map((model) =>
490 JSON.stringify([
491 model.providerID,
492 model.id,
493 model.name,
494 model.providerName,
495 model.variants.map((variant) => variant.id).toSorted(),
496 ]),
497 )
498 .toSorted()
499 .join("\n");
500​
501/** A cold server can list a partial catalog; two agreeing reads reduce that startup race. */
502const settleOpenCode2Catalog = <E>(
503 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
504 first?: ReadonlyArray<OpenCode2Model>,
505) =>
506 Effect.suspend(() => {
507 let previous = first && first.length > 0 ? catalogFingerprint(first) : undefined;
508 return (first ? Effect.delay(list, "250 millis") : list).pipe(
509 Effect.repeat({
510 until: (models) => {
511 const fingerprint = models.length > 0 ? catalogFingerprint(models) : undefined;
512 const settled = fingerprint !== undefined && fingerprint === previous;
513 previous = fingerprint;
514 return settled;
515 },
516 schedule: Schedule.spaced("250 millis"),
517 }),
518 );
519 });
520​
521const OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS = 5_000;
522​
523export const makeOpenCode2ModelLoader = <E>(
524 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
525 environment?: NodeJS.ProcessEnv,
526) =>
527 Effect.sync(() => {
528 let lastLoaded: ReadonlyArray<OpenCode2Model> = [];
529 return Effect.suspend(() => {
530 let latest: ReadonlyArray<OpenCode2Model> = [];
531 const tracked = list.pipe(
532 Effect.tap((models) =>
533 Effect.sync(() => {
534 if (models.length > 0) latest = models;
535 }),
536 ),
537 );
538 // The first read can include a cold server start, so only settling shares the budget.
539 return tracked.pipe(
540 Effect.flatMap((first) =>
541 settleOpenCode2Catalog(tracked, first).pipe(
542 Effect.timeoutOption(
543 resolveProviderProbeTimeoutMs(
544 "opencode",
545 OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS,
546 environment,
547 ),
548 ),
549 ),
550 ),
551 Effect.catch((error) =>
552 latest.length > 0 ? Effect.succeedSome(latest) : Effect.fail(error),
553 ),
554 Effect.map((loaded) => {
555 // Fresh progress may still be partial, but a warm cache must not freeze slow catalogs.
556 if (loaded._tag === "Some") lastLoaded = loaded.value;
557 else if (latest.length > 0) lastLoaded = latest;
558 return lastLoaded;
559 }),
560 );
561 });
562 });
563​
564/** None when the reads fail or never agree, so a partial catalog never passes for a settled one. */
565export const readSettledOpenCode2Catalog = <E>(
566 list: Effect.Effect<ReadonlyArray<OpenCode2Model>, E>,
567 environment?: NodeJS.ProcessEnv,
568) =>
569 settleOpenCode2Catalog(list).pipe(
570 Effect.timeoutOption(
571 resolveProviderProbeTimeoutMs("opencode", OPENCODE_2_CATALOG_SETTLE_TIMEOUT_MS, environment),
572 ),
573 Effect.orElseSucceed(() => Option.none<ReadonlyArray<OpenCode2Model>>()),
574 );
575​
576export interface OpenCode2Workspace {
577 readonly skills: ReadonlyArray<{
578 readonly id: string;
579 readonly name: string;
580 readonly path: string;
581 readonly description?: string | undefined;
582 }>;
583 readonly commands: ReadonlyArray<{
584 readonly name: string;
585 readonly description?: string | undefined;
586 }>;
588​
589/**
590 * Reading a location the server has not loaded boots its scan, and only reads after the scan's
591 * `command.updated` plus `skill.updated` are complete; `scanned` must listen before `read`. A
592 * loaded location listing no commands is still scanning. A location another caller booted
593 * moments earlier lists as loaded with only built-ins, so `settled` waits out its scan first.
594 */
595export const loadOpenCode2Workspace = Effect.fn("OpencodeProviderStatus.loadOpenCode2Workspace")(
596 function* <E>(
597 read: Effect.Effect<OpenCode2Workspace, E>,
598 scanned: Effect.Effect<void>,
599 loaded: Effect.Effect<boolean>,
600 settled: Effect.Effect<void> = Effect.void,
601 environment?: NodeJS.ProcessEnv,
602 ) {
603 const wasLoaded = yield* loaded;
604 yield* settled;
605 const first = yield* read;
606 if (wasLoaded && first.commands.length > 0) return first;
607 yield* scanned.pipe(
608 Effect.timeoutOption(resolveProviderProbeTimeoutMs("opencode", 10_000, environment)),
609 );
610 return yield* read;
611 },
612);
613​
614export function openCode2SkillsToServerProviderSkills(
615 skills: OpenCode2Workspace["skills"],
616): ReadonlyArray<ServerProviderSkill> {
617 return skills
618 .map((skill) => {
619 const description = trimOptional(skill.description);
620 return {
621 name: skill.id,
622 path: skill.path,
623 enabled: true,
624 ...(skill.name === skill.id ? {} : { displayName: skill.name }),
625 ...(description ? { description, shortDescription: description } : {}),
626 };
627 })
628 .toSorted((left, right) => left.name.localeCompare(right.name));
630​
631export function openCode2CommandsToServerProviderSlashCommands(
632 commands: OpenCode2Workspace["commands"],
633): ReadonlyArray<ServerProviderSlashCommand> {
634 const slashCommands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND];
635 const names = new Set([COMPACT_SLASH_COMMAND.name]);
636 for (const command of commands) {
637 const name = trimOptional(command.name);
638 if (!name || names.has(name)) continue;
639 names.add(name);
640 const description = trimOptional(command.description);
641 slashCommands.push({ name, ...(description ? { description } : {}) });
642 }
643 return slashCommands;
645​
646// OpenCode has no reviewer; Auto asks like Supervised.
647const OPENCODE_2_PRESENTATION = {
648 ...OPENCODE_PRESENTATION,
649 showInteractionModeToggle: true,
650 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "auto", "full-access"],
651} as const;
652​
653function openCode2ModelCapabilities(model: OpenCode2Model): ModelCapabilities {
654 const variants = model.variants.map((variant) => variant.id);
655 const defaultVariant = inferDefaultVariant(model.providerID, variants);
656 return createModelCapabilities({
657 optionDescriptors:
658 variants.length === 0
659 ? []
660 : [
661 {
662 id: "variant",
663 label: "Reasoning",
664 type: "select",
665 options: variants.map((id) => ({
666 id,
667 label: titleCaseSlug(id),
668 ...(id === defaultVariant ? { isDefault: true } : {}),
669 })),
670 ...(defaultVariant === undefined ? {} : { currentValue: defaultVariant }),
671 },
672 ],
673 });
675​
676export function openCode2ProviderModels(
677 settings: Pick<OpenCodeSettings, "customModels">,
678 catalog: ReadonlyArray<OpenCode2Model>,
679): ReadonlyArray<ServerProviderModel> {
680 return providerModelsFromSettings(
681 markOpenCodeLegacy(
682 catalog.map((model) => {
683 const subProvider = subProviderLabelFor(model);
684 return {
685 model: {
686 slug: `${model.providerID}/${model.id}`,
687 name: model.name,
688 ...(subProvider ? { subProvider } : {}),
689 isCustom: false,
690 capabilities: openCode2ModelCapabilities(model),
691 },
692 release: model,
693 };
694 }),
695 ).toSorted((left, right) => left.name.localeCompare(right.name)),
696 settings.customModels,
697 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
698 );
700​
701const checkOpenCode2 = Effect.fn("checkOpenCode2")(function* (
702 settings: OpenCodeSettings,
703 version: string,
704 checkedAt: string,
705 loadModels: Effect.Effect<ReadonlyArray<OpenCode2Model>, OpenCodeRuntime.OpenCodeRuntimeError>,
706) {
707 const result = yield* Effect.exit(loadModels);
708 const probe = (status: "ready" | "warning" | "error", message: string) => ({
709 installed: true,
710 version,
711 status,
712 auth: { status: status === "ready" ? ("authenticated" as const) : ("unknown" as const) },
713 message,
714 });
715 if (result._tag === "Failure") {
716 yield* logDiscoveryFailure({
717 phase: "models",
718 isExternalServer: settings.serverUrl.trim().length > 0,
719 version,
720 cause: result.cause,
721 });
722 return buildServerProvider({
723 presentation: OPENCODE_2_PRESENTATION,
724 enabled: true,
725 checkedAt,
726 models: providerModelsFromSettings(
727 [],
728 settings.customModels,
729 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
730 ),
731 slashCommands: [COMPACT_SLASH_COMMAND],
732 probe: probe("error", "OpenCode could not load its model list."),
733 });
734 }
735 if (result.value.length === 0) {
736 yield* logDiscoveryFailure({
737 phase: "models",
738 isExternalServer: settings.serverUrl.trim().length > 0,
739 version,
740 });
741 }
742 return buildServerProvider({
743 presentation: OPENCODE_2_PRESENTATION,
744 enabled: true,
745 checkedAt,
746 models: openCode2ProviderModels(settings, result.value),
747 slashCommands: [COMPACT_SLASH_COMMAND],
748 probe:
749 result.value.length > 0
750 ? probe(
751 "ready",
752 `OpenCode ${version} lists ${result.value.length} model${result.value.length === 1 ? "" : "s"}.`,
753 )
754 : probe("warning", "OpenCode 2 is running, but it did not list any models yet."),
755 });
756});
757​
758export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (
759 openCodeSettings: OpenCodeSettings,
760 cwd: string,
761 probeRuntime: Effect.Effect<ProbedOpenCode, OpenCodeRuntime.OpenCodeRuntimeError>,
762 loadOpenCode2Models: Effect.Effect<
763 ReadonlyArray<OpenCode2Model>,
764 OpenCodeRuntime.OpenCodeRuntimeError
765 >,
766 environment?: NodeJS.ProcessEnv,
767): Effect.fn.Return<
768 ServerProviderDraft,
769 never,
770 OpenCodeRuntime.OpenCodeRuntime | OpenCodeServerOwner.OpenCodeServerOwner
771> {
772 const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime;
773 const serverOwner = yield* OpenCodeServerOwner.OpenCodeServerOwner;
774 const checkedAt = DateTime.formatIso(yield* DateTime.now);
775 const customModels = openCodeSettings.customModels;
776 const isExternalServer = openCodeSettings.serverUrl.trim().length > 0;
777​
778 const fallback = (
779 cause: unknown,
780 version: string | null = null,
781 phase: "version" | "inventory" = "version",
782 ) => {
783 const failure = formatOpenCodeProbeError({
784 cause,
785 isExternalServer,
786 phase,
787 serverUrl: openCodeSettings.serverUrl,
788 });
789 return buildServerProvider({
790 presentation: OPENCODE_PRESENTATION,
791 enabled: openCodeSettings.enabled,
792 checkedAt,
793 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
794 probe: {
795 installed: failure.installed,
796 version,
797 status: "error",
798 auth: { status: failure.authStatus ?? "unknown" },
799 message: failure.message,
800 },
801 });
802 };
803​
804 if (!openCodeSettings.enabled) {
805 return buildServerProvider({
806 presentation: OPENCODE_PRESENTATION,
807 enabled: false,
808 checkedAt,
809 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
810 probe: {
811 installed: false,
812 version: null,
813 status: "warning",
814 auth: { status: "unknown" },
815 message: isExternalServer
816 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
817 : "OpenCode is disabled in T3 Code settings.",
818 },
819 });
820 }
821​
822 const probedExit = yield* Effect.exit(
823 probeRuntime.pipe(
824 Effect.mapError(
825 (cause) =>
826 new OpenCodeProbeError({
827 cause,
828 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
829 }),
830 ),
831 ),
832 );
833 if (probedExit._tag === "Failure") {
834 yield* logDiscoveryFailure({ phase: "version", isExternalServer, cause: probedExit.cause });
835 return fallback(Cause.squash(probedExit.cause));
836 }
837 const probed = probedExit.value;
838 if (probed.generation === "v2") {
839 return yield* checkOpenCode2(openCodeSettings, probed.version, checkedAt, loadOpenCode2Models);
840 }
841 if (compareSemverVersions(probed.version, OpenCodeRuntime.MINIMUM_OPENCODE_VERSION) < 0) {
842 return buildServerProvider({
843 presentation: OPENCODE_PRESENTATION,
844 enabled: openCodeSettings.enabled,
845 checkedAt,
846 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
847 probe: {
848 installed: true,
849 version: probed.version,
850 status: "error",
851 auth: { status: "unknown" },
852 message: `OpenCode v${probed.version} is too old. Upgrade to v${OpenCodeRuntime.MINIMUM_OPENCODE_VERSION} or newer.`,
853 },
854 });
855 }
856​
857 const installedVersion = isExternalServer ? {} : { installedVersion: probed.version };
858 let version = probed.version;
859 const loadInventory = Effect.fnUntraced(function* (server: {
860 readonly url: string;
861 readonly serverPassword?: string;
862 readonly version: string;
863 }) {
864 // Preserve the helper's version even when its inventory request fails.
865 version = server.version;
866 return yield* openCodeRuntime.loadOpenCodeInventory(
867 openCodeRuntime.createOpenCodeSdkClient({
868 baseUrl: server.url,
869 directory: cwd,
870 ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}),
871 }),
872 );
873 });
874 const inventoryEffect = isExternalServer
875 ? openCodeRuntime
876 .connectToOpenCodeServer({
877 binaryPath: openCodeSettings.binaryPath,
878 directory: cwd,
879 serverUrl: openCodeSettings.serverUrl,
880 ...(settingsSecretLiteral(openCodeSettings.serverPassword)
881 ? { serverPassword: settingsSecretLiteral(openCodeSettings.serverPassword) }
882 : {}),
883 ...(environment !== undefined ? { environment } : {}),
884 })
885 .pipe(Effect.flatMap(loadInventory), Effect.scoped)
886 : serverOwner.withServer(loadInventory);
887 const inventoryExit = yield* Effect.exit(
888 inventoryEffect.pipe(
889 Effect.mapError(
890 (cause) =>
891 new OpenCodeProbeError({
892 cause,
893 detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause),
894 }),
895 ),
896 ),
897 );
898 if (inventoryExit._tag === "Failure") {
899 yield* logDiscoveryFailure({
900 phase: "inventory",
901 isExternalServer,
902 version,
903 cause: inventoryExit.cause,
904 });
905 return {
906 ...fallback(Cause.squash(inventoryExit.cause), version, "inventory"),
907 ...installedVersion,
908 };
909 }
910​
911 const catalogModels = openCodeProviderModels(openCodeSettings, inventoryExit.value);
912 const models =
913 catalogModels ??
914 providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES);
915 const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills);
916 const connectedCount = inventoryExit.value.providerList.connected.length;
917 const catalogReady = connectedCount > 0 && catalogModels !== undefined;
918 if (connectedCount > 0 && catalogModels === undefined) {
919 yield* logDiscoveryFailure({ phase: "inventory", isExternalServer, version });
920 }
921 return {
922 ...buildServerProvider({
923 presentation: OPENCODE_PRESENTATION,
924 enabled: true,
925 checkedAt,
926 models,
927 skills,
928 slashCommands: openCodeCommandsToServerProviderSlashCommands(inventoryExit.value.commands),
929 probe: {
930 installed: true,
931 version,
932 status: catalogReady ? "ready" : "warning",
933 auth: {
934 status: catalogReady ? "authenticated" : "unknown",
935 type: "opencode",
936 },
937 message:
938 connectedCount > 0 && !catalogReady
939 ? "OpenCode upstream providers are connected, but it did not list any models yet."
940 : connectedCount > 0
941 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
942 : isExternalServer
943 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
944 : "OpenCode is available, but it did not report any connected upstream providers.",
945 },
946 }),
947 ...installedVersion,
948 };
949});