@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/configs/opl-browser.json.sample +8 -0
  4. package/configs/opl-footer.json +48 -0
  5. package/configs/opl-footer.json.sample +48 -0
  6. package/configs/opl-input.json +22 -0
  7. package/configs/opl-input.json.sample +22 -0
  8. package/configs/opl-modes.json +291 -0
  9. package/configs/opl-modes.json.sample +285 -0
  10. package/configs/opl-simplebench.json +8 -0
  11. package/configs/opl-simplebench.json.sample +8 -0
  12. package/configs/opl-todo.json +12 -0
  13. package/configs/opl-todo.json.sample +12 -0
  14. package/configs/opl-webaccess.json +31 -0
  15. package/configs/opl-webaccess.json.sample +31 -0
  16. package/extensions/opl-browser/README.md +83 -0
  17. package/extensions/opl-browser/browser.ts +185 -0
  18. package/extensions/opl-browser/config.ts +37 -0
  19. package/extensions/opl-browser/index.ts +89 -0
  20. package/extensions/opl-browser/package-lock.json +60 -0
  21. package/extensions/opl-browser/package.json +10 -0
  22. package/extensions/opl-ctxtrim/README.md +78 -0
  23. package/extensions/opl-ctxtrim/index.ts +151 -0
  24. package/extensions/opl-footer/README.md +193 -0
  25. package/extensions/opl-footer/config.ts +91 -0
  26. package/extensions/opl-footer/git-status.ts +170 -0
  27. package/extensions/opl-footer/icons.ts +49 -0
  28. package/extensions/opl-footer/index.ts +368 -0
  29. package/extensions/opl-footer/segments/caveman.ts +25 -0
  30. package/extensions/opl-footer/segments/chat-mode.ts +24 -0
  31. package/extensions/opl-footer/segments/context.ts +92 -0
  32. package/extensions/opl-footer/segments/cost.ts +25 -0
  33. package/extensions/opl-footer/segments/git.ts +47 -0
  34. package/extensions/opl-footer/segments/helpers.ts +18 -0
  35. package/extensions/opl-footer/segments/index.ts +51 -0
  36. package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
  37. package/extensions/opl-footer/segments/model.ts +22 -0
  38. package/extensions/opl-footer/segments/path.ts +34 -0
  39. package/extensions/opl-footer/segments/pi.ts +11 -0
  40. package/extensions/opl-footer/segments/plan-mode.ts +24 -0
  41. package/extensions/opl-footer/segments/separator.ts +9 -0
  42. package/extensions/opl-footer/segments/session-stats.ts +82 -0
  43. package/extensions/opl-footer/segments/thinking.ts +51 -0
  44. package/extensions/opl-footer/segments/tokens.ts +66 -0
  45. package/extensions/opl-footer/theme.ts +113 -0
  46. package/extensions/opl-footer/types.ts +172 -0
  47. package/extensions/opl-init/README.md +23 -0
  48. package/extensions/opl-init/index.ts +451 -0
  49. package/extensions/opl-input/README.md +83 -0
  50. package/extensions/opl-input/config.ts +137 -0
  51. package/extensions/opl-input/index.ts +284 -0
  52. package/extensions/opl-input/mode-style.ts +38 -0
  53. package/extensions/opl-input/utils.ts +378 -0
  54. package/extensions/opl-modes/README.md +132 -0
  55. package/extensions/opl-modes/config.ts +473 -0
  56. package/extensions/opl-modes/index.ts +1081 -0
  57. package/extensions/opl-modes/menus.ts +60 -0
  58. package/extensions/opl-modes/state.ts +152 -0
  59. package/extensions/opl-modes/types.ts +164 -0
  60. package/extensions/opl-modes/utils.ts +138 -0
  61. package/extensions/opl-questionnaire/README.md +26 -0
  62. package/extensions/opl-questionnaire/index.ts +445 -0
  63. package/extensions/opl-questionnaire/types.ts +55 -0
  64. package/extensions/opl-simplebench/README.md +242 -0
  65. package/extensions/opl-simplebench/artifact.ts +41 -0
  66. package/extensions/opl-simplebench/benchmark.ts +873 -0
  67. package/extensions/opl-simplebench/coding.ts +282 -0
  68. package/extensions/opl-simplebench/index.ts +203 -0
  69. package/extensions/opl-simplebench/llama-server.ts +290 -0
  70. package/extensions/opl-simplebench/metrics.ts +73 -0
  71. package/extensions/opl-simplebench/report.ts +46 -0
  72. package/extensions/opl-simplebench/research.ts +200 -0
  73. package/extensions/opl-simplebench/scoring.ts +48 -0
  74. package/extensions/opl-simplebench/tests.ts +39 -0
  75. package/extensions/opl-simplebench/types.ts +6 -0
  76. package/extensions/opl-simplebench/util/config.ts +170 -0
  77. package/extensions/opl-simplebench/util/debug.ts +32 -0
  78. package/extensions/opl-simplebench/util/format.ts +402 -0
  79. package/extensions/opl-simplebench/util/providers.ts +841 -0
  80. package/extensions/opl-todo/README.md +43 -0
  81. package/extensions/opl-todo/config.ts +71 -0
  82. package/extensions/opl-todo/index.ts +415 -0
  83. package/extensions/opl-webaccess/README.md +47 -0
  84. package/extensions/opl-webaccess/config.ts +55 -0
  85. package/extensions/opl-webaccess/extract.ts +83 -0
  86. package/extensions/opl-webaccess/index.ts +232 -0
  87. package/extensions/opl-webaccess/package-lock.json +345 -0
  88. package/extensions/opl-webaccess/package.json +18 -0
  89. package/extensions/opl-webaccess/pdf.ts +8 -0
  90. package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
  91. package/extensions/opl-webaccess/providers/exa.ts +74 -0
  92. package/extensions/opl-webaccess/providers/gemini.ts +60 -0
  93. package/extensions/opl-webaccess/providers/searxng.ts +86 -0
  94. package/extensions/opl-webaccess/providers/tavily.ts +60 -0
  95. package/extensions/opl-webaccess/search.ts +36 -0
  96. package/extensions/opl-webaccess/storage.ts +51 -0
  97. package/extensions/opl-webaccess/turndown.d.ts +1 -0
  98. package/extensions/opl-webaccess/types.ts +26 -0
  99. package/extensions/opl-webaccess/utils.ts +28 -0
  100. 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
+ }