@golba98/codexa 1.0.1
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.
- package/README.md +320 -0
- package/bin/codexa.js +445 -0
- package/package.json +45 -0
- package/scripts/audit-codexa-capabilities.mjs +466 -0
- package/scripts/smoke-terminal-bench.mjs +35 -0
- package/src/app.tsx +4561 -0
- package/src/appRenderStability.test.ts +131 -0
- package/src/commands/handler.test.ts +643 -0
- package/src/commands/handler.ts +875 -0
- package/src/config/launchArgs.test.ts +158 -0
- package/src/config/launchArgs.ts +186 -0
- package/src/config/layeredConfig.test.ts +143 -0
- package/src/config/layeredConfig.ts +836 -0
- package/src/config/persistence.test.ts +110 -0
- package/src/config/persistence.ts +311 -0
- package/src/config/runtimeConfig.test.ts +218 -0
- package/src/config/runtimeConfig.ts +554 -0
- package/src/config/settings.test.ts +155 -0
- package/src/config/settings.ts +401 -0
- package/src/config/toml-serialize.ts +98 -0
- package/src/config/trustStore.test.ts +29 -0
- package/src/config/trustStore.ts +68 -0
- package/src/core/attachments.test.ts +155 -0
- package/src/core/attachments.ts +71 -0
- package/src/core/auth/codexAuth.test.ts +68 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/cleanupFastFail.test.ts +76 -0
- package/src/core/cleanupFastFail.ts +67 -0
- package/src/core/clipboard.ts +24 -0
- package/src/core/codex.ts +124 -0
- package/src/core/codexExecArgs.test.ts +195 -0
- package/src/core/codexExecArgs.ts +152 -0
- package/src/core/codexLaunch.test.ts +205 -0
- package/src/core/codexLaunch.ts +162 -0
- package/src/core/codexPrompt.test.ts +252 -0
- package/src/core/codexPrompt.ts +428 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.test.ts +212 -0
- package/src/core/executables/codexExecutable.ts +159 -0
- package/src/core/executables/executableResolver.test.ts +129 -0
- package/src/core/executables/executableResolver.ts +138 -0
- package/src/core/executables/geminiExecutable.test.ts +116 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/executables/pathSanityScan.test.ts +47 -0
- package/src/core/githubDiagnostics.test.ts +92 -0
- package/src/core/githubDiagnostics.ts +222 -0
- package/src/core/hollowResponseFormat.test.ts +58 -0
- package/src/core/hollowResponseFormat.ts +39 -0
- package/src/core/inputDebug.ts +51 -0
- package/src/core/launchContext.test.ts +157 -0
- package/src/core/launchContext.ts +266 -0
- package/src/core/models/codexCapabilities.test.ts +45 -0
- package/src/core/models/codexCapabilities.ts +95 -0
- package/src/core/models/codexModelCapabilities.test.ts +246 -0
- package/src/core/models/codexModelCapabilities.ts +571 -0
- package/src/core/models/modelSpecs.test.ts +283 -0
- package/src/core/models/modelSpecs.ts +300 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.test.ts +230 -0
- package/src/core/perf/renderDebug.ts +373 -0
- package/src/core/planStorage.test.ts +143 -0
- package/src/core/planStorage.ts +141 -0
- package/src/core/process/CommandRunner.test.ts +105 -0
- package/src/core/process/CommandRunner.ts +269 -0
- package/src/core/process/processValidation.ts +101 -0
- package/src/core/projectInstructions.test.ts +50 -0
- package/src/core/projectInstructions.ts +54 -0
- package/src/core/providerLauncher/launcher.test.ts +238 -0
- package/src/core/providerLauncher/launcher.ts +203 -0
- package/src/core/providerLauncher/registry.test.ts +324 -0
- package/src/core/providerLauncher/registry.ts +253 -0
- package/src/core/providerLauncher/types.ts +84 -0
- package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
- package/src/core/providerLauncher/workspaceConfig.ts +407 -0
- package/src/core/providerRuntime/anthropic.test.ts +1120 -0
- package/src/core/providerRuntime/anthropic.ts +576 -0
- package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
- package/src/core/providerRuntime/capabilityProfile.ts +288 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
- package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
- package/src/core/providerRuntime/contextMetadata.ts +409 -0
- package/src/core/providerRuntime/gemini.test.ts +437 -0
- package/src/core/providerRuntime/gemini.ts +784 -0
- package/src/core/providerRuntime/lmstudio.test.ts +168 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.test.ts +787 -0
- package/src/core/providerRuntime/local.ts +754 -0
- package/src/core/providerRuntime/models.ts +150 -0
- package/src/core/providerRuntime/reasoning.ts +17 -0
- package/src/core/providerRuntime/registry.test.ts +233 -0
- package/src/core/providerRuntime/registry.ts +203 -0
- package/src/core/providerRuntime/types.ts +103 -0
- package/src/core/providers/codexJsonStream.test.ts +148 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.test.ts +68 -0
- package/src/core/providers/codexSubprocess.ts +372 -0
- package/src/core/providers/codexTranscript.test.ts +284 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +59 -0
- package/src/core/terminal/terminalCapabilities.test.ts +93 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.test.ts +75 -0
- package/src/core/terminal/terminalControl.ts +147 -0
- package/src/core/terminal/terminalSanitize.test.ts +22 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalSelection.test.ts +42 -0
- package/src/core/terminal/terminalSelection.ts +66 -0
- package/src/core/terminal/terminalTitle.test.ts +328 -0
- package/src/core/terminal/terminalTitle.ts +483 -0
- package/src/core/workspaceActivity.test.ts +163 -0
- package/src/core/workspaceActivity.ts +380 -0
- package/src/core/workspaceGuard.test.ts +151 -0
- package/src/core/workspaceGuard.ts +288 -0
- package/src/core/workspaceRoot.test.ts +23 -0
- package/src/core/workspaceRoot.ts +47 -0
- package/src/exec.test.ts +13 -0
- package/src/exec.ts +72 -0
- package/src/headless/execArgs.test.ts +147 -0
- package/src/headless/execArgs.ts +294 -0
- package/src/headless/execRunner.test.ts +434 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.test.tsx +618 -0
- package/src/index.tsx +296 -0
- package/src/session/appSession.test.ts +897 -0
- package/src/session/appSession.ts +761 -0
- package/src/session/chatLifecycle.test.ts +64 -0
- package/src/session/chatLifecycle.ts +951 -0
- package/src/session/liveRenderScheduler.test.ts +201 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/planFlow.test.ts +103 -0
- package/src/session/planFlow.ts +149 -0
- package/src/session/planTranscript.test.ts +65 -0
- package/src/session/planTranscript.ts +15 -0
- package/src/session/promptRunSchedule.test.ts +36 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +228 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/ActionRequiredBlock.tsx +38 -0
- package/src/ui/ActivityBars.tsx +68 -0
- package/src/ui/ActivityIndicator.test.tsx +58 -0
- package/src/ui/ActivityIndicator.tsx +58 -0
- package/src/ui/AgentBlock.test.ts +6 -0
- package/src/ui/AgentBlock.tsx +130 -0
- package/src/ui/AnimatedStatusText.test.ts +16 -0
- package/src/ui/AnimatedStatusText.tsx +69 -0
- package/src/ui/AppShell.test.tsx +1739 -0
- package/src/ui/AppShell.tsx +698 -0
- package/src/ui/AttachmentImportPanel.test.tsx +204 -0
- package/src/ui/AttachmentImportPanel.tsx +98 -0
- package/src/ui/AuthPanel.tsx +113 -0
- package/src/ui/BackendPicker.tsx +28 -0
- package/src/ui/BottomComposer.test.ts +674 -0
- package/src/ui/BottomComposer.tsx +1028 -0
- package/src/ui/CodexLogo.tsx +55 -0
- package/src/ui/DashCard.tsx +82 -0
- package/src/ui/Markdown.test.ts +157 -0
- package/src/ui/Markdown.tsx +310 -0
- package/src/ui/ModePicker.tsx +27 -0
- package/src/ui/ModelPicker.tsx +31 -0
- package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
- package/src/ui/ModelPickerScreen.test.tsx +99 -0
- package/src/ui/ModelPickerScreen.tsx +416 -0
- package/src/ui/ModelPickerState.test.tsx +151 -0
- package/src/ui/ModelReasoningPicker.test.tsx +447 -0
- package/src/ui/ModelReasoningPicker.tsx +458 -0
- package/src/ui/Panel.tsx +51 -0
- package/src/ui/PermissionsPanel.tsx +78 -0
- package/src/ui/PlanActionPicker.tsx +119 -0
- package/src/ui/PlanReviewPanel.test.tsx +267 -0
- package/src/ui/PlanReviewPanel.tsx +212 -0
- package/src/ui/PromptCardBorder.test.tsx +161 -0
- package/src/ui/ProviderPicker.test.tsx +289 -0
- package/src/ui/ProviderPicker.tsx +321 -0
- package/src/ui/ProviderShortcut.test.tsx +143 -0
- package/src/ui/ReasoningPicker.tsx +46 -0
- package/src/ui/RunFooter.tsx +65 -0
- package/src/ui/SelectionPanel.tsx +67 -0
- package/src/ui/SettingsPanel.test.tsx +233 -0
- package/src/ui/SettingsPanel.tsx +156 -0
- package/src/ui/Spinner.tsx +25 -0
- package/src/ui/StaticIntroItem.tsx +54 -0
- package/src/ui/StaticTranscriptItem.tsx +56 -0
- package/src/ui/TextEntryPanel.tsx +139 -0
- package/src/ui/ThemePicker.tsx +31 -0
- package/src/ui/ThinkingBlock.tsx +100 -0
- package/src/ui/Timeline.test.ts +2067 -0
- package/src/ui/Timeline.tsx +1472 -0
- package/src/ui/TimelineNavigation.test.tsx +201 -0
- package/src/ui/TopHeader.test.tsx +239 -0
- package/src/ui/TopHeader.tsx +257 -0
- package/src/ui/TurnGroup.test.tsx +365 -0
- package/src/ui/TurnGroup.tsx +657 -0
- package/src/ui/busyStatusAnimation.test.ts +30 -0
- package/src/ui/busyStatusAnimation.ts +11 -0
- package/src/ui/commandNormalize.test.ts +142 -0
- package/src/ui/commandNormalize.ts +66 -0
- package/src/ui/diffRenderer.test.ts +102 -0
- package/src/ui/diffRenderer.ts +116 -0
- package/src/ui/focus.ts +61 -0
- package/src/ui/focusFlow.test.tsx +1098 -0
- package/src/ui/inputBuffer.test.ts +151 -0
- package/src/ui/inputBuffer.ts +203 -0
- package/src/ui/layout.test.ts +145 -0
- package/src/ui/layout.ts +287 -0
- package/src/ui/modeDisplay.test.ts +42 -0
- package/src/ui/modeDisplay.ts +52 -0
- package/src/ui/outputPipeline.ts +64 -0
- package/src/ui/progressEntries.ts +156 -0
- package/src/ui/runActivityView.test.ts +89 -0
- package/src/ui/runActivityView.ts +37 -0
- package/src/ui/runLifecycleView.test.tsx +237 -0
- package/src/ui/slashCommands.ts +41 -0
- package/src/ui/statusRenderIsolation.test.tsx +654 -0
- package/src/ui/terminalAnswerFormat.test.ts +19 -0
- package/src/ui/terminalAnswerFormat.ts +128 -0
- package/src/ui/textLayout.test.ts +18 -0
- package/src/ui/textLayout.ts +338 -0
- package/src/ui/theme.tsx +395 -0
- package/src/ui/themeFlow.test.ts +53 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timelineMeasure.ts +3088 -0
- package/src/ui/timelineMeasureCache.test.ts +986 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
|
|
2
|
+
import type { BackendRunHandlers } from "../providers/types.js";
|
|
3
|
+
import type { ProviderWorkspaceOverride } from "../providerLauncher/types.js";
|
|
4
|
+
import type {
|
|
5
|
+
ProviderChatRequest,
|
|
6
|
+
ProviderModel,
|
|
7
|
+
ProviderModelDiscoveryResult,
|
|
8
|
+
ProviderRouteValidationResult,
|
|
9
|
+
ProviderRuntime,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
import { resolveModelCapabilityProfileCached, clearModelCapabilityProfileCache } from "./capabilityProfile.js";
|
|
12
|
+
import { clearModelContextMetadataCache, resolveModelContextLengthCached } from "./contextMetadata.js";
|
|
13
|
+
import { deriveLmStudioApiRoot, fetchLmStudioModels, type LmStudioModelInfo, type LmStudioModelList } from "./lmstudio.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_LOCAL_BASE_URL = "http://localhost:1234/v1";
|
|
16
|
+
const DEFAULT_LOCAL_API_KEY = "lm-studio";
|
|
17
|
+
const LOCAL_TIMEOUT_MS = Number(process.env.CODEXA_LOCAL_TIMEOUT_MS?.trim()) || 15_000;
|
|
18
|
+
const LOCAL_ROUTE_SETUP_MESSAGE = [
|
|
19
|
+
"Local provider unavailable",
|
|
20
|
+
`Could not reach ${DEFAULT_LOCAL_BASE_URL}`,
|
|
21
|
+
"Start LM Studio, load a model, and enable the local server.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
type FetchImpl = typeof fetch;
|
|
25
|
+
|
|
26
|
+
interface LocalProviderConfig {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
type: "openai-compatible";
|
|
29
|
+
baseUrl: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
pinnedModel: string | null;
|
|
32
|
+
currentModel: string | null;
|
|
33
|
+
defaultModel: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LocalDiscoveryCache {
|
|
37
|
+
configKey: string;
|
|
38
|
+
result: ProviderModelDiscoveryResult;
|
|
39
|
+
selectedModel: string | null;
|
|
40
|
+
checkedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let configuredOverride: ProviderWorkspaceOverride | null = null;
|
|
44
|
+
let discoveryCache: LocalDiscoveryCache | null = null;
|
|
45
|
+
|
|
46
|
+
function normalizeBaseUrl(value: string): string {
|
|
47
|
+
return value.trim().replace(/\/+$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nonEmpty(value: string | undefined | null): string | null {
|
|
51
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setLocalProviderConfig(override: ProviderWorkspaceOverride | null | undefined): void {
|
|
59
|
+
configuredOverride = override ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resetLocalProviderStateForTests(): void {
|
|
63
|
+
configuredOverride = null;
|
|
64
|
+
discoveryCache = null;
|
|
65
|
+
clearModelCapabilityProfileCache();
|
|
66
|
+
clearModelContextMetadataCache();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveLocalProviderConfig(
|
|
70
|
+
override: ProviderWorkspaceOverride | null | undefined = configuredOverride,
|
|
71
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
72
|
+
): LocalProviderConfig {
|
|
73
|
+
const baseUrl = nonEmpty(override?.baseUrl)
|
|
74
|
+
?? nonEmpty(env.CODEXA_LOCAL_BASE_URL)
|
|
75
|
+
?? nonEmpty(env.OPENAI_BASE_URL)
|
|
76
|
+
?? nonEmpty(env.OPENAI_API_BASE)
|
|
77
|
+
?? DEFAULT_LOCAL_BASE_URL;
|
|
78
|
+
const apiKey = nonEmpty(override?.apiKey)
|
|
79
|
+
?? nonEmpty(env.CODEXA_LOCAL_API_KEY)
|
|
80
|
+
?? nonEmpty(env.OPENAI_API_KEY)
|
|
81
|
+
?? DEFAULT_LOCAL_API_KEY;
|
|
82
|
+
const currentModel = nonEmpty(override?.currentModel);
|
|
83
|
+
const defaultModel = nonEmpty(override?.defaultModel)
|
|
84
|
+
?? nonEmpty(env.CODEXA_LOCAL_MODEL);
|
|
85
|
+
const pinnedModel = nonEmpty(override?.pinnedModel);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
enabled: override?.enabled !== false,
|
|
89
|
+
type: override?.type ?? "openai-compatible",
|
|
90
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
91
|
+
apiKey,
|
|
92
|
+
pinnedModel,
|
|
93
|
+
currentModel,
|
|
94
|
+
defaultModel,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function localConfigKey(config: LocalProviderConfig): string {
|
|
99
|
+
return JSON.stringify({
|
|
100
|
+
enabled: config.enabled,
|
|
101
|
+
type: config.type,
|
|
102
|
+
baseUrl: config.baseUrl,
|
|
103
|
+
pinnedModel: config.pinnedModel,
|
|
104
|
+
currentModel: config.currentModel,
|
|
105
|
+
defaultModel: config.defaultModel,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function modelFromId(id: string, source: ProviderModel["source"] = "discovered", raw: unknown = null): ProviderModel {
|
|
110
|
+
return {
|
|
111
|
+
id,
|
|
112
|
+
modelId: id,
|
|
113
|
+
label: id,
|
|
114
|
+
description: "Discovered from local OpenAI-compatible /v1/models endpoint.",
|
|
115
|
+
defaultReasoningLevel: null,
|
|
116
|
+
supportedReasoningLevels: null,
|
|
117
|
+
source,
|
|
118
|
+
raw,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseModels(body: unknown): ProviderModel[] {
|
|
123
|
+
const rawModels = typeof body === "object" && body !== null
|
|
124
|
+
? Array.isArray((body as { data?: unknown }).data)
|
|
125
|
+
? (body as { data: unknown[] }).data
|
|
126
|
+
: Array.isArray((body as { models?: unknown }).models)
|
|
127
|
+
? (body as { models: unknown[] }).models
|
|
128
|
+
: []
|
|
129
|
+
: [];
|
|
130
|
+
|
|
131
|
+
const models = rawModels
|
|
132
|
+
.map((item) => {
|
|
133
|
+
if (typeof item === "string") return modelFromId(item, "discovered", item);
|
|
134
|
+
if (typeof item === "object" && item !== null && typeof (item as { id?: unknown }).id === "string") {
|
|
135
|
+
return modelFromId((item as { id: string }).id, "discovered", item);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
})
|
|
139
|
+
.filter((model): model is ProviderModel => Boolean(model?.modelId.trim()));
|
|
140
|
+
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
return models.filter((model) => {
|
|
143
|
+
const key = model.modelId.toLowerCase();
|
|
144
|
+
if (seen.has(key)) return false;
|
|
145
|
+
seen.add(key);
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mergeModelIds(...groups: Array<readonly string[]>): string[] {
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
const result: string[] = [];
|
|
153
|
+
for (const group of groups) {
|
|
154
|
+
for (const id of group) {
|
|
155
|
+
const trimmed = id.trim();
|
|
156
|
+
const key = trimmed.toLowerCase();
|
|
157
|
+
if (!trimmed || seen.has(key)) continue;
|
|
158
|
+
seen.add(key);
|
|
159
|
+
result.push(trimmed);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function mergeProviderModels(v1Models: readonly ProviderModel[], lmStudioModels: LmStudioModelList): ProviderModel[] {
|
|
166
|
+
const byId = new Map<string, ProviderModel>();
|
|
167
|
+
for (const model of v1Models) {
|
|
168
|
+
byId.set(model.modelId.toLowerCase(), model);
|
|
169
|
+
}
|
|
170
|
+
for (const lmModel of lmStudioModels.data) {
|
|
171
|
+
const key = lmModel.id.toLowerCase();
|
|
172
|
+
const existing = byId.get(key);
|
|
173
|
+
byId.set(key, existing
|
|
174
|
+
? { ...existing, raw: { ...(isRecord(existing.raw) ? existing.raw : {}), ...lmModel } }
|
|
175
|
+
: modelFromId(lmModel.id, "discovered", lmModel));
|
|
176
|
+
}
|
|
177
|
+
return Array.from(byId.values());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function selectFallbackLocalModel(config: LocalProviderConfig, modelIds: readonly string[]): string | null {
|
|
181
|
+
for (const candidate of [config.pinnedModel, config.defaultModel, config.currentModel]) {
|
|
182
|
+
if (candidate && modelIds.includes(candidate)) return candidate;
|
|
183
|
+
}
|
|
184
|
+
return config.pinnedModel ?? config.defaultModel ?? config.currentModel ?? modelIds[0] ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function selectLoadedLmStudioModel(options: {
|
|
188
|
+
config: LocalProviderConfig;
|
|
189
|
+
loadedModels: readonly LmStudioModelInfo[];
|
|
190
|
+
previousModel: string | null;
|
|
191
|
+
}): { modelId: string | null; selectionReason: string } {
|
|
192
|
+
const loadedIds = options.loadedModels.map((model) => model.id);
|
|
193
|
+
if (options.config.pinnedModel && loadedIds.includes(options.config.pinnedModel)) {
|
|
194
|
+
return { modelId: options.config.pinnedModel, selectionReason: "pinned-loaded" };
|
|
195
|
+
}
|
|
196
|
+
if (loadedIds.length === 1) {
|
|
197
|
+
return { modelId: loadedIds[0] ?? null, selectionReason: "single-loaded" };
|
|
198
|
+
}
|
|
199
|
+
if (options.previousModel && loadedIds.includes(options.previousModel)) {
|
|
200
|
+
return { modelId: options.previousModel, selectionReason: "previous-loaded" };
|
|
201
|
+
}
|
|
202
|
+
return { modelId: loadedIds[0] ?? null, selectionReason: loadedIds.length > 1 ? "first-loaded" : "none-loaded" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function diagnosticsFor(options: {
|
|
206
|
+
config: LocalProviderConfig;
|
|
207
|
+
status: "available" | "unavailable" | "no-models";
|
|
208
|
+
models: readonly string[];
|
|
209
|
+
selectedModel: string | null;
|
|
210
|
+
lmStudioEndpoint?: string | null;
|
|
211
|
+
loadedModels?: readonly LmStudioModelInfo[];
|
|
212
|
+
selectedModelPrevious?: string | null;
|
|
213
|
+
selectionReason?: string | null;
|
|
214
|
+
contextField?: string | null;
|
|
215
|
+
error?: string | null;
|
|
216
|
+
}): Record<string, string | number | boolean | null> {
|
|
217
|
+
return {
|
|
218
|
+
enabled: options.config.enabled,
|
|
219
|
+
type: options.config.type,
|
|
220
|
+
baseUrl: options.config.baseUrl,
|
|
221
|
+
lmStudioModelsEndpoint: options.lmStudioEndpoint ?? null,
|
|
222
|
+
pinnedModel: options.config.pinnedModel,
|
|
223
|
+
previousModel: options.selectedModelPrevious ?? null,
|
|
224
|
+
selectedModel: options.selectedModel,
|
|
225
|
+
discoveredModels: options.models.join(", "),
|
|
226
|
+
loadedModels: options.loadedModels?.map((model) => model.id).join(", ") ?? null,
|
|
227
|
+
modelCount: options.models.length,
|
|
228
|
+
endpointCheckResult: options.status,
|
|
229
|
+
selectionReason: options.selectionReason ?? null,
|
|
230
|
+
contextSource: options.contextField ? "lmstudio-api" : null,
|
|
231
|
+
contextRawField: options.contextField ?? null,
|
|
232
|
+
errorMessage: options.error ?? null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function notConfiguredResult(
|
|
237
|
+
config: LocalProviderConfig,
|
|
238
|
+
message: string,
|
|
239
|
+
status: "unavailable" | "no-models" = "unavailable",
|
|
240
|
+
error?: string | null,
|
|
241
|
+
): ProviderModelDiscoveryResult {
|
|
242
|
+
return {
|
|
243
|
+
status: "not-configured",
|
|
244
|
+
providerId: "local",
|
|
245
|
+
backendKind: "unavailable",
|
|
246
|
+
models: [],
|
|
247
|
+
message,
|
|
248
|
+
diagnostics: diagnosticsFor({ config, status, models: [], selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, error }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function discoverLocalModels(
|
|
253
|
+
override: ProviderWorkspaceOverride | null | undefined = configuredOverride,
|
|
254
|
+
): ProviderModelDiscoveryResult {
|
|
255
|
+
const config = resolveLocalProviderConfig(override);
|
|
256
|
+
const key = localConfigKey(config);
|
|
257
|
+
if (discoveryCache?.configKey === key) {
|
|
258
|
+
return discoveryCache.result;
|
|
259
|
+
}
|
|
260
|
+
return notConfiguredResult(config, LOCAL_ROUTE_SETUP_MESSAGE);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function checkLocalProvider(options: {
|
|
264
|
+
override?: ProviderWorkspaceOverride | null;
|
|
265
|
+
fetchImpl?: FetchImpl;
|
|
266
|
+
signal?: AbortSignal;
|
|
267
|
+
} = {}): Promise<ProviderRouteValidationResult> {
|
|
268
|
+
const config = resolveLocalProviderConfig(options.override ?? configuredOverride);
|
|
269
|
+
const key = localConfigKey(config);
|
|
270
|
+
const previousSelectedModel = discoveryCache?.configKey === key ? discoveryCache.selectedModel : null;
|
|
271
|
+
clearModelCapabilityProfileCache();
|
|
272
|
+
clearModelContextMetadataCache();
|
|
273
|
+
|
|
274
|
+
if (!config.enabled) {
|
|
275
|
+
const result = notConfiguredResult(config, "Local provider is disabled in provider config.");
|
|
276
|
+
discoveryCache = { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now() };
|
|
277
|
+
return {
|
|
278
|
+
status: "not-configured",
|
|
279
|
+
providerId: "local",
|
|
280
|
+
backendKind: "unavailable",
|
|
281
|
+
message: result.message,
|
|
282
|
+
diagnostics: result.diagnostics,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (config.type !== "openai-compatible") {
|
|
287
|
+
const message = `Local provider type "${config.type}" is not supported. Use openai-compatible.`;
|
|
288
|
+
const result = notConfiguredResult(config, message);
|
|
289
|
+
discoveryCache = { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now() };
|
|
290
|
+
return {
|
|
291
|
+
status: "not-configured",
|
|
292
|
+
providerId: "local",
|
|
293
|
+
backendKind: "unavailable",
|
|
294
|
+
message,
|
|
295
|
+
diagnostics: result.diagnostics,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
300
|
+
const controller = new AbortController();
|
|
301
|
+
const timeout = setTimeout(() => controller.abort(), LOCAL_TIMEOUT_MS);
|
|
302
|
+
const abort = () => controller.abort();
|
|
303
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
304
|
+
try {
|
|
305
|
+
const response = await fetchImpl(`${config.baseUrl}/models`, {
|
|
306
|
+
method: "GET",
|
|
307
|
+
headers: {
|
|
308
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
309
|
+
},
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
});
|
|
312
|
+
const text = await response.text();
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const message = [
|
|
315
|
+
"Local provider unavailable",
|
|
316
|
+
`Could not reach ${config.baseUrl}`,
|
|
317
|
+
sanitizeTerminalOutput(text).slice(0, 300) || `HTTP ${response.status}`,
|
|
318
|
+
].join("\n");
|
|
319
|
+
const result = notConfiguredResult(config, message, "unavailable", `HTTP ${response.status}`);
|
|
320
|
+
discoveryCache = { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now() };
|
|
321
|
+
return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let parsed: unknown;
|
|
325
|
+
try {
|
|
326
|
+
parsed = text.trim() ? JSON.parse(text) : {};
|
|
327
|
+
} catch {
|
|
328
|
+
const message = "Local provider unavailable\n/v1/models returned invalid JSON.";
|
|
329
|
+
const result = notConfiguredResult(config, message, "unavailable", "invalid JSON");
|
|
330
|
+
discoveryCache = { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now() };
|
|
331
|
+
return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const rawModels = parseModels(parsed);
|
|
335
|
+
const v1ModelIds = rawModels.map((model) => model.modelId);
|
|
336
|
+
const apiRoot = deriveLmStudioApiRoot(config.baseUrl);
|
|
337
|
+
const lmStudioEndpoint = apiRoot ? `${apiRoot}/models` : null;
|
|
338
|
+
const lmStudioModels = apiRoot
|
|
339
|
+
? await fetchLmStudioModels({
|
|
340
|
+
apiRoot,
|
|
341
|
+
fetchImpl,
|
|
342
|
+
signal: controller.signal,
|
|
343
|
+
})
|
|
344
|
+
: null;
|
|
345
|
+
const loadedModels = lmStudioModels?.data.filter((model) => model.state === "loaded") ?? [];
|
|
346
|
+
const loadedModelIds = loadedModels.map((model) => model.id);
|
|
347
|
+
const discoveredIds = mergeModelIds(loadedModelIds, v1ModelIds);
|
|
348
|
+
|
|
349
|
+
let selectedModel: string | null = null;
|
|
350
|
+
let selectionReason = "fallback";
|
|
351
|
+
|
|
352
|
+
if (lmStudioModels) {
|
|
353
|
+
if (loadedModels.length === 0) {
|
|
354
|
+
const message = "LM Studio is running, but no model is loaded.";
|
|
355
|
+
const models = mergeProviderModels(rawModels, lmStudioModels);
|
|
356
|
+
const result: ProviderModelDiscoveryResult = {
|
|
357
|
+
status: "not-configured",
|
|
358
|
+
providerId: "local",
|
|
359
|
+
backendKind: "unavailable",
|
|
360
|
+
models,
|
|
361
|
+
message,
|
|
362
|
+
diagnostics: diagnosticsFor({
|
|
363
|
+
config,
|
|
364
|
+
status: "no-models",
|
|
365
|
+
models: discoveredIds,
|
|
366
|
+
selectedModel: null,
|
|
367
|
+
lmStudioEndpoint,
|
|
368
|
+
loadedModels,
|
|
369
|
+
selectedModelPrevious: previousSelectedModel,
|
|
370
|
+
selectionReason: "none-loaded",
|
|
371
|
+
error: message,
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
discoveryCache = { configKey: key, result, selectedModel: null, checkedAt: Date.now() };
|
|
375
|
+
return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const loadedSelection = selectLoadedLmStudioModel({
|
|
379
|
+
config,
|
|
380
|
+
loadedModels,
|
|
381
|
+
previousModel: previousSelectedModel,
|
|
382
|
+
});
|
|
383
|
+
selectedModel = loadedSelection.modelId;
|
|
384
|
+
selectionReason = loadedSelection.selectionReason;
|
|
385
|
+
} else {
|
|
386
|
+
selectedModel = selectFallbackLocalModel(config, v1ModelIds);
|
|
387
|
+
selectionReason = selectedModel === config.pinnedModel ? "pinned-available" : "fallback";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (discoveredIds.length === 0) {
|
|
391
|
+
const message = "Local endpoint is reachable, but no models were returned. Load a model in LM Studio.";
|
|
392
|
+
const result = notConfiguredResult(config, message, "no-models");
|
|
393
|
+
discoveryCache = { configKey: key, result, selectedModel, checkedAt: Date.now() };
|
|
394
|
+
return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const models = lmStudioModels
|
|
398
|
+
? mergeProviderModels(rawModels, lmStudioModels)
|
|
399
|
+
: rawModels;
|
|
400
|
+
const selectedRaw = models.find((model) => model.modelId === selectedModel)?.raw;
|
|
401
|
+
const contextField = isRecord(selectedRaw) && typeof selectedRaw.loaded_context_length === "number"
|
|
402
|
+
? "loaded_context_length"
|
|
403
|
+
: null;
|
|
404
|
+
const result: ProviderModelDiscoveryResult = {
|
|
405
|
+
status: "ready",
|
|
406
|
+
providerId: "local",
|
|
407
|
+
backendKind: "local-openai-compatible",
|
|
408
|
+
models,
|
|
409
|
+
message: [
|
|
410
|
+
"Local provider found",
|
|
411
|
+
"LM Studio endpoint reachable",
|
|
412
|
+
`Model: ${selectedModel}`,
|
|
413
|
+
].join("\n"),
|
|
414
|
+
diagnostics: diagnosticsFor({
|
|
415
|
+
config,
|
|
416
|
+
status: "available",
|
|
417
|
+
models: discoveredIds,
|
|
418
|
+
selectedModel,
|
|
419
|
+
lmStudioEndpoint,
|
|
420
|
+
loadedModels,
|
|
421
|
+
selectedModelPrevious: previousSelectedModel,
|
|
422
|
+
selectionReason,
|
|
423
|
+
contextField,
|
|
424
|
+
}),
|
|
425
|
+
};
|
|
426
|
+
discoveryCache = { configKey: key, result, selectedModel, checkedAt: Date.now() };
|
|
427
|
+
return {
|
|
428
|
+
status: "ready",
|
|
429
|
+
providerId: "local",
|
|
430
|
+
backendKind: "local-openai-compatible",
|
|
431
|
+
message: result.message,
|
|
432
|
+
diagnostics: result.diagnostics,
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
436
|
+
const message = [
|
|
437
|
+
"Local provider unavailable",
|
|
438
|
+
`Could not reach ${config.baseUrl}`,
|
|
439
|
+
"Start LM Studio, load a model, and enable the local server.",
|
|
440
|
+
].join("\n");
|
|
441
|
+
const result = notConfiguredResult(config, message, "unavailable", errorMessage);
|
|
442
|
+
discoveryCache = { configKey: key, result, selectedModel: config.pinnedModel ?? config.defaultModel ?? config.currentModel, checkedAt: Date.now() };
|
|
443
|
+
return { status: "not-configured", providerId: "local", backendKind: "unavailable", message, diagnostics: result.diagnostics };
|
|
444
|
+
} finally {
|
|
445
|
+
clearTimeout(timeout);
|
|
446
|
+
options.signal?.removeEventListener("abort", abort);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function getCachedSelectedModel(config: LocalProviderConfig, routeModel: string): string {
|
|
451
|
+
const cache = discoveryCache?.configKey === localConfigKey(config) ? discoveryCache : null;
|
|
452
|
+
const discoveredIds = cache?.result.models.map((model) => model.modelId) ?? [];
|
|
453
|
+
if (cache?.selectedModel && discoveredIds.includes(cache.selectedModel)) return cache.selectedModel;
|
|
454
|
+
if (config.pinnedModel && discoveredIds.includes(config.pinnedModel)) return config.pinnedModel;
|
|
455
|
+
if (routeModel && discoveredIds.includes(routeModel)) return routeModel;
|
|
456
|
+
return selectFallbackLocalModel(config, discoveredIds) ?? routeModel;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function extractNonStreamingText(body: unknown): string {
|
|
460
|
+
if (typeof body !== "object" || body === null) return "";
|
|
461
|
+
const choices = (body as { choices?: unknown }).choices;
|
|
462
|
+
if (!Array.isArray(choices)) return "";
|
|
463
|
+
return choices
|
|
464
|
+
.map((choice) => {
|
|
465
|
+
if (typeof choice !== "object" || choice === null) return "";
|
|
466
|
+
const message = (choice as { message?: { content?: unknown }; text?: unknown }).message;
|
|
467
|
+
if (typeof message?.content === "string") return message.content;
|
|
468
|
+
const text = (choice as { text?: unknown }).text;
|
|
469
|
+
return typeof text === "string" ? text : "";
|
|
470
|
+
})
|
|
471
|
+
.join("")
|
|
472
|
+
.trim();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseStreamDelta(line: string): string | null {
|
|
476
|
+
const trimmed = line.trim();
|
|
477
|
+
if (!trimmed || !trimmed.startsWith("data:")) return null;
|
|
478
|
+
const data = trimmed.slice("data:".length).trim();
|
|
479
|
+
if (!data || data === "[DONE]") return null;
|
|
480
|
+
try {
|
|
481
|
+
const parsed = JSON.parse(data) as {
|
|
482
|
+
choices?: Array<{ delta?: { content?: string }; text?: string }>;
|
|
483
|
+
};
|
|
484
|
+
return parsed.choices
|
|
485
|
+
?.map((choice) => choice.delta?.content ?? choice.text ?? "")
|
|
486
|
+
.join("") || null;
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function readStreamingResponse(response: Response, handlers: BackendRunHandlers): Promise<string> {
|
|
493
|
+
if (!response.body) return "";
|
|
494
|
+
const reader = response.body.getReader();
|
|
495
|
+
const decoder = new TextDecoder();
|
|
496
|
+
let buffer = "";
|
|
497
|
+
let accumulated = "";
|
|
498
|
+
while (true) {
|
|
499
|
+
const { value, done } = await reader.read();
|
|
500
|
+
if (done) break;
|
|
501
|
+
buffer += decoder.decode(value, { stream: true });
|
|
502
|
+
const lines = buffer.split(/\r?\n/);
|
|
503
|
+
buffer = lines.pop() ?? "";
|
|
504
|
+
for (const line of lines) {
|
|
505
|
+
const delta = parseStreamDelta(line);
|
|
506
|
+
if (delta) {
|
|
507
|
+
accumulated += delta;
|
|
508
|
+
handlers.onAssistantDelta?.(delta);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const tail = parseStreamDelta(buffer);
|
|
513
|
+
if (tail) {
|
|
514
|
+
accumulated += tail;
|
|
515
|
+
handlers.onAssistantDelta?.(tail);
|
|
516
|
+
}
|
|
517
|
+
return accumulated.trim();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function postLocalChatCompletion(options: {
|
|
521
|
+
request: ProviderChatRequest;
|
|
522
|
+
config: LocalProviderConfig;
|
|
523
|
+
stream: boolean;
|
|
524
|
+
fetchImpl: FetchImpl;
|
|
525
|
+
signal?: AbortSignal;
|
|
526
|
+
handlers: BackendRunHandlers;
|
|
527
|
+
capProfile: import("./capabilityProfile.js").ModelCapabilityProfile;
|
|
528
|
+
}): Promise<string> {
|
|
529
|
+
const model = getCachedSelectedModel(options.config, options.request.route.modelId);
|
|
530
|
+
const includeSystemPrompt = options.capProfile.supportsSystemPrompt !== false;
|
|
531
|
+
const messages = [
|
|
532
|
+
...(includeSystemPrompt && options.request.projectInstructions?.content
|
|
533
|
+
? [{ role: "system", content: options.request.projectInstructions.content }]
|
|
534
|
+
: []),
|
|
535
|
+
{ role: "user", content: options.request.prompt },
|
|
536
|
+
];
|
|
537
|
+
const response = await options.fetchImpl(`${options.config.baseUrl}/chat/completions`, {
|
|
538
|
+
method: "POST",
|
|
539
|
+
headers: {
|
|
540
|
+
"Content-Type": "application/json",
|
|
541
|
+
Authorization: `Bearer ${options.config.apiKey}`,
|
|
542
|
+
},
|
|
543
|
+
body: JSON.stringify({
|
|
544
|
+
model,
|
|
545
|
+
messages,
|
|
546
|
+
stream: options.stream,
|
|
547
|
+
}),
|
|
548
|
+
signal: options.signal,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
const body = await response.text();
|
|
553
|
+
const sanitized = sanitizeTerminalOutput(body).slice(0, 500);
|
|
554
|
+
if (isModelNotLoadedError(response.status, sanitized)) {
|
|
555
|
+
discoveryCache = null;
|
|
556
|
+
clearModelCapabilityProfileCache();
|
|
557
|
+
clearModelContextMetadataCache();
|
|
558
|
+
}
|
|
559
|
+
throw new Error(`Local OpenAI-compatible request failed (${response.status}): ${sanitized}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (options.stream) {
|
|
563
|
+
return readStreamingResponse(response, options.handlers);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const parsed = JSON.parse(await response.text()) as unknown;
|
|
567
|
+
return extractNonStreamingText(parsed);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isModelNotLoadedError(status: number, body: string): boolean {
|
|
571
|
+
return status === 404 || /model.+not.+loaded|not.+loaded.+model|load a model|no model is loaded/i.test(body);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export async function runLocalOpenAiCompatible(
|
|
575
|
+
request: ProviderChatRequest,
|
|
576
|
+
handlers: BackendRunHandlers,
|
|
577
|
+
options: { fetchImpl?: FetchImpl; signal?: AbortSignal } = {},
|
|
578
|
+
): Promise<string> {
|
|
579
|
+
const config = resolveLocalProviderConfig(request.localConfig ?? configuredOverride);
|
|
580
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
581
|
+
|
|
582
|
+
const resolvedModel = getCachedSelectedModel(config, request.route.modelId);
|
|
583
|
+
const rawMeta = discoveryCache?.configKey === localConfigKey(config)
|
|
584
|
+
? discoveryCache.result.models.find((m) => m.modelId === resolvedModel)?.raw
|
|
585
|
+
: undefined;
|
|
586
|
+
const capProfile = resolveModelCapabilityProfileCached({
|
|
587
|
+
providerId: "local",
|
|
588
|
+
modelId: resolvedModel,
|
|
589
|
+
providerConfig: request.localConfig ?? configuredOverride,
|
|
590
|
+
rawMetadata: rawMeta,
|
|
591
|
+
});
|
|
592
|
+
const streamingSupported = capProfile.supportsStreaming !== false;
|
|
593
|
+
|
|
594
|
+
if (streamingSupported) {
|
|
595
|
+
try {
|
|
596
|
+
const streamed = await postLocalChatCompletion({
|
|
597
|
+
request,
|
|
598
|
+
config,
|
|
599
|
+
stream: true,
|
|
600
|
+
fetchImpl,
|
|
601
|
+
signal: options.signal,
|
|
602
|
+
handlers,
|
|
603
|
+
capProfile,
|
|
604
|
+
});
|
|
605
|
+
if (streamed) return streamed;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (options.signal?.aborted) throw error;
|
|
608
|
+
handlers.onProgress?.({
|
|
609
|
+
id: "local-stream-fallback",
|
|
610
|
+
source: "stderr",
|
|
611
|
+
text: "Local streaming request failed; retrying without streaming.",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const text = await postLocalChatCompletion({
|
|
617
|
+
request,
|
|
618
|
+
config,
|
|
619
|
+
stream: false,
|
|
620
|
+
fetchImpl,
|
|
621
|
+
signal: options.signal,
|
|
622
|
+
handlers,
|
|
623
|
+
capProfile,
|
|
624
|
+
});
|
|
625
|
+
if (!text) {
|
|
626
|
+
throw new Error("Local OpenAI-compatible API returned no assistant text.");
|
|
627
|
+
}
|
|
628
|
+
handlers.onAssistantDelta?.(text);
|
|
629
|
+
return text;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function runLocalDiagnostics(options: {
|
|
633
|
+
localConfig?: ProviderWorkspaceOverride | null;
|
|
634
|
+
fetchImpl?: FetchImpl;
|
|
635
|
+
} = {}): Promise<string> {
|
|
636
|
+
const validation = await checkLocalProvider({
|
|
637
|
+
override: options.localConfig ?? configuredOverride,
|
|
638
|
+
fetchImpl: options.fetchImpl,
|
|
639
|
+
});
|
|
640
|
+
const diagnostics = validation.diagnostics ?? {};
|
|
641
|
+
const models = String(diagnostics.discoveredModels ?? "").trim() || "none";
|
|
642
|
+
const selectedModelId = String(diagnostics.selectedModel ?? "");
|
|
643
|
+
const previousModelId = typeof diagnostics.previousModel === "string" ? diagnostics.previousModel : "";
|
|
644
|
+
const lmStudioEndpoint = String(diagnostics.lmStudioModelsEndpoint ?? "");
|
|
645
|
+
const modelRaw = discoveryCache?.result.models.find((m) => m.modelId === selectedModelId)?.raw;
|
|
646
|
+
const lmLines: (string | null)[] = [];
|
|
647
|
+
const loadedModels = discoveryCache?.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: discoveryCache?.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: "Routes through a local OpenAI-compatible server such as LM Studio.",
|
|
718
|
+
routeSetupMessage: LOCAL_ROUTE_SETUP_MESSAGE,
|
|
719
|
+
launchAvailable: false,
|
|
720
|
+
isRouteConfigured: () => discoverLocalModels().status === "ready",
|
|
721
|
+
validateRoute: async ({ localConfig }) => checkLocalProvider({ override: localConfig ?? configuredOverride }),
|
|
722
|
+
discoverModels: discoverLocalModels,
|
|
723
|
+
refreshModels: async ({ localConfig }) => {
|
|
724
|
+
const validation = await checkLocalProvider({ override: localConfig ?? configuredOverride });
|
|
725
|
+
return {
|
|
726
|
+
status: validation.status,
|
|
727
|
+
providerId: "local",
|
|
728
|
+
backendKind: validation.backendKind,
|
|
729
|
+
models: validation.status === "ready" ? discoverLocalModels(localConfig).models : [],
|
|
730
|
+
message: validation.message,
|
|
731
|
+
diagnostics: validation.diagnostics,
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
run: (request, handlers) => {
|
|
735
|
+
const controller = new AbortController();
|
|
736
|
+
handlers.onProgress?.({
|
|
737
|
+
id: "local-route",
|
|
738
|
+
source: "stdout",
|
|
739
|
+
text: "Starting Local OpenAI-compatible provider",
|
|
740
|
+
});
|
|
741
|
+
runLocalOpenAiCompatible(request, handlers, { signal: controller.signal })
|
|
742
|
+
.then((text) => {
|
|
743
|
+
if (controller.signal.aborted) return;
|
|
744
|
+
handlers.onFinalAnswerObserved?.(text);
|
|
745
|
+
handlers.onResponse(text);
|
|
746
|
+
})
|
|
747
|
+
.catch((error) => {
|
|
748
|
+
if (controller.signal.aborted) return;
|
|
749
|
+
const message = error instanceof Error ? error.message : "Local OpenAI-compatible provider failed.";
|
|
750
|
+
handlers.onError(message);
|
|
751
|
+
});
|
|
752
|
+
return () => controller.abort();
|
|
753
|
+
},
|
|
754
|
+
};
|