ubume 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,770 @@
1
+ import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
2
+ import type { BackendRunHandlers } from "../providers/types.js";
3
+ import type { LocalBackendId, ProviderWorkspaceOverride } from "../providerLauncher/types.js";
4
+ import type {
5
+ ProviderChatRequest,
6
+ ProviderModel,
7
+ ProviderModelDiscoveryResult,
8
+ ProviderRouteValidationResult,
9
+ ProviderRuntime,
10
+ ResolvedLocalAgentConfig,
11
+ } from "./types.js";
12
+ import { resolveModelCapabilityProfileCached, clearModelCapabilityProfileCache } from "./capabilityProfile.js";
13
+ import { resolveDefaultMaxOutputTokens } from "./localOutputBudget.js";
14
+ import { clearModelContextMetadataCache, resolveModelContextLengthCached } from "./contextMetadata.js";
15
+ import { deriveLmStudioApiRoot, fetchLmStudioModels, type LmStudioModelInfo, type LmStudioModelList } from "./lmstudio.js";
16
+ import { parseUnslothModels, resolveUnslothConnection } from "./unsloth.js";
17
+ import { runLocalHarness } from "./localHarness/runtime.js";
18
+
19
+ const DEFAULT_LOCAL_BASE_URL = "http://localhost:1234/v1";
20
+ const DEFAULT_LOCAL_API_KEY = "lm-studio";
21
+ const LOCAL_TIMEOUT_MS = Number(process.env.UBUME_LOCAL_TIMEOUT_MS?.trim()) || 15_000;
22
+ const LOCAL_ROUTE_SETUP_MESSAGE = [
23
+ "Local provider unavailable",
24
+ `Could not reach ${DEFAULT_LOCAL_BASE_URL}`,
25
+ "Start LM Studio, load a model, and enable the local server.",
26
+ ].join("\n");
27
+
28
+ type FetchImpl = typeof fetch;
29
+
30
+ interface LocalProviderConfig {
31
+ localBackend: LocalBackendId;
32
+ enabled: boolean;
33
+ type: "openai-compatible";
34
+ baseUrl: string;
35
+ apiKey: string;
36
+ pinnedModel: string | null;
37
+ currentModel: string | null;
38
+ defaultModel: string | null;
39
+ }
40
+
41
+ interface LocalDiscoveryCache {
42
+ configKey: string;
43
+ result: ProviderModelDiscoveryResult;
44
+ selectedModel: string | null;
45
+ checkedAt: number;
46
+ resolvedConfig: LocalProviderConfig;
47
+ }
48
+
49
+ let configuredOverride: ProviderWorkspaceOverride | null = null;
50
+ const discoveryCaches = new Map<LocalBackendId, LocalDiscoveryCache>();
51
+
52
+ function normalizeBaseUrl(value: string): string {
53
+ return value.trim().replace(/\/+$/, "");
54
+ }
55
+
56
+ function nonEmpty(value: string | undefined | null): string | null {
57
+ return typeof value === "string" && value.trim() ? value.trim() : null;
58
+ }
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+
64
+ export function setLocalProviderConfig(override: ProviderWorkspaceOverride | null | undefined): void {
65
+ configuredOverride = override ?? null;
66
+ }
67
+
68
+ export function resetLocalProviderStateForTests(): void {
69
+ configuredOverride = null;
70
+ discoveryCaches.clear();
71
+ clearModelCapabilityProfileCache();
72
+ clearModelContextMetadataCache();
73
+ }
74
+
75
+ export function resolveLocalProviderConfig(
76
+ override: ProviderWorkspaceOverride | null | undefined = configuredOverride,
77
+ env: NodeJS.ProcessEnv = process.env,
78
+ localBackend: LocalBackendId = override?.localBackend ?? "lm-studio",
79
+ ): LocalProviderConfig {
80
+ const baseUrl = nonEmpty(override?.baseUrl)
81
+ ?? nonEmpty(env.UBUME_LOCAL_BASE_URL)
82
+ ?? nonEmpty(env.OPENAI_BASE_URL)
83
+ ?? nonEmpty(env.OPENAI_API_BASE)
84
+ ?? DEFAULT_LOCAL_BASE_URL;
85
+ const apiKey = nonEmpty(override?.apiKey)
86
+ ?? nonEmpty(env.UBUME_LOCAL_API_KEY)
87
+ ?? nonEmpty(env.OPENAI_API_KEY)
88
+ ?? DEFAULT_LOCAL_API_KEY;
89
+ const currentModel = nonEmpty(override?.currentModel);
90
+ const defaultModel = nonEmpty(override?.defaultModel)
91
+ ?? nonEmpty(env.UBUME_LOCAL_MODEL);
92
+ const pinnedModel = nonEmpty(override?.pinnedModel);
93
+
94
+ return {
95
+ localBackend,
96
+ enabled: override?.enabled !== false,
97
+ type: override?.type ?? "openai-compatible",
98
+ baseUrl: normalizeBaseUrl(baseUrl),
99
+ apiKey,
100
+ pinnedModel,
101
+ currentModel,
102
+ defaultModel,
103
+ };
104
+ }
105
+
106
+ function localConfigKey(config: LocalProviderConfig): string {
107
+ return JSON.stringify({
108
+ localBackend: config.localBackend,
109
+ enabled: config.enabled,
110
+ type: config.type,
111
+ baseUrl: config.baseUrl,
112
+ pinnedModel: config.pinnedModel,
113
+ currentModel: config.currentModel,
114
+ defaultModel: config.defaultModel,
115
+ });
116
+ }
117
+
118
+ function modelFromId(id: string, source: ProviderModel["source"] = "discovered", raw: unknown = null): ProviderModel {
119
+ return {
120
+ id,
121
+ modelId: id,
122
+ label: id,
123
+ description: "Discovered from local OpenAI-compatible /v1/models endpoint.",
124
+ defaultReasoningLevel: null,
125
+ supportedReasoningLevels: null,
126
+ source,
127
+ raw,
128
+ };
129
+ }
130
+
131
+ function parseModels(body: unknown): ProviderModel[] {
132
+ const rawModels = typeof body === "object" && body !== null
133
+ ? Array.isArray((body as { data?: unknown }).data)
134
+ ? (body as { data: unknown[] }).data
135
+ : Array.isArray((body as { models?: unknown }).models)
136
+ ? (body as { models: unknown[] }).models
137
+ : []
138
+ : [];
139
+
140
+ const models = rawModels
141
+ .map((item) => {
142
+ if (typeof item === "string") return modelFromId(item, "discovered", item);
143
+ if (typeof item === "object" && item !== null && typeof (item as { id?: unknown }).id === "string") {
144
+ return modelFromId((item as { id: string }).id, "discovered", item);
145
+ }
146
+ return null;
147
+ })
148
+ .filter((model): model is ProviderModel => Boolean(model?.modelId.trim()));
149
+
150
+ const seen = new Set<string>();
151
+ return models.filter((model) => {
152
+ const key = model.modelId.toLowerCase();
153
+ if (seen.has(key)) return false;
154
+ seen.add(key);
155
+ return true;
156
+ });
157
+ }
158
+
159
+ function mergeModelIds(...groups: Array<readonly string[]>): string[] {
160
+ const seen = new Set<string>();
161
+ const result: string[] = [];
162
+ for (const group of groups) {
163
+ for (const id of group) {
164
+ const trimmed = id.trim();
165
+ const key = trimmed.toLowerCase();
166
+ if (!trimmed || seen.has(key)) continue;
167
+ seen.add(key);
168
+ result.push(trimmed);
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+
174
+ function mergeProviderModels(v1Models: readonly ProviderModel[], lmStudioModels: LmStudioModelList): ProviderModel[] {
175
+ const byId = new Map<string, ProviderModel>();
176
+ for (const model of v1Models) {
177
+ byId.set(model.modelId.toLowerCase(), model);
178
+ }
179
+ for (const lmModel of lmStudioModels.data) {
180
+ const key = lmModel.id.toLowerCase();
181
+ const existing = byId.get(key);
182
+ byId.set(key, existing
183
+ ? { ...existing, raw: { ...(isRecord(existing.raw) ? existing.raw : {}), ...lmModel } }
184
+ : modelFromId(lmModel.id, "discovered", lmModel));
185
+ }
186
+ return Array.from(byId.values());
187
+ }
188
+
189
+ function selectFallbackLocalModel(config: LocalProviderConfig, modelIds: readonly string[]): string | null {
190
+ for (const candidate of [config.pinnedModel, config.defaultModel, config.currentModel]) {
191
+ if (candidate && modelIds.includes(candidate)) return candidate;
192
+ }
193
+ return config.pinnedModel ?? config.defaultModel ?? config.currentModel ?? modelIds[0] ?? null;
194
+ }
195
+
196
+ function selectLoadedLmStudioModel(options: {
197
+ config: LocalProviderConfig;
198
+ loadedModels: readonly LmStudioModelInfo[];
199
+ previousModel: string | null;
200
+ }): { modelId: string | null; selectionReason: string } {
201
+ const loadedIds = options.loadedModels.map((model) => model.id);
202
+ if (options.config.pinnedModel && loadedIds.includes(options.config.pinnedModel)) {
203
+ return { modelId: options.config.pinnedModel, selectionReason: "pinned-loaded" };
204
+ }
205
+ if (loadedIds.length === 1) {
206
+ return { modelId: loadedIds[0] ?? null, selectionReason: "single-loaded" };
207
+ }
208
+ if (options.previousModel && loadedIds.includes(options.previousModel)) {
209
+ return { modelId: options.previousModel, selectionReason: "previous-loaded" };
210
+ }
211
+ return { modelId: loadedIds[0] ?? null, selectionReason: loadedIds.length > 1 ? "first-loaded" : "none-loaded" };
212
+ }
213
+
214
+ function diagnosticsFor(options: {
215
+ config: LocalProviderConfig;
216
+ status: "available" | "unavailable" | "no-models";
217
+ models: readonly string[];
218
+ selectedModel: string | null;
219
+ lmStudioEndpoint?: string | null;
220
+ loadedModels?: readonly { id: string }[];
221
+ selectedModelPrevious?: string | null;
222
+ selectionReason?: string | null;
223
+ contextField?: string | null;
224
+ error?: string | null;
225
+ }): Record<string, string | number | boolean | null> {
226
+ return {
227
+ localBackend: options.config.localBackend,
228
+ enabled: options.config.enabled,
229
+ type: options.config.type,
230
+ baseUrl: options.config.baseUrl,
231
+ lmStudioModelsEndpoint: options.lmStudioEndpoint ?? null,
232
+ pinnedModel: options.config.pinnedModel,
233
+ previousModel: options.selectedModelPrevious ?? null,
234
+ selectedModel: options.selectedModel,
235
+ discoveredModels: options.models.join(", "),
236
+ loadedModels: options.loadedModels?.map((model) => model.id).join(", ") ?? null,
237
+ modelCount: options.models.length,
238
+ endpointCheckResult: options.status,
239
+ selectionReason: options.selectionReason ?? null,
240
+ contextSource: options.contextField
241
+ ? options.config.localBackend === "unsloth" ? "unsloth-api" : "lmstudio-api"
242
+ : null,
243
+ contextRawField: options.contextField ?? null,
244
+ errorMessage: options.error ?? null,
245
+ };
246
+ }
247
+
248
+ function notConfiguredResult(
249
+ config: LocalProviderConfig,
250
+ message: string,
251
+ status: "unavailable" | "no-models" = "unavailable",
252
+ error?: string | null,
253
+ ): ProviderModelDiscoveryResult {
254
+ return {
255
+ status: "not-configured",
256
+ providerId: "local",
257
+ localBackend: config.localBackend,
258
+ backendKind: "unavailable",
259
+ models: [],
260
+ message,
261
+ diagnostics: diagnosticsFor({ config, status, models: [], selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, error }),
262
+ };
263
+ }
264
+
265
+ export function discoverLocalModels(
266
+ override: ProviderWorkspaceOverride | null | undefined = configuredOverride,
267
+ localBackend: LocalBackendId = override?.localBackend ?? "lm-studio",
268
+ ): ProviderModelDiscoveryResult {
269
+ const config = resolveLocalProviderConfig(override, process.env, localBackend);
270
+ const key = localConfigKey(config);
271
+ const cache = discoveryCaches.get(localBackend);
272
+ if (cache && (localBackend === "unsloth" || cache.configKey === key)) {
273
+ return cache.result;
274
+ }
275
+ return notConfiguredResult(config, LOCAL_ROUTE_SETUP_MESSAGE);
276
+ }
277
+
278
+ export async function checkLocalProvider(options: {
279
+ override?: ProviderWorkspaceOverride | null;
280
+ localBackend?: LocalBackendId;
281
+ fetchImpl?: FetchImpl;
282
+ signal?: AbortSignal;
283
+ } = {}): Promise<ProviderRouteValidationResult> {
284
+ const localBackend = options.localBackend ?? options.override?.localBackend ?? configuredOverride?.localBackend ?? "lm-studio";
285
+ if (localBackend === "unsloth") return checkUnslothProvider({ ...options, localBackend });
286
+ const config = resolveLocalProviderConfig(options.override ?? configuredOverride, process.env, localBackend);
287
+ const key = localConfigKey(config);
288
+ const previousCache = discoveryCaches.get(localBackend);
289
+ const previousSelectedModel = previousCache?.configKey === key ? previousCache.selectedModel : null;
290
+ clearModelCapabilityProfileCache();
291
+ clearModelContextMetadataCache();
292
+
293
+ if (!config.enabled) {
294
+ const result = notConfiguredResult(config, "Local provider is disabled in provider config.");
295
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now(), resolvedConfig: config });
296
+ return {
297
+ status: "not-configured",
298
+ providerId: "local",
299
+ backendKind: "unavailable",
300
+ message: result.message,
301
+ diagnostics: result.diagnostics,
302
+ };
303
+ }
304
+
305
+ if (config.type !== "openai-compatible") {
306
+ const message = `Local provider type "${config.type}" is not supported. Use openai-compatible.`;
307
+ const result = notConfiguredResult(config, message);
308
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now(), resolvedConfig: config });
309
+ return {
310
+ status: "not-configured",
311
+ providerId: "local",
312
+ backendKind: "unavailable",
313
+ message,
314
+ diagnostics: result.diagnostics,
315
+ };
316
+ }
317
+
318
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
319
+ const controller = new AbortController();
320
+ const timeout = setTimeout(() => controller.abort(), LOCAL_TIMEOUT_MS);
321
+ const abort = () => controller.abort();
322
+ options.signal?.addEventListener("abort", abort, { once: true });
323
+ try {
324
+ const response = await fetchImpl(`${config.baseUrl}/models`, {
325
+ method: "GET",
326
+ headers: {
327
+ Authorization: `Bearer ${config.apiKey}`,
328
+ },
329
+ signal: controller.signal,
330
+ });
331
+ const text = await response.text();
332
+ if (!response.ok) {
333
+ const message = [
334
+ "Local provider unavailable",
335
+ `Could not reach ${config.baseUrl}`,
336
+ sanitizeTerminalOutput(text).slice(0, 300) || `HTTP ${response.status}`,
337
+ ].join("\n");
338
+ const result = notConfiguredResult(config, message, "unavailable", `HTTP ${response.status}`);
339
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now(), resolvedConfig: config });
340
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
341
+ }
342
+
343
+ let parsed: unknown;
344
+ try {
345
+ parsed = text.trim() ? JSON.parse(text) : {};
346
+ } catch {
347
+ const message = "Local provider unavailable\n/v1/models returned invalid JSON.";
348
+ const result = notConfiguredResult(config, message, "unavailable", "invalid JSON");
349
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now(), resolvedConfig: config });
350
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
351
+ }
352
+
353
+ const rawModels = parseModels(parsed);
354
+ const v1ModelIds = rawModels.map((model) => model.modelId);
355
+ const apiRoot = deriveLmStudioApiRoot(config.baseUrl);
356
+ const lmStudioEndpoint = apiRoot ? `${apiRoot}/models` : null;
357
+ const lmStudioModels = apiRoot
358
+ ? await fetchLmStudioModels({
359
+ apiRoot,
360
+ fetchImpl,
361
+ signal: controller.signal,
362
+ })
363
+ : null;
364
+ const loadedModels = lmStudioModels?.data.filter((model) => model.state === "loaded") ?? [];
365
+ const loadedModelIds = loadedModels.map((model) => model.id);
366
+ const discoveredIds = mergeModelIds(loadedModelIds, v1ModelIds);
367
+
368
+ let selectedModel: string | null = null;
369
+ let selectionReason = "fallback";
370
+
371
+ if (lmStudioModels) {
372
+ if (loadedModels.length === 0) {
373
+ const message = "LM Studio is running, but no model is loaded.";
374
+ const models = mergeProviderModels(rawModels, lmStudioModels);
375
+ const result: ProviderModelDiscoveryResult = {
376
+ status: "not-configured",
377
+ providerId: "local",
378
+ localBackend,
379
+ backendKind: "unavailable",
380
+ models,
381
+ message,
382
+ diagnostics: diagnosticsFor({
383
+ config,
384
+ status: "no-models",
385
+ models: discoveredIds,
386
+ selectedModel: null,
387
+ lmStudioEndpoint,
388
+ loadedModels,
389
+ selectedModelPrevious: previousSelectedModel,
390
+ selectionReason: "none-loaded",
391
+ error: message,
392
+ }),
393
+ };
394
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: null, checkedAt: Date.now(), resolvedConfig: config });
395
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
396
+ }
397
+
398
+ const loadedSelection = selectLoadedLmStudioModel({
399
+ config,
400
+ loadedModels,
401
+ previousModel: previousSelectedModel,
402
+ });
403
+ selectedModel = loadedSelection.modelId;
404
+ selectionReason = loadedSelection.selectionReason;
405
+ } else {
406
+ selectedModel = selectFallbackLocalModel(config, v1ModelIds);
407
+ selectionReason = selectedModel === config.pinnedModel ? "pinned-available" : "fallback";
408
+ }
409
+
410
+ if (discoveredIds.length === 0) {
411
+ const message = "Local endpoint is reachable, but no models were returned. Load a model in LM Studio.";
412
+ const result = notConfiguredResult(config, message, "no-models");
413
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel, checkedAt: Date.now(), resolvedConfig: config });
414
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
415
+ }
416
+
417
+ const models = lmStudioModels
418
+ ? mergeProviderModels(rawModels, lmStudioModels)
419
+ : rawModels;
420
+ const selectedRaw = models.find((model) => model.modelId === selectedModel)?.raw;
421
+ const contextField = isRecord(selectedRaw) && typeof selectedRaw.loaded_context_length === "number"
422
+ ? "loaded_context_length"
423
+ : null;
424
+ const result: ProviderModelDiscoveryResult = {
425
+ status: "ready",
426
+ providerId: "local",
427
+ localBackend,
428
+ backendKind: "local-openai-compatible",
429
+ models,
430
+ message: [
431
+ "Local provider found",
432
+ "LM Studio endpoint reachable",
433
+ `Model: ${selectedModel}`,
434
+ ].join("\n"),
435
+ diagnostics: diagnosticsFor({
436
+ config,
437
+ status: "available",
438
+ models: discoveredIds,
439
+ selectedModel,
440
+ lmStudioEndpoint,
441
+ loadedModels,
442
+ selectedModelPrevious: previousSelectedModel,
443
+ selectionReason,
444
+ contextField,
445
+ }),
446
+ };
447
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel, checkedAt: Date.now(), resolvedConfig: config });
448
+ return {
449
+ status: "ready",
450
+ providerId: "local",
451
+ backendKind: "local-openai-compatible",
452
+ message: result.message,
453
+ diagnostics: result.diagnostics,
454
+ };
455
+ } catch (error) {
456
+ const errorMessage = error instanceof Error ? error.message : String(error);
457
+ const message = [
458
+ "Local provider unavailable",
459
+ `Could not reach ${config.baseUrl}`,
460
+ "Start LM Studio, load a model, and enable the local server.",
461
+ ].join("\n");
462
+ const result = notConfiguredResult(config, message, "unavailable", errorMessage);
463
+ discoveryCaches.set(localBackend, { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now(), resolvedConfig: config });
464
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
465
+ } finally {
466
+ clearTimeout(timeout);
467
+ options.signal?.removeEventListener("abort", abort);
468
+ }
469
+ }
470
+
471
+ async function checkUnslothProvider(options: {
472
+ override?: ProviderWorkspaceOverride | null;
473
+ localBackend: "unsloth";
474
+ fetchImpl?: FetchImpl;
475
+ signal?: AbortSignal;
476
+ }): Promise<ProviderRouteValidationResult> {
477
+ const initialConfig = resolveLocalProviderConfig(options.override ?? configuredOverride, process.env, "unsloth");
478
+ clearModelCapabilityProfileCache();
479
+ clearModelContextMetadataCache();
480
+ if (!initialConfig.enabled) {
481
+ const result = notConfiguredResult(initialConfig, "Unsloth is disabled in provider config.");
482
+ discoveryCaches.set("unsloth", { configKey: localConfigKey(initialConfig), result, selectedModel: null, checkedAt: Date.now(), resolvedConfig: initialConfig });
483
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message: result.message, diagnostics: result.diagnostics };
484
+ }
485
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
486
+ const controller = new AbortController();
487
+ const timeout = setTimeout(() => controller.abort(), LOCAL_TIMEOUT_MS);
488
+ const abort = () => controller.abort();
489
+ options.signal?.addEventListener("abort", abort, { once: true });
490
+ try {
491
+ const connection = await resolveUnslothConnection({ fetchImpl, signal: controller.signal });
492
+ const config: LocalProviderConfig = { ...initialConfig, baseUrl: connection.baseUrl, apiKey: connection.apiKey };
493
+ const key = localConfigKey(config);
494
+ const previous = discoveryCaches.get("unsloth");
495
+ const response = await fetchImpl(`${config.baseUrl}/models`, { headers: { Authorization: `Bearer ${config.apiKey}` }, redirect: "manual", signal: controller.signal });
496
+ const text = await response.text();
497
+ if (!response.ok) throw new Error(sanitizeTerminalOutput(text).slice(0, 300) || `HTTP ${response.status}`);
498
+ const unslothModels = parseUnslothModels(text.trim() ? JSON.parse(text) as unknown : {});
499
+ const loadedModels = unslothModels.filter((model) => model.loaded === true);
500
+ let status: Record<string, unknown> = {};
501
+ try {
502
+ const statusResponse = await fetchImpl(`${connection.rootUrl}/api/inference/status`, { headers: { Authorization: `Bearer ${config.apiKey}` }, redirect: "manual", signal: controller.signal });
503
+ if (statusResponse.ok) status = await statusResponse.json() as Record<string, unknown>;
504
+ } catch {}
505
+ const activeModel = typeof status.active_model === "string" ? status.active_model : null;
506
+ const loadedIds = loadedModels.map((model) => model.id);
507
+ // A pin is an explicit user override. Otherwise Unsloth's active model is
508
+ // authoritative: persisted defaults describe the previous session and must
509
+ // not pull the route back after the user loads a different model in Studio.
510
+ const selectedModel = [config.pinnedModel, activeModel, config.currentModel, config.defaultModel, previous?.selectedModel, loadedIds[0]]
511
+ .find((candidate): candidate is string => Boolean(candidate && loadedIds.includes(candidate))) ?? null;
512
+ const models = loadedModels.map((model) => modelFromId(model.id, "discovered", {
513
+ ...(isRecord(model.raw) ? model.raw : {}),
514
+ ...(model.id === selectedModel ? status : {}),
515
+ state: "loaded",
516
+ supports_tool_calls: model.id === selectedModel ? status.supports_tools : undefined,
517
+ supports_streaming: true,
518
+ context_length: model.id === selectedModel ? status.context_length : undefined,
519
+ max_context_length: model.id === selectedModel ? status.max_context_length : undefined,
520
+ }));
521
+ if (!selectedModel) {
522
+ const message = "Unsloth Studio is running, but no model is loaded.";
523
+ const result: ProviderModelDiscoveryResult = {
524
+ status: "not-configured", providerId: "local", localBackend: "unsloth", backendKind: "unavailable", models, message,
525
+ diagnostics: diagnosticsFor({ config, status: "no-models", models: unslothModels.map((model) => model.id), selectedModel: null, loadedModels, error: message }),
526
+ };
527
+ discoveryCaches.set("unsloth", { configKey: key, result, selectedModel: null, checkedAt: Date.now(), resolvedConfig: config });
528
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
529
+ }
530
+ const result: ProviderModelDiscoveryResult = {
531
+ status: "ready", providerId: "local", localBackend: "unsloth", backendKind: "local-openai-compatible", models,
532
+ message: ["Local provider found", "Unsloth Studio endpoint reachable", `Model: ${selectedModel}`].join("\n"),
533
+ diagnostics: diagnosticsFor({ config, status: "available", models: loadedIds, selectedModel, loadedModels, selectedModelPrevious: previous?.selectedModel ?? null, selectionReason: selectedModel === activeModel ? "active-loaded" : loadedIds.length === 1 ? "single-loaded" : "preferred-loaded", contextField: typeof status.context_length === "number" ? "context_length" : null }),
534
+ };
535
+ discoveryCaches.set("unsloth", { configKey: key, result, selectedModel, checkedAt: Date.now(), resolvedConfig: config });
536
+ return { status: "ready", providerId: "local", backendKind: "local-openai-compatible", message: result.message, diagnostics: result.diagnostics };
537
+ } catch (error) {
538
+ const errorMessage = error instanceof Error ? error.message : String(error);
539
+ const message = ["Unsloth provider unavailable", "Start Unsloth Studio and load a model.", errorMessage].join("\n");
540
+ const result = notConfiguredResult(initialConfig, message, "unavailable", errorMessage);
541
+ discoveryCaches.set("unsloth", { configKey: localConfigKey(initialConfig), result, selectedModel: null, checkedAt: Date.now(), resolvedConfig: initialConfig });
542
+ return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
543
+ } finally {
544
+ clearTimeout(timeout);
545
+ options.signal?.removeEventListener("abort", abort);
546
+ }
547
+ }
548
+
549
+ function getCachedSelectedModel(config: LocalProviderConfig, routeModel: string): string {
550
+ const candidate = discoveryCaches.get(config.localBackend);
551
+ const cache = candidate?.configKey === localConfigKey(config) ? candidate : null;
552
+ const discoveredIds = cache?.result.models.map((model) => model.modelId) ?? [];
553
+ if (cache?.selectedModel && discoveredIds.includes(cache.selectedModel)) return cache.selectedModel;
554
+ if (config.pinnedModel && discoveredIds.includes(config.pinnedModel)) return config.pinnedModel;
555
+ if (routeModel && discoveredIds.includes(routeModel)) return routeModel;
556
+ return selectFallbackLocalModel(config, discoveredIds) ?? routeModel;
557
+ }
558
+
559
+ async function resolveLocalAgentConfig(
560
+ request: ProviderChatRequest,
561
+ signal: AbortSignal,
562
+ fetchImpl: FetchImpl = globalThis.fetch,
563
+ ): Promise<ResolvedLocalAgentConfig> {
564
+ const localBackend = request.route.localBackend ?? request.localConfig?.localBackend ?? "lm-studio";
565
+ const modelId = request.route.modelId;
566
+ let config = resolveLocalProviderConfig(request.localConfig, process.env, localBackend);
567
+ let rawMetadata: Record<string, unknown> | undefined;
568
+
569
+ if (localBackend === "unsloth") {
570
+ const connection = await resolveUnslothConnection({ fetchImpl, signal });
571
+ config = { ...config, baseUrl: connection.baseUrl, apiKey: connection.apiKey };
572
+ const statusResponse = await fetchImpl(`${connection.rootUrl}/api/inference/status`, {
573
+ headers: { Authorization: `Bearer ${connection.apiKey}` },
574
+ redirect: "manual",
575
+ signal,
576
+ });
577
+ if (!statusResponse.ok) {
578
+ throw new Error(`Unsloth inference status returned HTTP ${statusResponse.status}.`);
579
+ }
580
+ const status = await statusResponse.json();
581
+ if (isRecord(status)) {
582
+ rawMetadata = {
583
+ ...status,
584
+ supports_streaming: true,
585
+ supports_tool_calls: status.supports_tools,
586
+ supports_system_prompt: true,
587
+ supports_vision: status.is_vision,
588
+ };
589
+ if (typeof status.active_model === "string" && status.active_model !== modelId) {
590
+ throw new Error(`Unsloth has model "${status.active_model}" active, but Ubume selected "${modelId}".`);
591
+ }
592
+ }
593
+ } else {
594
+ const cache = discoveryCaches.get(localBackend);
595
+ const discovered = cache?.result.models.find((model) => model.modelId === modelId)?.raw;
596
+ if (isRecord(discovered)) rawMetadata = discovered;
597
+ }
598
+
599
+ const context = resolveModelContextLengthCached({
600
+ providerId: "local",
601
+ modelId,
602
+ providerConfig: request.localConfig,
603
+ rawMetadata,
604
+ });
605
+ const capabilities = resolveModelCapabilityProfileCached({
606
+ providerId: "local",
607
+ modelId,
608
+ providerConfig: request.localConfig,
609
+ rawMetadata,
610
+ });
611
+ const configuredModel = request.localConfig?.models?.[modelId];
612
+ return {
613
+ localBackend,
614
+ baseUrl: config.baseUrl,
615
+ apiKey: config.apiKey,
616
+ modelId,
617
+ contextWindow: context.contextLength ?? configuredModel?.contextLength ?? 32_768,
618
+ maxTokens: capabilities.maxOutputTokens
619
+ ?? configuredModel?.maxOutputTokens
620
+ ?? resolveDefaultMaxOutputTokens(context.contextLength ?? configuredModel?.contextLength),
621
+ supportsStreaming: capabilities.supportsStreaming,
622
+ supportsToolCalls: capabilities.supportsToolCalls,
623
+ supportsSystemPrompt: capabilities.supportsSystemPrompt,
624
+ supportsVision: capabilities.supportsVision ?? rawMetadata?.supports_vision === true,
625
+ };
626
+ }
627
+
628
+ export async function runLocalDiagnostics(options: {
629
+ localConfig?: ProviderWorkspaceOverride | null;
630
+ localBackend?: LocalBackendId;
631
+ fetchImpl?: FetchImpl;
632
+ } = {}): Promise<string> {
633
+ const localBackend = options.localBackend ?? options.localConfig?.localBackend ?? configuredOverride?.localBackend ?? "lm-studio";
634
+ const validation = await checkLocalProvider({
635
+ override: options.localConfig ?? configuredOverride,
636
+ localBackend,
637
+ fetchImpl: options.fetchImpl,
638
+ });
639
+ const diagnostics = validation.diagnostics ?? {};
640
+ const models = String(diagnostics.discoveredModels ?? "").trim() || "none";
641
+ const selectedModelId = String(diagnostics.selectedModel ?? "");
642
+ const previousModelId = typeof diagnostics.previousModel === "string" ? diagnostics.previousModel : "";
643
+ const lmStudioEndpoint = String(diagnostics.lmStudioModelsEndpoint ?? "");
644
+ const cache = discoveryCaches.get(localBackend);
645
+ const modelRaw = cache?.result.models.find((m) => m.modelId === selectedModelId)?.raw;
646
+ const lmLines: (string | null)[] = [];
647
+ const loadedModels = cache?.result.models.filter((model) => {
648
+ const raw = model.raw;
649
+ return isRecord(raw) && raw.state === "loaded";
650
+ }) ?? [];
651
+ if (loadedModels.length > 0) {
652
+ lmLines.push("Loaded models:");
653
+ for (const model of loadedModels) {
654
+ const raw = isRecord(model.raw) ? model.raw : {};
655
+ lmLines.push(`- ${model.modelId}`);
656
+ if (typeof raw.state === "string") lmLines.push(` state: ${raw.state}`);
657
+ if (typeof raw.loaded_context_length === "number") lmLines.push(` loaded context: ${raw.loaded_context_length.toLocaleString()}`);
658
+ if (typeof raw.max_context_length === "number") lmLines.push(` max context: ${raw.max_context_length.toLocaleString()}`);
659
+ if (Array.isArray(raw.capabilities) && raw.capabilities.length > 0) {
660
+ lmLines.push(` capabilities: ${(raw.capabilities as unknown[]).join(", ")}`);
661
+ }
662
+ }
663
+ }
664
+ if (isRecord(modelRaw)) {
665
+ if (typeof modelRaw.state === "string") lmLines.push(`State: ${modelRaw.state}`);
666
+ if (typeof modelRaw.type === "string") lmLines.push(`Type: ${modelRaw.type}`);
667
+ if (typeof modelRaw.arch === "string") lmLines.push(`Architecture: ${modelRaw.arch}`);
668
+ if (typeof modelRaw.quantization === "string") lmLines.push(`Quantization: ${modelRaw.quantization}`);
669
+ if (typeof modelRaw.loaded_context_length === "number") {
670
+ lmLines.push(`Loaded context: ${modelRaw.loaded_context_length.toLocaleString()}`);
671
+ }
672
+ if (typeof modelRaw.max_context_length === "number") {
673
+ lmLines.push(`Max context: ${modelRaw.max_context_length.toLocaleString()}`);
674
+ }
675
+ if (Array.isArray(modelRaw.capabilities) && modelRaw.capabilities.length > 0) {
676
+ lmLines.push(`Capabilities: ${(modelRaw.capabilities as unknown[]).join(", ")}`);
677
+ }
678
+ }
679
+ if (selectedModelId) {
680
+ const contextMeta = resolveModelContextLengthCached({
681
+ providerId: "local",
682
+ modelId: selectedModelId,
683
+ rawMetadata: cache?.result.models.find((m) => m.modelId === selectedModelId)?.raw,
684
+ });
685
+ if (contextMeta.contextLength !== null) {
686
+ lmLines.push(`Active context: ${contextMeta.contextLength.toLocaleString()}`);
687
+ lmLines.push(`Active context limit: ${contextMeta.contextLength.toLocaleString()}`);
688
+ lmLines.push(`Source: ${contextMeta.source}`);
689
+ if (contextMeta.rawField) {
690
+ lmLines.push(`Field: ${contextMeta.rawField.replace(/^raw\./, "")}`);
691
+ }
692
+ }
693
+ }
694
+ if (previousModelId && selectedModelId && previousModelId !== selectedModelId) {
695
+ lmLines.push(`Previous/stale model cleared: ${previousModelId}`);
696
+ }
697
+ return [
698
+ "Local provider",
699
+ `Local: ${validation.status === "ready" ? "available" : "unavailable"}`,
700
+ `Base URL: ${diagnostics.baseUrl ?? resolveLocalProviderConfig(options.localConfig ?? configuredOverride).baseUrl}`,
701
+ lmStudioEndpoint ? `LM Studio models endpoint: ${lmStudioEndpoint}` : null,
702
+ `Models: ${models}`,
703
+ `Active model: ${diagnostics.selectedModel ?? "none"}`,
704
+ `Selected: ${diagnostics.selectedModel ?? "none"}`,
705
+ `Endpoint check: ${diagnostics.endpointCheckResult ?? "unknown"}`,
706
+ diagnostics.errorMessage ? `Error: ${diagnostics.errorMessage}` : null,
707
+ ...lmLines,
708
+ ].filter(Boolean).join("\n");
709
+ }
710
+
711
+ export const localRuntime: ProviderRuntime = {
712
+ providerId: "local",
713
+ label: "Local",
714
+ modelPickerLabel: "Local",
715
+ backendKind: "local-openai-compatible",
716
+ routeAvailable: true,
717
+ routeStatus: "Uses the DeepSeek Harness agent runtime with the configured Local OpenAI-compatible server.",
718
+ routeSetupMessage: LOCAL_ROUTE_SETUP_MESSAGE,
719
+ launchAvailable: false,
720
+ isRouteConfigured: () => discoverLocalModels().status === "ready",
721
+ validateRoute: async ({ route, localConfig, localBackend }) => checkLocalProvider({ override: localConfig ?? configuredOverride, localBackend: localBackend ?? route.localBackend }),
722
+ discoverModels: discoverLocalModels,
723
+ refreshModels: async ({ localConfig, localBackend }) => {
724
+ const backend = localBackend ?? localConfig?.localBackend ?? configuredOverride?.localBackend ?? "lm-studio";
725
+ const validation = await checkLocalProvider({ override: localConfig ?? configuredOverride, localBackend: backend });
726
+ return {
727
+ status: validation.status,
728
+ providerId: "local",
729
+ localBackend: backend,
730
+ backendKind: validation.backendKind,
731
+ models: validation.status === "ready" ? discoverLocalModels(localConfig, backend).models : [],
732
+ message: validation.message,
733
+ diagnostics: validation.diagnostics,
734
+ };
735
+ },
736
+ run: (request, handlers) => {
737
+ const controller = new AbortController();
738
+ handlers.onProgress?.({
739
+ id: "local-route",
740
+ source: "stdout",
741
+ text: "Starting Local agent harness",
742
+ });
743
+ resolveLocalAgentConfig(request, controller.signal)
744
+ .then((resolvedLocalAgentConfig) => {
745
+ if (controller.signal.aborted) throw new DOMException("Local request cancelled.", "AbortError");
746
+ return runLocalHarness({ ...request, resolvedLocalAgentConfig }, handlers, controller.signal);
747
+ })
748
+ .then((text) => {
749
+ if (controller.signal.aborted) return;
750
+ handlers.onResponse(text);
751
+ })
752
+ .catch((error) => {
753
+ if (controller.signal.aborted) return;
754
+ const detail = error instanceof Error ? error.message : "Local agent harness failed.";
755
+ const message = detail.startsWith("Local agent request failed") || detail.startsWith("Local Harness")
756
+ ? detail
757
+ : [
758
+ `Local agent request failed: ${detail}`,
759
+ `Backend: ${request.route.localBackend ?? request.localConfig?.localBackend ?? "lm-studio"}`,
760
+ `Model: ${request.route.modelId}`,
761
+ ].join("\n");
762
+ handlers.onError(message);
763
+ });
764
+ return () => controller.abort();
765
+ },
766
+ };
767
+
768
+ export const localRuntimeTestUtils = {
769
+ resolveLocalAgentConfig,
770
+ };