1import type { SendUserTurnOptions } from "@muse-code/sdk";
2import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts";
3import { MuseSettings } from "../settings.ts";
4import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model";
5import * as Effect from "effect/Effect";
6import * as Schema from "effect/Schema";
7import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts";
8
9type MuseReasoningEffort = NonNullable<SendUserTurnOptions<never>["reasoningEffort"]>;
10
11const REASONING_EFFORT_LABELS = {
12 none: "None",
13 minimal: "Minimal",
14 low: "Low",
15 medium: "Medium",
16 high: "High",
17 xhigh: "Extra High",
18 max: "Max",
19 ultra: "Ultra",
20} satisfies Record<MuseReasoningEffort, string>;
21
22const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort =>
23 Object.hasOwn(REASONING_EFFORT_LABELS, tier);
24
25/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */
26const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
27/** Muse's own CLI default. */
28const FALLBACK_DEFAULT_EFFORT = "high";
29
30const ModelCatalogEntry = Schema.Struct({
31 modelId: Schema.NonEmptyString,
32 displayLabel: Schema.String,
33 providerId: Schema.String,
34 isDefault: Schema.Boolean,
35 variants: Schema.optional(Schema.Union([Schema.Array(Schema.String), Schema.Literal("unknown")])),
36 defaultReasoningEffort: Schema.optional(Schema.String),
37 reasoningEffortVariants: Schema.optional(
38 Schema.Array(
39 Schema.Struct({ tier: Schema.String, description: Schema.optional(Schema.String) }),
40 ),
41 ),
42});
43const decodeModelList = Schema.decodeUnknownEffect(
44 Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }),
45);
46
47/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */
48export function museModelCapabilities(
49 model?: Pick<
50 typeof ModelCatalogEntry.Type,
51 "variants" | "defaultReasoningEffort" | "reasoningEffortVariants"
52 >,
53): ModelCapabilities {
54 const reported = model?.variants !== undefined && model.variants !== "unknown";
55 const efforts = (reported ? model.variants : FALLBACK_EFFORTS).filter(isMuseReasoningEffort);
56 const preferredDefault = reported ? model?.defaultReasoningEffort : FALLBACK_DEFAULT_EFFORT;
57 const defaultValue = efforts.find((tier) => tier === preferredDefault) ?? efforts[0];
58 if (!defaultValue) return createModelCapabilities({ optionDescriptors: [] });
59 return createModelCapabilities({
60 optionDescriptors: [
61 {
62 id: "reasoningEffort",
63 label: "Reasoning",
64 type: "select",
65 currentValue: defaultValue,
66 options: efforts.map((id) => {
67 const description = model?.reasoningEffortVariants?.find(
68 (variant) => variant.tier === id,
69 )?.description;
70 return {
71 id,
72 label: REASONING_EFFORT_LABELS[id],
73 ...(id === defaultValue ? { isDefault: true } : {}),
74 ...(description ? { description } : {}),
75 };
76 }),
77 },
78 ],
79 });
80}
81
82/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */
83export function resolveMuseReasoningEffort(
84 capabilities: ModelCapabilities | null | undefined,
85 effort: string | undefined,
86): string | undefined {
87 if (!capabilities) return effort;
88 const descriptor = getProviderOptionDescriptors({
89 caps: capabilities,
90 selections: effort ? [{ id: "reasoningEffort", value: effort }] : undefined,
91 }).find((descriptor) => descriptor.id === "reasoningEffort");
92 if (descriptor?.type !== "select") return undefined;
93 return descriptor.options.find((option) => option.id === descriptor.currentValue)?.id;
94}
95
96class MuseCatalogError extends Schema.TaggedError<MuseCatalogError>()("MuseCatalogError", {
97 detail: Schema.String,
98}) {}
99
100export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* (
101 settings: MuseSettings,
102 environment: NodeJS.ProcessEnv | undefined,
103 cwd?: string,
104 createHost: typeof createMuseSdkHost = createMuseSdkHost,
105) {
106 const host = yield* Effect.acquireRelease(
107 createMuseSdkHostEffect(
108 {
109 binaryPath: settings.binaryPath,
110 ...(environment ? { environment } : {}),
111 ...(cwd ? { cwd } : {}),
112 readOnly: true,
113 startupTimeoutMs: 8_000,
114 },
115 createHost,
116 ),
117 (host: MuseSdkHost) => Effect.promise(() => host.close()),
118 { interruptible: true },
119 );
120 const result = yield* Effect.tryPromise(() => host.connection.request("model/list", {}));
121 const catalog = yield* decodeModelList(result);
122 if (catalog.providerId !== "meta") {
123 return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." });
124 }
125 const seen = new Set<string>();
126 return catalog.models.flatMap((model): ServerProviderModel[] => {
127 if (model.providerId !== "meta" || seen.has(model.modelId)) return [];
128 seen.add(model.modelId);
129 return [
130 {
131 slug: model.modelId,
132 name: model.displayLabel.trim() || model.modelId,
133 isCustom: false,
134 isDefault: model.isDefault,
135 capabilities: museModelCapabilities(model),
136 },
137 ];
138 });
139});