pi-free 2.0.15 → 2.1.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +100 -3
  2. package/README.md +64 -79
  3. package/banner.svg +21 -36
  4. package/config.ts +123 -9
  5. package/constants.ts +3 -9
  6. package/index.ts +14 -15
  7. package/lib/built-in-toggle.ts +29 -56
  8. package/lib/json-persistence.ts +90 -22
  9. package/lib/logger.ts +21 -12
  10. package/lib/model-detection.ts +2 -12
  11. package/lib/model-enhancer.ts +11 -2
  12. package/lib/model-metadata.ts +387 -0
  13. package/lib/open-browser.ts +74 -24
  14. package/lib/paths.ts +90 -0
  15. package/lib/probe-cache.ts +19 -19
  16. package/lib/provider-cache.ts +74 -28
  17. package/lib/provider-compat.ts +53 -37
  18. package/lib/provider-probe.ts +188 -0
  19. package/lib/registry.ts +1 -5
  20. package/lib/session-start-metrics.ts +46 -0
  21. package/lib/telemetry.ts +115 -86
  22. package/lib/types.ts +22 -2
  23. package/lib/util.ts +80 -21
  24. package/package.json +7 -2
  25. package/provider-failover/benchmark-lookup.ts +17 -5
  26. package/provider-helper.ts +12 -27
  27. package/providers/cline/cline-models.ts +7 -1
  28. package/providers/cline/cline-xml-bridge.ts +1471 -0
  29. package/providers/cline/cline.ts +67 -199
  30. package/providers/codestral/codestral.ts +0 -11
  31. package/providers/crofai/crofai.ts +6 -1
  32. package/providers/deepinfra/deepinfra.ts +69 -2
  33. package/providers/dynamic-built-in/index.ts +237 -22
  34. package/providers/kilo/kilo-models.ts +3 -1
  35. package/providers/kilo/kilo.ts +270 -60
  36. package/providers/model-fetcher.ts +18 -55
  37. package/providers/novita/novita.ts +69 -2
  38. package/providers/ollama/ollama.ts +47 -36
  39. package/providers/opencode-session.ts +67 -2
  40. package/providers/routeway/routeway.ts +25 -17
  41. package/providers/sambanova/sambanova.ts +67 -1
  42. package/providers/together/together.ts +69 -2
  43. package/providers/tokenrouter/tokenrouter.ts +634 -0
  44. package/providers/zenmux/zenmux.ts +6 -1
  45. package/scripts/check-extensions.mjs +32 -16
  46. package/providers/nvidia/nvidia.ts +0 -510
