@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Ollama utilities for Pi Coding Agent extensions.
|
|
3
|
+
* Eliminates getOllamaBaseUrl() duplication across simplebench, ollama-sync, status.
|
|
4
|
+
*
|
|
5
|
+
* @module shared/ollama
|
|
6
|
+
* @writtenby VTSTech — https://www.vts-tech.org
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { debugLog } from "./debug";
|
|
12
|
+
import type { PiExtensionContext } from "../../../shared/types";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Constants
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extension package version. Derived from the VERSION file in the repo root.
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT: Do NOT update this constant manually.
|
|
22
|
+
* Use ./scripts/bump-version.sh <new-version> to update ALL locations:
|
|
23
|
+
* - VERSION file (source of truth)
|
|
24
|
+
* - shared/ollama.ts (EXTENSION_VERSION)
|
|
25
|
+
* - scripts/build-packages.sh (derived from VERSION at runtime)
|
|
26
|
+
* - scripts/publish-packages.sh (derived from VERSION at runtime)
|
|
27
|
+
* - package.json (version field)
|
|
28
|
+
*/
|
|
29
|
+
export const EXTENSION_VERSION = "1.3.4";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Path to Pi's models.json configuration file.
|
|
33
|
+
*
|
|
34
|
+
* This file contains the provider configurations and model definitions
|
|
35
|
+
* used by Pi to connect to various LLM backends (Ollama, OpenAI, etc.).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* console.log(MODELS_JSON_PATH);
|
|
40
|
+
* // Output: "/home/user/.pi/agent/models.json"
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const MODELS_JSON_PATH: string = path.join(os.homedir(), ".pi", "agent", "models.json");
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Types
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Model information returned by Ollama's /api/tags endpoint.
|
|
51
|
+
*
|
|
52
|
+
* Contains metadata about a pulled model including its name, size,
|
|
53
|
+
* quantization level, and model family.
|
|
54
|
+
*
|
|
55
|
+
* @property name - The model's tag name (e.g., "qwen3:0.6b", "llama3.2:1b")
|
|
56
|
+
* @property model - Alternative model identifier (often same as name)
|
|
57
|
+
* @property modified_at - ISO 8601 timestamp of when the model was last modified
|
|
58
|
+
* @property size - Model file size in bytes
|
|
59
|
+
* @property digest - SHA256 digest of the model
|
|
60
|
+
* @property details - Additional model metadata
|
|
61
|
+
*/
|
|
62
|
+
export interface OllamaModel {
|
|
63
|
+
name: string;
|
|
64
|
+
model: string;
|
|
65
|
+
modified_at: string;
|
|
66
|
+
size: number;
|
|
67
|
+
digest: string;
|
|
68
|
+
details: {
|
|
69
|
+
parent_model: string;
|
|
70
|
+
format: string;
|
|
71
|
+
family: string;
|
|
72
|
+
families: string[];
|
|
73
|
+
parameter_size: string;
|
|
74
|
+
quantization_level: string;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pi's models.json configuration structure.
|
|
80
|
+
*
|
|
81
|
+
* Contains provider configurations keyed by provider name (e.g., "ollama", "openai").
|
|
82
|
+
* Each provider has a base URL, API mode, and list of available models.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```json
|
|
86
|
+
* {
|
|
87
|
+
* "providers": {
|
|
88
|
+
* "ollama": {
|
|
89
|
+
* "baseUrl": "http://localhost:11434/v1",
|
|
90
|
+
* "api": "openai-completions",
|
|
91
|
+
* "models": [{ "id": "qwen3:0.6b" }]
|
|
92
|
+
* }
|
|
93
|
+
* }
|
|
94
|
+
* }
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export interface PiModelsJson {
|
|
98
|
+
providers: Record<string, PiProviderConfig>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Configuration for a single provider in models.json.
|
|
103
|
+
*
|
|
104
|
+
* @property baseUrl - The base URL for the provider's API (e.g., "http://localhost:11434/v1")
|
|
105
|
+
* @property api - The API mode to use (e.g., "openai-completions", "anthropic-messages")
|
|
106
|
+
* @property apiKey - Optional API key for authentication
|
|
107
|
+
* @property compat - Compatibility settings for non-standard APIs
|
|
108
|
+
* @property models - List of models available from this provider
|
|
109
|
+
*/
|
|
110
|
+
export interface PiProviderConfig {
|
|
111
|
+
baseUrl?: string;
|
|
112
|
+
api?: string;
|
|
113
|
+
apiKey?: string;
|
|
114
|
+
compat?: Record<string, unknown>;
|
|
115
|
+
models: PiModelEntry[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Entry for a single model in Pi's configuration.
|
|
120
|
+
*
|
|
121
|
+
* @property id - The model identifier (e.g., "qwen3:0.6b")
|
|
122
|
+
* @property reasoning - Whether the model supports extended thinking/reasoning
|
|
123
|
+
* @property toolSupport - Level of tool support ("native", "react", "none")
|
|
124
|
+
* @property modelFamily - The model family (e.g., "qwen3", "llama", "granite")
|
|
125
|
+
* @property parameterSize - Human-readable parameter count (e.g., "352M", "1.5B")
|
|
126
|
+
* @property quantizationLevel - Quantization type (e.g., "Q4_K_M", "BF16")
|
|
127
|
+
*/
|
|
128
|
+
export interface PiModelEntry {
|
|
129
|
+
id: string;
|
|
130
|
+
reasoning?: boolean;
|
|
131
|
+
toolSupport?: string;
|
|
132
|
+
modelFamily?: string;
|
|
133
|
+
parameterSize?: string;
|
|
134
|
+
quantizationLevel?: string;
|
|
135
|
+
contextLength?: number;
|
|
136
|
+
estimatedSize?: { gpu: number; cpu: number };
|
|
137
|
+
[key: string]: unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// In-Memory Cache (TTL-based)
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
let _modelsJsonCache: { data: PiModelsJson; ts: number } | null = null;
|
|
145
|
+
let _ollamaBaseUrlCache: { data: string; ts: number } | null = null;
|
|
146
|
+
const CACHE_TTL_MS = 2000; // 2-second TTL
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Ollama Base URL Resolution
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the Ollama base URL using the three-tier priority chain.
|
|
154
|
+
*
|
|
155
|
+
* This function determines the correct Ollama API endpoint by checking
|
|
156
|
+
* multiple sources in order of precedence:
|
|
157
|
+
*
|
|
158
|
+
* 1. **models.json** - Checks `providers.ollama.baseUrl` in Pi's config
|
|
159
|
+
* 2. **OLLAMA_HOST** - Falls back to the environment variable
|
|
160
|
+
* 3. **localhost** - Uses the default `http://localhost:11434`
|
|
161
|
+
*
|
|
162
|
+
* The function automatically strips the `/v1` suffix from URLs since
|
|
163
|
+
* different endpoints need different path handling.
|
|
164
|
+
*
|
|
165
|
+
* @returns The resolved Ollama base URL without trailing slash
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```typescript
|
|
169
|
+
* // With models.json configured
|
|
170
|
+
* getOllamaBaseUrl();
|
|
171
|
+
* // Returns: "https://abc123.trycloudflare.com"
|
|
172
|
+
*
|
|
173
|
+
* // With OLLAMA_HOST env var
|
|
174
|
+
* process.env.OLLAMA_HOST = "192.168.1.100:11434";
|
|
175
|
+
* getOllamaBaseUrl();
|
|
176
|
+
* // Returns: "http://192.168.1.100:11434"
|
|
177
|
+
*
|
|
178
|
+
* // Default fallback
|
|
179
|
+
* getOllamaBaseUrl();
|
|
180
|
+
* // Returns: "http://localhost:11434"
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export function getOllamaBaseUrl(): string {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
if (_ollamaBaseUrlCache && now - _ollamaBaseUrlCache.ts < CACHE_TTL_MS) return _ollamaBaseUrlCache.data;
|
|
186
|
+
try {
|
|
187
|
+
if (fs.existsSync(MODELS_JSON_PATH)) {
|
|
188
|
+
const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
|
|
189
|
+
const config = JSON.parse(raw) as PiModelsJson;
|
|
190
|
+
const baseUrl = config?.providers?.["ollama"]?.baseUrl;
|
|
191
|
+
if (baseUrl) {
|
|
192
|
+
// baseUrl is like "https://host/v1" or "http://localhost:11434/v1" — strip /v1
|
|
193
|
+
const result = baseUrl.replace(/\/v1\/?$/, "");
|
|
194
|
+
_ollamaBaseUrlCache = { data: result, ts: now };
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (err) { debugLog("ollama", "failed to parse models.json for base URL", err); }
|
|
199
|
+
if (process.env.OLLAMA_HOST) {
|
|
200
|
+
const result = `http://${process.env.OLLAMA_HOST.replace(/^https?:\/\//, "")}`;
|
|
201
|
+
_ollamaBaseUrlCache = { data: result, ts: now };
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
const fallback = "http://localhost:11434";
|
|
205
|
+
_ollamaBaseUrlCache = { data: fallback, ts: now };
|
|
206
|
+
return fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// Models.json I/O
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read and parse Pi's models.json configuration file.
|
|
215
|
+
*
|
|
216
|
+
* Safely reads the configuration file, returning an empty structure
|
|
217
|
+
* if the file doesn't exist or contains invalid JSON.
|
|
218
|
+
*
|
|
219
|
+
* @returns The parsed models.json configuration, or an empty structure
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* const config = readModelsJson();
|
|
224
|
+
* const ollamaModels = config.providers["ollama"]?.models || [];
|
|
225
|
+
* console.log(`Found ${ollamaModels.length} Ollama models`);
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function readModelsJson(): PiModelsJson {
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (_modelsJsonCache && now - _modelsJsonCache.ts < CACHE_TTL_MS) return _modelsJsonCache.data;
|
|
231
|
+
try {
|
|
232
|
+
if (fs.existsSync(MODELS_JSON_PATH)) {
|
|
233
|
+
const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
|
|
234
|
+
const data = JSON.parse(raw) as PiModelsJson;
|
|
235
|
+
_modelsJsonCache = { data, ts: now };
|
|
236
|
+
return data;
|
|
237
|
+
}
|
|
238
|
+
} catch (err) { debugLog("ollama", "failed to read/parse models.json", err); }
|
|
239
|
+
const empty = { providers: {} };
|
|
240
|
+
_modelsJsonCache = { data: empty, ts: now };
|
|
241
|
+
return empty;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Write Pi's models.json configuration back to disk.
|
|
246
|
+
*
|
|
247
|
+
* Uses an atomic write-then-rename pattern: the new content is first written
|
|
248
|
+
* to a `.tmp` sibling file, then renamed over the target. This ensures
|
|
249
|
+
* concurrent readers never see a partially-written file.
|
|
250
|
+
*
|
|
251
|
+
* NOTE: The write-then-rename pattern provides filesystem-level atomicity
|
|
252
|
+
* but does NOT protect against concurrent read-modify-write cycles (e.g.
|
|
253
|
+
* two processes both read the file, mutate in memory, then write back).
|
|
254
|
+
* If that becomes a concern in the future, file-based locking (e.g. `proper-lockfile`)
|
|
255
|
+
* should be added around the read-modify-write sequence.
|
|
256
|
+
*
|
|
257
|
+
* @param data - The configuration object to write
|
|
258
|
+
*
|
|
259
|
+
* @example
|
|
260
|
+
* ```typescript
|
|
261
|
+
* const config = readModelsJson();
|
|
262
|
+
* config.providers["ollama"] = {
|
|
263
|
+
* baseUrl: "http://localhost:11434/v1",
|
|
264
|
+
* models: [{ id: "qwen3:0.6b" }]
|
|
265
|
+
* };
|
|
266
|
+
* writeModelsJson(config);
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
export function writeModelsJson(data: PiModelsJson): void {
|
|
270
|
+
const dir = path.dirname(MODELS_JSON_PATH);
|
|
271
|
+
if (!fs.existsSync(dir)) {
|
|
272
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
273
|
+
}
|
|
274
|
+
const tmpPath = MODELS_JSON_PATH + ".tmp";
|
|
275
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
276
|
+
fs.renameSync(tmpPath, MODELS_JSON_PATH);
|
|
277
|
+
// Invalidate cache so next read picks up the written data
|
|
278
|
+
_modelsJsonCache = null;
|
|
279
|
+
_ollamaBaseUrlCache = null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// In-memory mutex for models.json write operations
|
|
283
|
+
let _modelsJsonLock: Promise<void> | null = null;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Acquire the models.json write lock.
|
|
287
|
+
* Returns a release function that MUST be called when done.
|
|
288
|
+
* Prevents concurrent read-modify-write cycles from different extensions.
|
|
289
|
+
*/
|
|
290
|
+
export async function acquireModelsJsonLock(): Promise<{ release: () => void }> {
|
|
291
|
+
// Wait for any existing lock to be released
|
|
292
|
+
while (_modelsJsonLock) {
|
|
293
|
+
await _modelsJsonLock;
|
|
294
|
+
}
|
|
295
|
+
// Create new lock
|
|
296
|
+
let releaseLock: () => void;
|
|
297
|
+
_modelsJsonLock = new Promise<void>((resolve) => {
|
|
298
|
+
releaseLock = resolve;
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
release: () => {
|
|
302
|
+
releaseLock!();
|
|
303
|
+
_modelsJsonLock = null;
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Safely read, modify, and write models.json under a lock.
|
|
310
|
+
* Prevents concurrent read-modify-write cycles between extensions.
|
|
311
|
+
*
|
|
312
|
+
* @param modifier - Function that receives the current data and returns modified data (or null to abort)
|
|
313
|
+
* @returns true if the write succeeded, false if aborted
|
|
314
|
+
*/
|
|
315
|
+
export async function readModifyWriteModelsJson(
|
|
316
|
+
modifier: (data: PiModelsJson) => PiModelsJson | null
|
|
317
|
+
): Promise<boolean> {
|
|
318
|
+
const { release } = await acquireModelsJsonLock();
|
|
319
|
+
try {
|
|
320
|
+
const data = readModelsJson();
|
|
321
|
+
const modified = modifier(data);
|
|
322
|
+
if (modified === null) return false;
|
|
323
|
+
writeModelsJson(modified);
|
|
324
|
+
return true;
|
|
325
|
+
} finally {
|
|
326
|
+
release();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ============================================================================
|
|
331
|
+
// Ollama API Helpers
|
|
332
|
+
// ============================================================================
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Retry configuration for Ollama API calls.
|
|
336
|
+
*/
|
|
337
|
+
export interface RetryOptions {
|
|
338
|
+
/** Maximum number of retry attempts (default: 2) */
|
|
339
|
+
maxRetries?: number;
|
|
340
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
341
|
+
baseDelayMs?: number;
|
|
342
|
+
/** Maximum delay in ms (default: 10000) */
|
|
343
|
+
maxDelayMs?: number;
|
|
344
|
+
/** Whether to retry on timeout (default: true) */
|
|
345
|
+
retryOnTimeout?: boolean;
|
|
346
|
+
/** Whether to retry on connection errors (default: true) */
|
|
347
|
+
retryOnConnectionError?: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
|
|
351
|
+
maxRetries: 2,
|
|
352
|
+
baseDelayMs: 1000,
|
|
353
|
+
maxDelayMs: 10000,
|
|
354
|
+
retryOnTimeout: true,
|
|
355
|
+
retryOnConnectionError: true,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Calculate exponential backoff delay with jitter.
|
|
360
|
+
*/
|
|
361
|
+
function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
|
362
|
+
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
363
|
+
// Add jitter (±25%) to avoid thundering herd
|
|
364
|
+
const jitter = delay * 0.25 * (Math.random() * 2 - 1);
|
|
365
|
+
return Math.max(0, Math.round(delay + jitter));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Error message patterns that indicate transient (retryable) failures.
|
|
370
|
+
*/
|
|
371
|
+
const RETRYABLE_ERROR_PATTERNS = [
|
|
372
|
+
"ECONNREFUSED",
|
|
373
|
+
"ECONNRESET",
|
|
374
|
+
"ENOTFOUND",
|
|
375
|
+
"ETIMEDOUT",
|
|
376
|
+
"fetch failed",
|
|
377
|
+
"network error",
|
|
378
|
+
"socket hang up",
|
|
379
|
+
"Empty response",
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Determine if an error is retryable based on its message.
|
|
384
|
+
*/
|
|
385
|
+
function isRetryableError(error: unknown, opts: Required<RetryOptions>): boolean {
|
|
386
|
+
if (error instanceof Error) {
|
|
387
|
+
if (error.name === "AbortError" && opts.retryOnTimeout) return true;
|
|
388
|
+
const msg = error.message;
|
|
389
|
+
if (opts.retryOnConnectionError && RETRYABLE_ERROR_PATTERNS.some(p => msg.includes(p))) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Execute a fetch request with exponential backoff retry logic.
|
|
398
|
+
*
|
|
399
|
+
* Wraps any async function with automatic retry on transient failures.
|
|
400
|
+
* Uses exponential backoff with jitter to avoid thundering herd.
|
|
401
|
+
*
|
|
402
|
+
* @param fn - The async function to execute (typically a fetch call)
|
|
403
|
+
* @param options - Retry configuration
|
|
404
|
+
* @returns The result of the function
|
|
405
|
+
* @throws The last error if all retries are exhausted
|
|
406
|
+
*
|
|
407
|
+
* @example
|
|
408
|
+
* ```typescript
|
|
409
|
+
* const models = await withRetry(
|
|
410
|
+
* () => fetchOllamaModels(baseUrl),
|
|
411
|
+
* { maxRetries: 2, baseDelayMs: 1000 }
|
|
412
|
+
* );
|
|
413
|
+
* ```
|
|
414
|
+
*/
|
|
415
|
+
export async function withRetry<T>(
|
|
416
|
+
fn: () => Promise<T>,
|
|
417
|
+
options?: RetryOptions,
|
|
418
|
+
): Promise<T> {
|
|
419
|
+
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
420
|
+
let lastError: unknown;
|
|
421
|
+
|
|
422
|
+
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
|
|
423
|
+
try {
|
|
424
|
+
return await fn();
|
|
425
|
+
} catch (error) {
|
|
426
|
+
lastError = error;
|
|
427
|
+
if (attempt < opts.maxRetries && isRetryableError(error, opts)) {
|
|
428
|
+
const delay = backoffDelay(attempt, opts.baseDelayMs, opts.maxDelayMs);
|
|
429
|
+
debugLog("ollama", `Retry ${attempt + 1}/${opts.maxRetries} after ${delay}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
430
|
+
await new Promise(r => setTimeout(r, delay));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
throw lastError;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Fetch the list of available models from an Ollama instance.
|
|
441
|
+
*
|
|
442
|
+
* Queries the `/api/tags` endpoint to get all pulled models with their
|
|
443
|
+
* metadata (size, quantization, family, etc.).
|
|
444
|
+
*
|
|
445
|
+
* @param baseUrl - The Ollama base URL (without /v1 suffix)
|
|
446
|
+
* @returns Array of model objects from Ollama
|
|
447
|
+
* @throws Error if the request fails or Ollama returns an error status
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```typescript
|
|
451
|
+
* const models = await fetchOllamaModels("http://localhost:11434");
|
|
452
|
+
* for (const model of models) {
|
|
453
|
+
* console.log(`${model.name}: ${model.details.parameter_size}`);
|
|
454
|
+
* }
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
export async function fetchOllamaModels(baseUrl: string): Promise<OllamaModel[]> {
|
|
458
|
+
return withRetry(async () => {
|
|
459
|
+
const res = await fetch(`${baseUrl}/api/tags`, {
|
|
460
|
+
signal: AbortSignal.timeout(5000),
|
|
461
|
+
});
|
|
462
|
+
if (!res.ok) throw new Error(`Ollama returned ${res.status}`);
|
|
463
|
+
const data = (await res.json()) as { models?: OllamaModel[] };
|
|
464
|
+
return data.models ?? [];
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Fetch detailed model info from Ollama's /api/show endpoint.
|
|
470
|
+
*
|
|
471
|
+
* Returns the model's context window size (`num_ctx`), along with
|
|
472
|
+
* other details like the template and system prompt. Used by
|
|
473
|
+
* ollama-sync to enrich model entries with context length metadata.
|
|
474
|
+
*
|
|
475
|
+
* @param baseUrl - The Ollama base URL (without /v1 suffix)
|
|
476
|
+
* @param modelName - The model tag name (e.g., "qwen3:0.6b")
|
|
477
|
+
* @returns Context length in tokens, or undefined if unavailable
|
|
478
|
+
*
|
|
479
|
+
* @example
|
|
480
|
+
* ```typescript
|
|
481
|
+
* const ctx = await fetchModelContextLength("http://localhost:11434", "qwen3:0.6b");
|
|
482
|
+
* console.log(ctx); // 8192
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
export async function fetchModelContextLength(
|
|
486
|
+
baseUrl: string,
|
|
487
|
+
modelName: string
|
|
488
|
+
): Promise<number | undefined> {
|
|
489
|
+
return withRetry(async () => {
|
|
490
|
+
try {
|
|
491
|
+
const res = await fetch(`${baseUrl}/api/show`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: { "Content-Type": "application/json" },
|
|
494
|
+
body: JSON.stringify({ name: modelName }),
|
|
495
|
+
signal: AbortSignal.timeout(30000),
|
|
496
|
+
});
|
|
497
|
+
if (!res.ok) return undefined;
|
|
498
|
+
const data = (await res.json()) as {
|
|
499
|
+
model_info?: Record<string, unknown>;
|
|
500
|
+
template?: string;
|
|
501
|
+
};
|
|
502
|
+
const modelInfo = data.model_info ?? {};
|
|
503
|
+
// Ollama uses architecture-specific keys like "qwen3.context_length"
|
|
504
|
+
for (const key of Object.keys(modelInfo)) {
|
|
505
|
+
if (key.endsWith(".context_length")) {
|
|
506
|
+
const val = modelInfo[key];
|
|
507
|
+
if (typeof val === "number") return val;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Fallback: generic "num_ctx" key
|
|
511
|
+
const numCtx = data?.model_info?.["num_ctx"];
|
|
512
|
+
if (typeof numCtx === "number") return numCtx;
|
|
513
|
+
} catch (err) {
|
|
514
|
+
debugLog("ollama", `failed to fetch context length for ${modelName}`, err);
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Fetch context lengths for multiple models, processing them in small batches
|
|
523
|
+
* to avoid overwhelming the connection (especially over tunnels).
|
|
524
|
+
*
|
|
525
|
+
* @param baseUrl - The Ollama base URL (without /v1 suffix)
|
|
526
|
+
* @param modelNames - Array of model tag names
|
|
527
|
+
* @param batchSize - Number of concurrent requests (default: 3)
|
|
528
|
+
* @returns Map of model name to context length (undefined if unavailable)
|
|
529
|
+
*/
|
|
530
|
+
export async function fetchContextLengthsBatched(
|
|
531
|
+
baseUrl: string,
|
|
532
|
+
modelNames: string[],
|
|
533
|
+
batchSize = 3
|
|
534
|
+
): Promise<Map<string, number | undefined>> {
|
|
535
|
+
const result = new Map<string, number | undefined>();
|
|
536
|
+
for (let i = 0; i < modelNames.length; i += batchSize) {
|
|
537
|
+
const batch = modelNames.slice(i, i + batchSize);
|
|
538
|
+
const results = await Promise.allSettled(
|
|
539
|
+
batch.map((name) => fetchModelContextLength(baseUrl, name))
|
|
540
|
+
);
|
|
541
|
+
results.forEach((r, idx) => {
|
|
542
|
+
result.set(batch[idx], r.status === "fulfilled" ? r.value : undefined);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Check if an Ollama model name suggests reasoning capability.
|
|
550
|
+
*
|
|
551
|
+
* Examines the model name for patterns that indicate support for
|
|
552
|
+
* extended thinking/reasoning tokens (e.g., deepseek-r1, qwq, o1, o3).
|
|
553
|
+
*
|
|
554
|
+
* @param name - The model name to check
|
|
555
|
+
* @returns `true` if the model name suggests reasoning capability
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* ```typescript
|
|
559
|
+
* isReasoningModel("deepseek-r1:1.5b"); // true
|
|
560
|
+
* isReasoningModel("qwq:32b"); // true
|
|
561
|
+
* isReasoningModel("qwen3:0.6b"); // false (qwen3 has thinking but not in name)
|
|
562
|
+
* isReasoningModel("llama3.2:1b"); // false
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
export function isReasoningModel(name: string): boolean {
|
|
566
|
+
const lower = name.toLowerCase();
|
|
567
|
+
return (
|
|
568
|
+
lower.includes("deepseek-r1") ||
|
|
569
|
+
lower.includes("qwq") ||
|
|
570
|
+
/\bo1\b/.test(lower) ||
|
|
571
|
+
/\bo3\b/.test(lower) ||
|
|
572
|
+
lower.includes("qwen3") ||
|
|
573
|
+
lower.includes("reasoning") ||
|
|
574
|
+
lower.includes("thinker") ||
|
|
575
|
+
lower.includes("thinking")
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ============================================================================
|
|
580
|
+
// Built-in Provider Registry
|
|
581
|
+
// ============================================================================
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Known built-in providers that Pi supports out of the box.
|
|
585
|
+
*
|
|
586
|
+
* Pi has two provider layers:
|
|
587
|
+
* 1. Built-in providers (openrouter, anthropic, google, etc.) configured by Pi internally
|
|
588
|
+
* 2. User-defined providers in models.json (e.g. ollama)
|
|
589
|
+
*
|
|
590
|
+
* The ExtensionAPI does not expose a getProviderConfig() method, so we maintain a
|
|
591
|
+
* lookup table for known built-in providers. Used by diag.ts, simplebench.ts, and
|
|
592
|
+
* api.ts for provider detection and display.
|
|
593
|
+
*
|
|
594
|
+
* Each entry maps provider name to its API mode, base URL, and the env var
|
|
595
|
+
* that Pi reads for the API key.
|
|
596
|
+
*/
|
|
597
|
+
export const BUILTIN_PROVIDERS: Record<string, { api: string; baseUrl: string; envKey: string }> = {
|
|
598
|
+
openrouter: { api: "openai-completions", baseUrl: "https://openrouter.ai/api/v1", envKey: "OPENROUTER_API_KEY" },
|
|
599
|
+
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com/v1", envKey: "ANTHROPIC_API_KEY" },
|
|
600
|
+
google: { api: "gemini", baseUrl: "https://generativelanguage.googleapis.com", envKey: "GOOGLE_API_KEY" },
|
|
601
|
+
openai: { api: "openai-completions", baseUrl: "https://api.openai.com/v1", envKey: "OPENAI_API_KEY" },
|
|
602
|
+
groq: { api: "openai-completions", baseUrl: "https://api.groq.com/v1", envKey: "GROQ_API_KEY" },
|
|
603
|
+
deepseek: { api: "openai-completions", baseUrl: "https://api.deepseek.com/v1", envKey: "DEEPSEEK_API_KEY" },
|
|
604
|
+
mistral: { api: "openai-completions", baseUrl: "https://api.mistral.ai/v1", envKey: "MISTRAL_API_KEY" },
|
|
605
|
+
xai: { api: "openai-completions", baseUrl: "https://api.x.ai/v1", envKey: "XAI_API_KEY" },
|
|
606
|
+
together: { api: "openai-completions", baseUrl: "https://api.together.xyz/v1", envKey: "TOGETHER_API_KEY" },
|
|
607
|
+
fireworks: { api: "openai-completions", baseUrl: "https://api.fireworks.ai/inference/v1", envKey: "FIREWORKS_API_KEY" },
|
|
608
|
+
cohere: { api: "cohere-chat", baseUrl: "https://api.cohere.com/v1", envKey: "COHERE_API_KEY" },
|
|
609
|
+
zai: { api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4", envKey: "ZAI_API_KEY" },
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// Model Family Detection
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Detect the model family from a model name.
|
|
618
|
+
*
|
|
619
|
+
* Ported from AgentNova's `core/model_family_config.py` `detect_family()`.
|
|
620
|
+
* Uses substring matching against known model name patterns to determine
|
|
621
|
+
* the family for template/prompt selection purposes.
|
|
622
|
+
*
|
|
623
|
+
* The order of checks matters: more specific patterns (e.g., "qwen3.5")
|
|
624
|
+
* are checked before general ones (e.g., "qwen").
|
|
625
|
+
*
|
|
626
|
+
* @param modelName - The model name to analyze
|
|
627
|
+
* @returns The detected family identifier, or "unknown" if no match
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```typescript
|
|
631
|
+
* detectModelFamily("qwen3.5:0.8b"); // "qwen35"
|
|
632
|
+
* detectModelFamily("qwen3:0.6b"); // "qwen3"
|
|
633
|
+
* detectModelFamily("llama3.2:1b"); // "llama"
|
|
634
|
+
* detectModelFamily("granite4:350m"); // "granite"
|
|
635
|
+
* detectModelFamily("glm-4:flash"); // "glm"
|
|
636
|
+
* detectModelFamily("unknown-model"); // "unknown"
|
|
637
|
+
* ```
|
|
638
|
+
*/
|
|
639
|
+
export function detectModelFamily(modelName: string): string {
|
|
640
|
+
const name = modelName.toLowerCase();
|
|
641
|
+
|
|
642
|
+
// Order matters: longer/more-specific prefixes first
|
|
643
|
+
const families: [string, string][] = [
|
|
644
|
+
["qwen3.5", "qwen35"],
|
|
645
|
+
["qwen3", "qwen3"],
|
|
646
|
+
["qwen2.5", "qwen2"],
|
|
647
|
+
["qwen2", "qwen2"],
|
|
648
|
+
["qwen", "qwen2"],
|
|
649
|
+
["llama3.3", "llama"],
|
|
650
|
+
["llama3.2", "llama"],
|
|
651
|
+
["llama3.1", "llama"],
|
|
652
|
+
["llama3", "llama"],
|
|
653
|
+
["llama", "llama"],
|
|
654
|
+
["gemma3", "gemma3"],
|
|
655
|
+
["gemma2", "gemma2"],
|
|
656
|
+
["gemma", "gemma2"],
|
|
657
|
+
["granite", "granite"],
|
|
658
|
+
["dolphin", "dolphin"],
|
|
659
|
+
["glm-4", "glm"],
|
|
660
|
+
["glm", "glm"],
|
|
661
|
+
["deepseek-r1", "deepseek-r1"],
|
|
662
|
+
["deepseek", "deepseek"],
|
|
663
|
+
["mistral", "qwen2"],
|
|
664
|
+
["phi", "llama"],
|
|
665
|
+
["tinyllama", "llama"],
|
|
666
|
+
["codestral", "qwen2"],
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
for (const [prefix, family] of families) {
|
|
670
|
+
if (name.includes(prefix)) return family;
|
|
671
|
+
}
|
|
672
|
+
return "unknown";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ============================================================================
|
|
676
|
+
// Provider Detection
|
|
677
|
+
// ============================================================================
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Kind of provider: local Ollama, built-in cloud, or unknown.
|
|
681
|
+
*/
|
|
682
|
+
export type ProviderKind = "ollama" | "builtin" | "bedrock" | "unknown";
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Information about the active provider, detected from Pi's context and models.json.
|
|
686
|
+
*
|
|
687
|
+
* @property kind - The provider kind (ollama, builtin, or unknown)
|
|
688
|
+
* @property name - Provider name (e.g. "ollama", "openrouter", "anthropic")
|
|
689
|
+
* @property apiMode - API mode (e.g. "openai-completions", "ollama")
|
|
690
|
+
* @property baseUrl - API base URL
|
|
691
|
+
* @property envKey - Environment variable for API key
|
|
692
|
+
* @property apiKey - Actual API key value (if available)
|
|
693
|
+
*/
|
|
694
|
+
export interface ProviderInfo {
|
|
695
|
+
kind: ProviderKind;
|
|
696
|
+
name: string;
|
|
697
|
+
apiMode?: string;
|
|
698
|
+
baseUrl?: string;
|
|
699
|
+
region?: string;
|
|
700
|
+
envKey?: string;
|
|
701
|
+
apiKey?: string;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Expand environment variable references in a string.
|
|
706
|
+
* Supports $VAR_NAME and ${VAR_NAME} syntax.
|
|
707
|
+
* Returns the original string if the env var is not set.
|
|
708
|
+
*/
|
|
709
|
+
function expandEnvVars(value: string): string {
|
|
710
|
+
if (!value || !value.includes("$")) return value;
|
|
711
|
+
|
|
712
|
+
// Match $VAR_NAME or ${VAR_NAME}
|
|
713
|
+
return value.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, varName) => {
|
|
714
|
+
const envValue = process.env[varName];
|
|
715
|
+
return envValue !== undefined ? envValue : `$${varName}`;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Look up a provider's stored credential in ~/.pi/agent/auth.json.
|
|
721
|
+
* Shape: { "openrouter": { "type": "api_key", "key": "sk-or-..." } }
|
|
722
|
+
* Returns "" when absent.
|
|
723
|
+
*/
|
|
724
|
+
function readAuthJsonKey(providerName: string): string {
|
|
725
|
+
try {
|
|
726
|
+
const p = path.join(os.homedir(), ".pi", "agent", "auth.json");
|
|
727
|
+
if (!fs.existsSync(p)) return "";
|
|
728
|
+
const entry = JSON.parse(fs.readFileSync(p, "utf-8"))[providerName];
|
|
729
|
+
return typeof entry?.key === "string" ? entry.key : "";
|
|
730
|
+
} catch {
|
|
731
|
+
return "";
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Detect whether the current model is on an Ollama provider, a built-in
|
|
737
|
+
* provider, or an unknown provider. Uses 3-tier logic:
|
|
738
|
+
* 1. User-defined provider in models.json
|
|
739
|
+
* 2. Known built-in provider
|
|
740
|
+
* 3. Unknown provider
|
|
741
|
+
*
|
|
742
|
+
* @param ctx - Pi's extension context (from session_start event)
|
|
743
|
+
* @param modelsJson - Optional pre-loaded models.json data (avoids redundant disk read)
|
|
744
|
+
* @returns Provider information including kind, name, API details, and key
|
|
745
|
+
*/
|
|
746
|
+
export function detectProvider(ctx: PiExtensionContext, modelsJson?: PiModelsJson): ProviderInfo {
|
|
747
|
+
const model = ctx.model;
|
|
748
|
+
if (!model) return { kind: "unknown", name: "none" };
|
|
749
|
+
|
|
750
|
+
const providerName = model.provider || "";
|
|
751
|
+
if (!providerName) return { kind: "unknown", name: "none" };
|
|
752
|
+
|
|
753
|
+
// Tier 1: Check if provider is defined in models.json
|
|
754
|
+
const effectiveModelsJson = modelsJson ?? readModelsJson();
|
|
755
|
+
|
|
756
|
+
// Bedrock: AWS SigV4 auth, region taken from the model's baseUrl
|
|
757
|
+
if (providerName === "amazon-bedrock") {
|
|
758
|
+
const baseUrl = (model as any).baseUrl || "";
|
|
759
|
+
const region = baseUrl.match(/bedrock-runtime\.([a-z0-9-]+)\.amazonaws/)?.[1]
|
|
760
|
+
|| process.env.AWS_REGION || "us-east-1";
|
|
761
|
+
return { kind: "bedrock", name: providerName, baseUrl, region, apiKey: "" };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Only treat as a user-defined provider if it carries connection config;
|
|
765
|
+
// entries like openrouter exist in models.json purely for modelOverrides.
|
|
766
|
+
const cfg = ((effectiveModelsJson.providers || {}) as Record<string, PiProviderConfig | undefined>)[providerName];
|
|
767
|
+
const hasConnectionConfig = !!(cfg?.baseUrl || cfg?.api || cfg?.apiKey);
|
|
768
|
+
const userProviderCfg = hasConnectionConfig ? cfg : undefined;
|
|
769
|
+
if (userProviderCfg) {
|
|
770
|
+
const baseUrl = userProviderCfg.baseUrl || "";
|
|
771
|
+
const apiMode = userProviderCfg.api || "";
|
|
772
|
+
const apiKey = expandEnvVars(userProviderCfg.apiKey || "");
|
|
773
|
+
|
|
774
|
+
// Check if it's an Ollama provider (user-defined Ollama in models.json)
|
|
775
|
+
const isOllama = /ollama/i.test(providerName) ||
|
|
776
|
+
/localhost:\d+/.test(baseUrl) ||
|
|
777
|
+
/127\.0\.0\.1:\d+/.test(baseUrl) ||
|
|
778
|
+
/0\.0\.0\.0:\d+/.test(baseUrl) ||
|
|
779
|
+
/\/api\/chat/.test(baseUrl) ||
|
|
780
|
+
apiMode === "ollama";
|
|
781
|
+
|
|
782
|
+
if (isOllama) {
|
|
783
|
+
return { kind: "ollama", name: providerName, apiMode: "ollama", baseUrl, apiKey };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// User-defined non-Ollama provider (treat as Ollama-style if it has /api/chat)
|
|
787
|
+
if (/\/api\/chat/.test(baseUrl)) {
|
|
788
|
+
return { kind: "ollama", name: providerName, apiMode: "ollama", baseUrl, apiKey };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// User-defined provider with custom config — treat as built-in style
|
|
792
|
+
return {
|
|
793
|
+
kind: "builtin",
|
|
794
|
+
name: providerName,
|
|
795
|
+
apiMode: apiMode || "openai-completions",
|
|
796
|
+
baseUrl,
|
|
797
|
+
apiKey,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Tier 2: Check built-in providers
|
|
802
|
+
const builtin = BUILTIN_PROVIDERS[providerName];
|
|
803
|
+
if (builtin) {
|
|
804
|
+
// Key priority: models.json entry → env var → auth.json (where Pi stores login credentials)
|
|
805
|
+
const userProviderCfg = (effectiveModelsJson.providers || {})[providerName];
|
|
806
|
+
const apiKeyFromConfig = expandEnvVars(userProviderCfg?.apiKey || "");
|
|
807
|
+
const apiKeyFromEnv = process.env[builtin.envKey] || "";
|
|
808
|
+
const apiKey = apiKeyFromConfig || apiKeyFromEnv || readAuthJsonKey(providerName);
|
|
809
|
+
|
|
810
|
+
return {
|
|
811
|
+
kind: "builtin",
|
|
812
|
+
name: providerName,
|
|
813
|
+
apiMode: builtin.api,
|
|
814
|
+
baseUrl: builtin.baseUrl,
|
|
815
|
+
envKey: builtin.envKey,
|
|
816
|
+
apiKey,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Tier 3: Unknown provider
|
|
821
|
+
return { kind: "unknown", name: providerName };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ============================================================================
|
|
825
|
+
// Local Provider Detection
|
|
826
|
+
// ============================================================================
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Check if a provider URL indicates a local (on-machine) provider.
|
|
830
|
+
* Used by api.ts, status.ts, and diag.ts to determine whether
|
|
831
|
+
* system metrics (CPU/RAM/Swap) are meaningful.
|
|
832
|
+
*
|
|
833
|
+
* @param baseUrl - The provider's base URL (e.g. "http://localhost:11434/v1")
|
|
834
|
+
* @param providerName - Optional provider name (e.g. "ollama")
|
|
835
|
+
* @returns `true` if the provider is local, `false` otherwise
|
|
836
|
+
*/
|
|
837
|
+
export function isLocalProvider(baseUrl: string, providerName?: string): boolean {
|
|
838
|
+
if (providerName === "ollama") return true;
|
|
839
|
+
const url = baseUrl || "";
|
|
840
|
+
return url.includes("localhost") || url.includes("127.0.0.1") || url.includes("0.0.0.0");
|
|
841
|
+
}
|