pi-free 2.0.15 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +64 -79
- package/banner.svg +21 -36
- package/config.ts +123 -9
- package/constants.ts +3 -9
- package/index.ts +14 -15
- package/lib/built-in-toggle.ts +29 -16
- package/lib/json-persistence.ts +90 -22
- package/lib/logger.ts +21 -12
- package/lib/model-detection.ts +2 -12
- package/lib/model-enhancer.ts +11 -2
- package/lib/model-metadata.ts +387 -0
- package/lib/open-browser.ts +74 -24
- package/lib/paths.ts +90 -0
- package/lib/probe-cache.ts +19 -19
- package/lib/provider-cache.ts +74 -28
- package/lib/provider-compat.ts +53 -37
- package/lib/provider-probe.ts +188 -0
- package/lib/registry.ts +1 -5
- package/lib/session-start-metrics.ts +46 -0
- package/lib/telemetry.ts +115 -86
- package/lib/types.ts +22 -2
- package/lib/util.ts +80 -21
- package/package.json +7 -2
- package/provider-failover/benchmark-lookup.ts +17 -5
- package/provider-helper.ts +11 -2
- package/providers/cline/cline-models.ts +7 -1
- package/providers/cline/cline-xml-bridge.ts +974 -0
- package/providers/cline/cline.ts +67 -176
- package/providers/crofai/crofai.ts +6 -1
- package/providers/deepinfra/deepinfra.ts +69 -2
- package/providers/dynamic-built-in/index.ts +237 -2
- package/providers/kilo/kilo-models.ts +3 -1
- package/providers/kilo/kilo.ts +268 -41
- package/providers/model-fetcher.ts +18 -55
- package/providers/novita/novita.ts +69 -2
- package/providers/ollama/ollama.ts +48 -24
- package/providers/opencode-session.ts +67 -2
- package/providers/routeway/routeway.ts +25 -17
- package/providers/sambanova/sambanova.ts +67 -1
- package/providers/together/together.ts +69 -2
- package/providers/tokenrouter/tokenrouter.ts +378 -0
- package/providers/zenmux/zenmux.ts +6 -1
- package/scripts/check-extensions.mjs +32 -16
- 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
|
+
}
|
package/lib/probe-cache.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
}
|
package/lib/provider-cache.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
105
|
-
|
|
147
|
+
export async function clearAllProviderCaches(): Promise<void> {
|
|
148
|
+
await _cache.update(() => {
|
|
149
|
+
_logger.debug("Cleared all provider caches");
|
|
150
|
+
return { providers: {} };
|
|
151
|
+
});
|
|
106
152
|
}
|
package/lib/provider-compat.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
19
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|