package/lib/paths.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Centralized path resolution for ~/.pi/ data files.
3
+ *
4
+ * These helpers constrain file paths to the user's pi data directory
5
+ * to prevent arbitrary-file-write vulnerabilities from attacker-controlled
6
+ * environment variables. All env-var file overrides must resolve to a
7
+ * path inside ~/.pi/ or be rejected.
8
+ */
9
+
10
+ import { existsSync, mkdirSync } from "node:fs";
11
+ import { homedir as _nodeHomedir } from "node:os";
12
+ import { basename, join, resolve, sep } from "node:path";
13
+
14
+ /**
15
+ * The user's home directory.
16
+ *
17
+ * On POSIX, `node:os.homedir()` already respects the `HOME` env var.
18
+ * On Windows, it uses `USERPROFILE` and ignores `HOME`. We prefer `HOME`
19
+ * (set by tests and various cross-platform tooling) and fall back to
20
+ * `USERPROFILE` and then to `node:os.homedir()`.
21
+ */
22
+ function resolveHomeDir(): string {
23
+ if (process.env.HOME) return process.env.HOME;
24
+ if (process.env.USERPROFILE) return process.env.USERPROFILE;
25
+ return _nodeHomedir();
26
+ }
27
+
28
+ /** The user's pi data directory (~/.pi). */
29
+ export const PI_DATA_DIR = join(resolveHomeDir(), ".pi");
30
+
31
+ /** Maximum basename length for override env vars. */
32
+ const MAX_BASENAME_LENGTH = 128;
33
+
34
+ /** Characters that are not allowed in a basename. */
35
+ const FORBIDDEN_CHARS_RE = /[/\\\0]/;
36
+
37
+ /**
38
+ * Ensure a directory exists, creating it (and parents) if missing.
39
+ * Idempotent — safe to call repeatedly.
40
+ */
41
+ export function ensureDir(dir: string): void {
42
+ if (!existsSync(dir)) {
43
+ mkdirSync(dir, { recursive: true });
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve a file path from an env-var override, constrained to ~/.pi/.
49
+ *
50
+ * The env var may override only the *filename* (not the directory).
51
+ * If the env var is unset or empty, returns the default path.
52
+ * If the env var contains a path separator or null byte, the override
53
+ * is rejected and the default path is used.
54
+ *
55
+ * @param envValue - Raw env var value (may be undefined/empty)
56
+ * @param defaultFilename - Default filename inside ~/.pi/
57
+ * @returns A path string that is guaranteed to be inside ~/.pi/
58
+ */
59
+ export function resolveSafeDataFile(
60
+ envValue: string | undefined,
61
+ defaultFilename: string,
62
+ ): string {
63
+ if (!envValue) {
64
+ return join(PI_DATA_DIR, defaultFilename);
65
+ }
66
+
67
+ // Reject any path separator — only allow bare filenames
68
+ if (FORBIDDEN_CHARS_RE.test(envValue)) {
69
+ return join(PI_DATA_DIR, defaultFilename);
70
+ }
71
+
72
+ // Reject empty after trim, overly long, or suspicious filenames
73
+ const trimmed = envValue.trim();
74
+ if (
75
+ !trimmed ||
76
+ trimmed === "." ||
77
+ trimmed === ".." ||
78
+ trimmed.length > MAX_BASENAME_LENGTH
79
+ ) {
80
+ return join(PI_DATA_DIR, defaultFilename);
81
+ }
82
+
83
+ // Final safety check: resolve and verify the result is still inside PI_DATA_DIR
84
+ const candidate = resolve(join(PI_DATA_DIR, basename(trimmed)));
85
+ if (candidate !== PI_DATA_DIR && !candidate.startsWith(PI_DATA_DIR + sep)) {
86
+ return join(PI_DATA_DIR, defaultFilename);
87
+ }
88
+
89
+ return candidate;
90
+ }
@@ -5,10 +5,9 @@
5
5
  * background cleanup can avoid spending quota on the same checks every session.
6
6
  */
7
7
 
8
- import { homedir } from "node:os";
9
- import { join } from "node:path";
10
8
  import { createJSONStore } from "./json-persistence.ts";
11
9
  import { createLogger } from "./logger.ts";
10
+ import { PI_DATA_DIR } from "./paths.ts";
12
11
 
13
12
  const _logger = createLogger("probe-cache");
14
13
 
@@ -35,7 +34,7 @@ interface ProbeCacheData {
35
34
  providers: Record<string, ProviderProbeCache>;
36
35
  }
37
36
 
38
- const CACHE_FILE = join(homedir(), ".pi", "probe-cache.json");
37
+ const CACHE_FILE = `${PI_DATA_DIR}/probe-cache.json`;
39
38
  const _cache = createJSONStore<ProbeCacheData>(CACHE_FILE, { providers: {} });
40
39
 
41
40
  export function getModelsDueForProbe(
@@ -61,26 +60,27 @@ export function getModelsDueForProbe(
61
60
  });
62
61
  }
63
62
 
64
- export function recordModelProbeResults(
63
+ export async function recordModelProbeResults(
65
64
  providerId: string,
66
65
  results: ModelProbeResult[],
67
- ): void {
66
+ ): Promise<void> {
68
67
  if (results.length === 0) return;
69
68
 
70
- const data = _cache.load();
71
- const provider = (data.providers[providerId] ??= {
72
- provider: providerId,
73
- models: {},
69
+ await _cache.update((data) => {
70
+ const provider = (data.providers[providerId] ??= {
71
+ provider: providerId,
72
+ models: {},
73
+ });
74
+ const lastProbedAt = new Date().toISOString();
75
+
76
+ for (const result of results) {
77
+ provider.models[result.modelId] = {
78
+ lastProbedAt,
79
+ status: result.status,
80
+ };
81
+ }
82
+
83
+ return data;
74
84
  });
75
- const lastProbedAt = new Date().toISOString();
76
-
77
- for (const result of results) {
78
- provider.models[result.modelId] = {
79
- lastProbedAt,
80
- status: result.status,
81
- };
82
- }
83
-
84
- _cache.save(data);
85
85
  _logger.debug(`Recorded ${results.length} probe results for ${providerId}`);
86
86
  }
@@ -9,10 +9,9 @@
9
9
  * 3. If API fails: use cached models as fallback
10
10
  */
11
11
 
12
- import { homedir } from "node:os";
13
- import { join } from "node:path";
14
12
  import { createJSONStore } from "./json-persistence.ts";
15
13
  import { createLogger } from "./logger.ts";
14
+ import { resolveSafeDataFile } from "./paths.ts";
16
15
  import type { ProviderModelConfig } from "./types.ts";
17
16
 
18
17
  const _logger = createLogger("provider-cache");
@@ -38,10 +37,31 @@ interface CacheData {
38
37
  // Cache Store
39
38
  // =============================================================================
40
39
 
41
- const CACHE_FILE = join(homedir(), ".pi", "provider-cache.json");
40
+ const CACHE_FILE = resolveSafeDataFile(
41
+ process.env.PI_FREE_PROVIDER_CACHE,
42
+ "provider-cache.json",
43
+ );
42
44
 
43
45
  const _cache = createJSONStore<CacheData>(CACHE_FILE, { providers: {} });
44
46
 
47
+ export const DEFAULT_PROVIDER_CACHE_TTL_MS = 60 * 60 * 1000;
48
+
49
+ function getProviderCacheEntry(
50
+ providerId: string,
51
+ ): CachedProviderModels | undefined {
52
+ try {
53
+ const data = _cache.load();
54
+ const cached = data?.providers?.[providerId];
55
+ if (!cached || !Array.isArray(cached.models)) return undefined;
56
+ return cached;
57
+ } catch (error) {
58
+ _logger.warn(`Failed to load provider cache for ${providerId}`, {
59
+ error: error instanceof Error ? error.message : String(error),
60
+ });
61
+ return undefined;
62
+ }
63
+ }
64
+
45
65
  /**
46
66
  * Load cached models for a provider.
47
67
  * Returns undefined if no cache exists.
@@ -49,8 +69,7 @@ const _cache = createJSONStore<CacheData>(CACHE_FILE, { providers: {} });
49
69
  export function loadProviderCache(
50
70
  providerId: string,
51
71
  ): ProviderModelConfig[] | undefined {
52
- const data = _cache.load();
53
- const cached = data.providers[providerId];
72
+ const cached = getProviderCacheEntry(providerId);
54
73
 
55
74
  if (!cached) {
56
75
  return undefined;
@@ -61,25 +80,50 @@ export function loadProviderCache(
61
80
  fetchedAt: cached.fetchedAt,
62
81
  });
63
82
 
64
- return cached.models;
83
+ return structuredClone(cached.models);
84
+ }
85
+
86
+ /**
87
+ * Return the age of a provider cache entry in milliseconds.
88
+ * Returns undefined if no cache exists or the timestamp is invalid.
89
+ */
90
+ function getProviderCacheAgeMs(providerId: string): number | undefined {
91
+ const cached = getProviderCacheEntry(providerId);
92
+ if (!cached) return undefined;
93
+
94
+ const fetchedAt = new Date(cached.fetchedAt).getTime();
95
+ if (Number.isNaN(fetchedAt)) return undefined;
96
+ const age = Date.now() - fetchedAt;
97
+ if (age < -5000) return undefined;
98
+ return Math.max(0, age);
99
+ }
100
+
101
+ /**
102
+ * Check whether a provider cache entry is fresh enough to skip network refresh.
103
+ */
104
+ export function isProviderCacheFresh(
105
+ providerId: string,
106
+ maxAgeMs: number,
107
+ ): boolean {
108
+ const age = getProviderCacheAgeMs(providerId);
109
+ return age !== undefined && age <= maxAgeMs;
65
110
  }
66
111
 
67
112
  /**
68
113
  * Save models to cache for a provider.
69
114
  */
70
- export function saveProviderCache(
115
+ export async function saveProviderCache(
71
116
  providerId: string,
72
117
  models: ProviderModelConfig[],
73
- ): void {
74
- const data = _cache.load();
75
-
76
- data.providers[providerId] = {
77
- provider: providerId,
78
- models,
79
- fetchedAt: new Date().toISOString(),
80
- };
81
-
82
- _cache.save(data);
118
+ ): Promise<void> {
119
+ await _cache.update((data) => {
120
+ data.providers[providerId] = {
121
+ provider: providerId,
122
+ models: structuredClone(models),
123
+ fetchedAt: new Date().toISOString(),
124
+ };
125
+ return data;
126
+ });
83
127
 
84
128
  _logger.debug(`Saved ${models.length} models to cache for ${providerId}`);
85
129
  }
@@ -87,20 +131,22 @@ export function saveProviderCache(
87
131
  /**
88
132
  * Clear cached models for a provider.
89
133
  */
90
- export function clearProviderCache(providerId: string): void {
91
- const data = _cache.load();
92
-
93
- if (data.providers[providerId]) {
94
- delete data.providers[providerId];
95
- _cache.save(data);
96
- _logger.debug(`Cleared cache for ${providerId}`);
97
- }
134
+ export async function clearProviderCache(providerId: string): Promise<void> {
135
+ await _cache.update((data) => {
136
+ if (data.providers[providerId]) {
137
+ delete data.providers[providerId];
138
+ _logger.debug(`Cleared cache for ${providerId}`);
139
+ }
140
+ return data;
141
+ });
98
142
  }
99
143
 
100
144
  /**
101
145
  * Clear all provider caches.
102
146
  */
103
- export function clearAllProviderCaches(): void {
104
- _cache.save({ providers: {} });
105
- _logger.debug("Cleared all provider caches");
147
+ export async function clearAllProviderCaches(): Promise<void> {
148
+ await _cache.update(() => {
149
+ _logger.debug("Cleared all provider caches");
150
+ return { providers: {} };
151
+ });
106
152
  }
@@ -1,9 +1,7 @@
1
1
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelIdentity } from "./types.ts";
2
3
 
3
- export interface ProviderModelIdentity {
4
- id: string;
5
- name?: string;
6
- }
4
+ export type ProviderModelIdentity = ModelIdentity;
7
5
 
8
6
  export const DEEPSEEK_PROXY_COMPAT: NonNullable<ProviderModelConfig["compat"]> =
9
7
  {
@@ -14,16 +12,39 @@ export const DEEPSEEK_PROXY_COMPAT: NonNullable<ProviderModelConfig["compat"]> =
14
12
  thinkingFormat: "deepseek",
15
13
  };
16
14
 
15
+ /** Kimi K2.6 on OpenRouter needs reasoning_content on assistant messages
16
+ * (OpenRouter issue #5309) but doesn't use the DeepSeek thinking format. */
17
+ const KIMI_PROXY_COMPAT: NonNullable<ProviderModelConfig["compat"]> = {
18
+ supportsStore: false,
19
+ supportsDeveloperRole: false,
20
+ supportsReasoningEffort: true,
21
+ requiresReasoningContentOnAssistantMessages: true,
22
+ };
23
+
24
+ function getModelHaystack(model: ProviderModelIdentity): string {
25
+ return [model.id, model.name, model.family, model.provider]
26
+ .filter(Boolean)
27
+ .join(" ")
28
+ .toLowerCase();
29
+ }
30
+
17
31
  export function isDeepSeekModel(model: ProviderModelIdentity): boolean {
18
- const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
19
- return haystack.includes("deepseek");
32
+ return getModelHaystack(model).includes("deepseek");
33
+ }
34
+
35
+ /** MiMo/Xiaomi reasoning models expose OpenAI-compatible reasoning controls
36
+ * through gateways such as Cline/OpenRouter, but they are not DeepSeek-format. */
37
+ function isMimoModel(model: ProviderModelIdentity): boolean {
38
+ const haystack = getModelHaystack(model);
39
+ return haystack.includes("mimo") || haystack.includes("xiaomi");
20
40
  }
21
41
 
22
42
  export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
23
- const haystack = `${model.id} ${model.name ?? ""}`.toLowerCase();
43
+ const haystack = getModelHaystack(model);
24
44
  return (
25
45
  isDeepSeekModel(model) ||
26
46
  haystack.includes("minimax") ||
47
+ isMimoModel(model) ||
27
48
  haystack.includes("kimi") ||
28
49
  haystack.includes("qwen3.7") ||
29
50
  haystack.includes("qwen3-7") ||
@@ -35,6 +56,28 @@ export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
35
56
  );
36
57
  }
37
58
 
59
+ /**
60
+ * Models that the gateway/proxy exposes as a DeepSeek-style reasoning
61
+ * format — use the canonical DEEPSEEK_PROXY_COMPAT (full feature set +
62
+ * thinkingFormat).
63
+ */
64
+ function isDeepSeekStyleModel(model: ProviderModelIdentity): boolean {
65
+ const id = model.id.toLowerCase();
66
+ return (
67
+ isDeepSeekModel(model) ||
68
+ id.includes("qwen3.7") ||
69
+ id.includes("qwen3-7")
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Kimi variants need the same reasoning_content replay as DeepSeek-style
75
+ * models but without the thinkingFormat override.
76
+ */
77
+ function isKimiModel(model: ProviderModelIdentity): boolean {
78
+ return model.id.toLowerCase().includes("kimi");
79
+ }
80
+
38
81
  /**
39
82
  * For gateway/proxy providers that mask the upstream DeepSeek base URL,
40
83
  * add explicit compat so pi-ai preserves and replays reasoning_content.
@@ -42,38 +85,11 @@ export function isLikelyReasoningModel(model: ProviderModelIdentity): boolean {
42
85
  export function getProxyModelCompat(
43
86
  model: ProviderModelIdentity,
44
87
  ): ProviderModelConfig["compat"] | undefined {
45
- if (isDeepSeekModel(model)) {
46
- return DEEPSEEK_PROXY_COMPAT;
47
- }
48
-
49
- // MiniMax on OpenRouter/Cline uses reasoning_content (DeepSeek format)
50
- if (model.id.toLowerCase().includes("minimax")) {
51
- return {
52
- supportsStore: false,
53
- supportsDeveloperRole: false,
54
- supportsReasoningEffort: true,
55
- requiresReasoningContentOnAssistantMessages: true,
56
- thinkingFormat: "deepseek",
57
- };
58
- }
59
-
60
- // Qwen 3.7+ on OpenRouter/Cline uses reasoning_content (DeepSeek format)
61
- if (
62
- model.id.toLowerCase().includes("qwen3.7") ||
63
- model.id.toLowerCase().includes("qwen3-7")
64
- ) {
88
+ if (isDeepSeekStyleModel(model)) {
65
89
  return DEEPSEEK_PROXY_COMPAT;
66
90
  }
67
-
68
- // Kimi K2.6 needs reasoning_content on assistant messages (OpenRouter issue #5309)
69
- if (model.id.toLowerCase().includes("kimi")) {
70
- return {
71
- supportsStore: false,
72
- supportsDeveloperRole: false,
73
- supportsReasoningEffort: true,
74
- requiresReasoningContentOnAssistantMessages: true,
75
- };
91
+ if (isKimiModel(model) || isMimoModel(model)) {
92
+ return KIMI_PROXY_COMPAT;
76
93
  }
77
-
78
94
  return undefined;
79
95
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Generic Provider Probe Helper
3
+ *
4
+ * Provides a reusable auto-probe factory for providers whose free model
5
+ * availability may change over time (expired promotions, rate limits,
6
+ * server spin-down).
7
+ *
8
+ * Usage:
9
+ * const probe = createProviderProbe({
10
+ * providerId: "deepinfra",
11
+ * probeModel: async (apiKey, modelId) => { ... return "ok"|"broken"|"unknown"; },
12
+ * });
13
+ * const broken = await probe.run(apiKey, models);
14
+ * // broken is a string[] of model IDs that returned "broken"
15
+ *
16
+ * The helper handles:
17
+ * - Batching probe requests (default batchSize=5)
18
+ * - Probe-cache integration (skip recently-probed models, persist results)
19
+ * - Auto-hiding broken models in config (provider-scoped)
20
+ * - Re-registration after hiding
21
+ */
22
+
23
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
24
+ import { updateConfig } from "../config.ts";
25
+ import { createLogger } from "./logger.ts";
26
+ import {
27
+ getModelsDueForProbe,
28
+ recordModelProbeResults,
29
+ type ModelProbeResult,
30
+ } from "./probe-cache.ts";
31
+
32
+ const _logger = createLogger("provider-probe");
33
+
34
+ // =============================================================================
35
+ // Types
36
+ // =============================================================================
37
+
38
+ export type ProbeModelFn = (
39
+ apiKey: string,
40
+ modelId: string,
41
+ ) => Promise<"ok" | "broken" | "unknown">;
42
+
43
+ export interface ProviderProbeOptions {
44
+ /** Provider identifier (used for probe-cache key and config hiding). */
45
+ providerId: string;
46
+ /** Provider-specific probe function. */
47
+ probeModel: ProbeModelFn;
48
+ /** Max concurrent probes per batch (default: 5). */
49
+ batchSize?: number;
50
+ /**
51
+ * Whether broken models should be auto-hidden in config.
52
+ * Default: true for most providers; false for transient promotions.
53
+ */
54
+ autoHide?: boolean;
55
+ }
56
+
57
+ export interface ProviderProbe {
58
+ /**
59
+ * Run the probe against the given models.
60
+ *
61
+ * @param apiKey - The provider's API key
62
+ * @param models - Models to test (typically free models)
63
+ * @param options.useCache - When true, skip models with fresh probe-cache entries
64
+ * @param options.onBroken - Optional callback fired per broken model (e.g., for notifications)
65
+ * @returns Array of broken model IDs
66
+ */
67
+ run: (
68
+ apiKey: string,
69
+ models: ProviderModelConfig[],
70
+ options?: {
71
+ useCache?: boolean;
72
+ onBroken?: (brokenIds: string[]) => void;
73
+ },
74
+ ) => Promise<string[]>;
75
+
76
+ /**
77
+ * Convenience: wire lazy async auto-probe into session_start.
78
+ * Returns a fire-and-forget session_start handler that schedules one probe.
79
+ */
80
+ autoProbeHandler: (
81
+ apiKey: string,
82
+ models: ProviderModelConfig[],
83
+ ) => () => void;
84
+ }
85
+
86
+ // =============================================================================
87
+ // Factory
88
+ // =============================================================================
89
+
90
+ export function createProviderProbe(
91
+ options: ProviderProbeOptions,
92
+ ): ProviderProbe {
93
+ const { providerId, probeModel, batchSize = 5, autoHide = true } = options;
94
+
95
+ const run: ProviderProbe["run"] = async (
96
+ apiKey,
97
+ models,
98
+ opts = {},
99
+ ): Promise<string[]> => {
100
+ const { useCache = false, onBroken } = opts;
101
+
102
+ // Determine which models need probing
103
+ const modelIdsToProbe = useCache
104
+ ? new Set(
105
+ getModelsDueForProbe(
106
+ providerId,
107
+ models.map((m) => m.id),
108
+ ),
109
+ )
110
+ : undefined;
111
+ const probeCandidates = modelIdsToProbe
112
+ ? models.filter((m) => modelIdsToProbe.has(m.id))
113
+ : models;
114
+
115
+ if (probeCandidates.length === 0) {
116
+ _logger.info(`[probe] ${providerId}: probe cache is fresh`);
117
+ return [];
118
+ }
119
+
120
+ _logger.info(
121
+ `[probe] ${providerId}: probing ${probeCandidates.length} models (batch ${batchSize})`,
122
+ );
123
+
124
+ // Batch probes
125
+ const broken: string[] = [];
126
+ const cacheableResults: ModelProbeResult[] = [];
127
+
128
+ for (let i = 0; i < probeCandidates.length; i += batchSize) {
129
+ const batch = probeCandidates.slice(i, i + batchSize);
130
+ const results = await Promise.all(
131
+ batch.map(async (m) => {
132
+ const status = await probeModel(apiKey, m.id);
133
+ return { id: m.id, status };
134
+ }),
135
+ );
136
+ for (const r of results) {
137
+ if (r.status === "broken") broken.push(r.id);
138
+ if (r.status !== "unknown") {
139
+ cacheableResults.push({ modelId: r.id, status: r.status });
140
+ }
141
+ }
142
+ }
143
+
144
+ // Persist probe results to cache
145
+ await recordModelProbeResults(providerId, cacheableResults);
146
+
147
+ if (broken.length === 0) {
148
+ _logger.info(`[probe] ${providerId}: all models accessible`);
149
+ return [];
150
+ }
151
+
152
+ // Optional auto-hide — use updateConfig for atomic RMW to prevent
153
+ // concurrent probes from clobbering each other's hidden_models.
154
+ if (autoHide) {
155
+ await updateConfig((cfg) => {
156
+ const existingHidden = new Set(cfg.hidden_models ?? []);
157
+ for (const id of broken) existingHidden.add(`${providerId}/${id}`);
158
+ return { hidden_models: Array.from(existingHidden) };
159
+ });
160
+ _logger.info(
161
+ `[probe] ${providerId}: auto-hidden ${broken.length} broken models`,
162
+ );
163
+ }
164
+
165
+ onBroken?.(broken);
166
+ _logger.info(`[probe] ${providerId}: found ${broken.length} broken models`);
167
+ return broken;
168
+ };
169
+
170
+ const autoProbeHandler: ProviderProbe["autoProbeHandler"] = (
171
+ apiKey,
172
+ models,
173
+ ) => {
174
+ let done = false;
175
+ return () => {
176
+ if (done) return;
177
+ done = true;
178
+ _logger.info(`[probe] Starting lazy auto-probe for ${providerId}...`);
179
+ run(apiKey, models, { useCache: true }).catch((err) => {
180
+ _logger.warn(`[probe] ${providerId}: auto-probe failed`, {
181
+ error: err instanceof Error ? err.message : String(err),
182
+ });
183
+ });
184
+ };
185
+ };
186
+
187
+ return { run, autoProbeHandler };
188
+ }
package/lib/registry.ts CHANGED
@@ -5,10 +5,7 @@
5
5
  * without creating a circular dependency.
6
6
  */
7
7
 
8
- import type {
9
- ExtensionAPI,
10
- ProviderModelConfig,
11
- } from "@earendil-works/pi-coding-agent";
8
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
12
9
  import { getFreeOnly, getProviderShowPaid, saveConfig } from "../config.ts";
13
10
  import { createLogger } from "./logger.ts";
14
11
 
@@ -221,7 +218,6 @@ function applyFilterToProvider(
221
218
  }
222
219
 
223
220
  export function applyGlobalFilter(
224
- _pi: ExtensionAPI,
225
221
  freeOnly: boolean,
226
222
  options: { force?: boolean } = {},
227
223
  ): void {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session-start timing helpers.
3
+ *
4
+ * Pi-lens logs the total cost of session_start via debug messages. Extensions
5
+ * like pi-free attach async handlers to session_start that can materially
6
+ * increase that cost (model refresh, accessibility probes, etc.). This module
7
+ * wraps handlers so those delays show up in the logs and can be audited.
8
+ */
9
+
10
+ import { createLogger } from "./logger.ts";
11
+
12
+ const _logger = createLogger("session-start-metrics");
13
+
14
+ /**
15
+ * Wrap a session_start handler so its wall-clock duration is logged.
16
+ * The label should identify the provider/feature being timed.
17
+ */
18
+ export function wrapSessionStartHandler<TArgs extends unknown[]>(
19
+ label: string,
20
+ handler: (...args: TArgs) => void | Promise<void>,
21
+ ): (...args: TArgs) => Promise<void> {
22
+ return async (...args) => {
23
+ const start = Date.now();
24
+ try {
25
+ await handler(...args);
26
+ } finally {
27
+ _logger.info(`session_start ${label}: ${Date.now() - start}ms`);
28
+ }
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Time a synchronous or asynchronous block and log its duration.
34
+ * Returns the result of the block.
35
+ */
36
+ export async function timeAsync<T>(
37
+ label: string,
38
+ block: () => T | Promise<T>,
39
+ ): Promise<T> {
40
+ const start = Date.now();
41
+ try {
42
+ return await block();
43
+ } finally {
44
+ _logger.info(`${label}: ${Date.now() - start}ms`);
45
+ }
46
+ }