@luxass/pi-personal 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +14 -2
- package/package.json +45 -3
- package/src/continue-after-compaction.ts +59 -0
- package/src/crow/auto-compact.ts +318 -0
- package/src/crow/codex-stream.ts +742 -0
- package/src/crow/index.ts +826 -0
- package/src/crow/lib.ts +744 -0
- package/src/crow/retry.ts +38 -0
- package/src/custom-header.ts +220 -0
- package/src/git-interceptor.ts +39 -0
- package/src/package-manager-interceptor.ts +24 -0
- package/src/worker-configuration-guard.ts +191 -0
package/src/crow/lib.ts
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for CLIProxyAPI baseUrl normalization, model mapping, and config I/O.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import type { ModelCost, ModelCostTier, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
10
|
+
import { readStoredCredential, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_PROVIDER_ID = "crow";
|
|
13
|
+
export const DEFAULT_PROVIDER_NAME = "Crow";
|
|
14
|
+
export const DEFAULT_BASE_URL = "http://127.0.0.1:8317";
|
|
15
|
+
export const AUTH_FILE_NAME = "auth.json";
|
|
16
|
+
export const CLIENT_VERSION = "pi";
|
|
17
|
+
export const MODELS_REQUEST_TIMEOUT_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
/** Keep login credentials effectively permanent; reconfigure via /login. */
|
|
20
|
+
export const CREDENTIAL_TTL_MS = 100 * 365 * 24 * 60 * 60 * 1000;
|
|
21
|
+
export const MODELS_DEV_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
|
|
23
|
+
export const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
|
|
24
|
+
export const DEFAULT_MAX_TOKENS = 16384;
|
|
25
|
+
export const DEFAULT_CONTEXT_WINDOW = 128000;
|
|
26
|
+
|
|
27
|
+
const PI_THINKING_LEVELS = [
|
|
28
|
+
"off",
|
|
29
|
+
"minimal",
|
|
30
|
+
"low",
|
|
31
|
+
"medium",
|
|
32
|
+
"high",
|
|
33
|
+
"xhigh",
|
|
34
|
+
"max",
|
|
35
|
+
] as const satisfies readonly (keyof ThinkingLevelMap)[];
|
|
36
|
+
|
|
37
|
+
export interface ResolvedIdentity {
|
|
38
|
+
providerId: string;
|
|
39
|
+
providerName: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolvedConnection {
|
|
43
|
+
baseUrlInput: string;
|
|
44
|
+
apiKey: string;
|
|
45
|
+
inferenceBaseUrl: string;
|
|
46
|
+
modelsUrl: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CodexReasoningLevel {
|
|
50
|
+
effort?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CodexClientModel {
|
|
55
|
+
slug?: string;
|
|
56
|
+
id?: string;
|
|
57
|
+
display_name?: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
context_window?: number;
|
|
61
|
+
max_context_window?: number;
|
|
62
|
+
max_tokens?: number;
|
|
63
|
+
max_output_tokens?: number;
|
|
64
|
+
max_completion_tokens?: number;
|
|
65
|
+
input_modalities?: string[];
|
|
66
|
+
supported_reasoning_levels?: CodexReasoningLevel[] | string[];
|
|
67
|
+
|
|
68
|
+
visibility?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CodexClientModelsResponse {
|
|
72
|
+
models?: CodexClientModel[];
|
|
73
|
+
data?: CodexClientModel[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MappedModels {
|
|
77
|
+
models: ProviderModelConfig[];
|
|
78
|
+
inferenceBaseUrl: string;
|
|
79
|
+
modelsUrl: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ModelsDevCostPayload {
|
|
83
|
+
input?: unknown;
|
|
84
|
+
output?: unknown;
|
|
85
|
+
cache_read?: unknown;
|
|
86
|
+
cache_write?: unknown;
|
|
87
|
+
tiers?: unknown;
|
|
88
|
+
context_over_200k?: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ModelsDevModelPayload {
|
|
92
|
+
cost?: ModelsDevCostPayload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ModelsDevCostEntry {
|
|
96
|
+
providerId: string;
|
|
97
|
+
modelId: string;
|
|
98
|
+
standard: ModelCost;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ModelsDevCostCatalog {
|
|
102
|
+
exact: Map<string, ModelsDevCostEntry[]>;
|
|
103
|
+
normalized: Map<string, ModelsDevCostEntry[]>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface OAuthRefreshMeta {
|
|
107
|
+
baseUrl: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function firstNonEmpty(...values: Array<string | undefined | null>): string | undefined {
|
|
111
|
+
for (const value of values) {
|
|
112
|
+
if (typeof value === "string" && value.trim()) {
|
|
113
|
+
return value.trim();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize user-provided base URL into inference + models endpoints.
|
|
121
|
+
*
|
|
122
|
+
* Preferred input: host:port (e.g. http://127.0.0.1:8317)
|
|
123
|
+
* - /backend-api kept as-is for inference
|
|
124
|
+
* - /v1 rewritten to /backend-api for inference
|
|
125
|
+
* - models always at {root}/v1/models?client_version=pi
|
|
126
|
+
*/
|
|
127
|
+
export function resolveEndpoints(baseUrlInput: string): {
|
|
128
|
+
inferenceBaseUrl: string;
|
|
129
|
+
modelsUrl: string;
|
|
130
|
+
rootOrigin: string;
|
|
131
|
+
} {
|
|
132
|
+
let raw = baseUrlInput.trim();
|
|
133
|
+
if (!raw) {
|
|
134
|
+
throw new Error("baseUrl is empty");
|
|
135
|
+
}
|
|
136
|
+
if (!/^https?:\/\//i.test(raw)) {
|
|
137
|
+
raw = `http://${raw}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const url = new URL(raw);
|
|
141
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
142
|
+
|
|
143
|
+
if (path === "/v1") {
|
|
144
|
+
path = "/backend-api";
|
|
145
|
+
} else if (path.endsWith("/v1")) {
|
|
146
|
+
path = `${path.slice(0, -"/v1".length)}/backend-api`;
|
|
147
|
+
} else if (path === "" || path === "/") {
|
|
148
|
+
path = "/backend-api";
|
|
149
|
+
} else if (!path.endsWith("/backend-api")) {
|
|
150
|
+
path = `${path}/backend-api`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rootPath = path.replace(/\/backend-api$/, "");
|
|
154
|
+
const inferenceBaseUrl = `${url.origin}${path}/`;
|
|
155
|
+
const modelsPath = `${rootPath}/v1/models`.replace(/\/{2,}/g, "/");
|
|
156
|
+
const modelsUrl = `${url.origin}${modelsPath}?client_version=${encodeURIComponent(CLIENT_VERSION)}`;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
inferenceBaseUrl,
|
|
160
|
+
modelsUrl,
|
|
161
|
+
rootOrigin: url.origin,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function encodeRefreshMeta(baseUrl: string): string {
|
|
166
|
+
const meta: OAuthRefreshMeta = { baseUrl };
|
|
167
|
+
return JSON.stringify(meta);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function decodeRefreshMeta(refresh: string | undefined): OAuthRefreshMeta | null {
|
|
171
|
+
if (!refresh?.trim()) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(refresh) as OAuthRefreshMeta;
|
|
176
|
+
if (parsed && typeof parsed.baseUrl === "string" && parsed.baseUrl.trim()) {
|
|
177
|
+
return { baseUrl: parsed.baseUrl.trim() };
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Older / non-JSON refresh tokens are ignored.
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function loadAuthConnection(
|
|
186
|
+
agentDir: string,
|
|
187
|
+
providerId: string,
|
|
188
|
+
): { baseUrl?: string; apiKey?: string } | null {
|
|
189
|
+
const entry = readStoredCredential(providerId, join(agentDir, AUTH_FILE_NAME));
|
|
190
|
+
if (entry?.type === "oauth" && typeof entry.access === "string" && entry.access.trim()) {
|
|
191
|
+
const meta = decodeRefreshMeta(typeof entry.refresh === "string" ? entry.refresh : undefined);
|
|
192
|
+
return {
|
|
193
|
+
apiKey: entry.access.trim(),
|
|
194
|
+
baseUrl: meta?.baseUrl,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (entry?.type === "api_key" && typeof entry.key === "string" && entry.key.trim()) {
|
|
199
|
+
return { apiKey: entry.key.trim() };
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function resolveIdentity(): ResolvedIdentity {
|
|
205
|
+
return {
|
|
206
|
+
providerId: firstNonEmpty(process.env.CLIPROXYAPI_PROVIDER_ID, DEFAULT_PROVIDER_ID)!,
|
|
207
|
+
providerName: firstNonEmpty(process.env.CLIPROXYAPI_PROVIDER_NAME, DEFAULT_PROVIDER_NAME)!,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve connection settings.
|
|
213
|
+
* Priority: env > auth.json (/login) > default baseUrl
|
|
214
|
+
*/
|
|
215
|
+
export function resolveConnection(agentDir: string, providerId: string): ResolvedConnection | null {
|
|
216
|
+
let auth: { baseUrl?: string; apiKey?: string } | null = null;
|
|
217
|
+
try {
|
|
218
|
+
auth = loadAuthConnection(agentDir, providerId);
|
|
219
|
+
} catch {
|
|
220
|
+
auth = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const baseUrlInput = firstNonEmpty(
|
|
224
|
+
process.env.CLIPROXYAPI_BASE_URL,
|
|
225
|
+
auth?.baseUrl,
|
|
226
|
+
DEFAULT_BASE_URL,
|
|
227
|
+
)!;
|
|
228
|
+
const apiKey = firstNonEmpty(process.env.CLIPROXYAPI_API_KEY, auth?.apiKey);
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const endpoints = resolveEndpoints(baseUrlInput);
|
|
234
|
+
return {
|
|
235
|
+
baseUrlInput,
|
|
236
|
+
apiKey,
|
|
237
|
+
inferenceBaseUrl: endpoints.inferenceBaseUrl,
|
|
238
|
+
modelsUrl: endpoints.modelsUrl,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function extractReasoningEfforts(model: CodexClientModel): string[] {
|
|
243
|
+
const raw = model.supported_reasoning_levels ?? [];
|
|
244
|
+
const efforts: string[] = [];
|
|
245
|
+
for (const entry of raw) {
|
|
246
|
+
const effort =
|
|
247
|
+
typeof entry === "string" ? entry : typeof entry?.effort === "string" ? entry.effort : "";
|
|
248
|
+
const normalized = effort.trim().toLowerCase();
|
|
249
|
+
if (!normalized) continue;
|
|
250
|
+
if (!efforts.includes(normalized)) {
|
|
251
|
+
efforts.push(normalized);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return efforts;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function buildThinkingLevelMap(efforts: string[]): ThinkingLevelMap | undefined {
|
|
258
|
+
if (efforts.length === 0) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const supported = new Set(efforts);
|
|
263
|
+
const map: ThinkingLevelMap = {};
|
|
264
|
+
|
|
265
|
+
for (const level of PI_THINKING_LEVELS) {
|
|
266
|
+
if (level === "off") {
|
|
267
|
+
map.off = supported.has("none") ? "none" : null;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
map[level] = supported.has(level) ? level : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return map;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function buildInputModalities(model: CodexClientModel): Array<"text" | "image"> {
|
|
277
|
+
const raw = model.input_modalities ?? [];
|
|
278
|
+
const input: Array<"text" | "image"> = [];
|
|
279
|
+
for (const modality of raw) {
|
|
280
|
+
if (typeof modality !== "string") continue;
|
|
281
|
+
const value = modality.trim().toLowerCase();
|
|
282
|
+
if ((value === "text" || value === "image") && !input.includes(value)) {
|
|
283
|
+
input.push(value);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!input.includes("text")) {
|
|
287
|
+
input.unshift("text");
|
|
288
|
+
}
|
|
289
|
+
return input;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function codexModelId(model: CodexClientModel): string {
|
|
293
|
+
return (model.slug ?? model.id ?? "").trim();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function toPiModel(
|
|
297
|
+
model: CodexClientModel,
|
|
298
|
+
costCatalog?: ModelsDevCostCatalog,
|
|
299
|
+
): ProviderModelConfig | null {
|
|
300
|
+
const id = codexModelId(model);
|
|
301
|
+
if (!id) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
if (typeof model.visibility === "string" && model.visibility.toLowerCase() === "hide") {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const efforts = extractReasoningEfforts(model);
|
|
309
|
+
const hasReasoning = efforts.some((effort) => effort !== "none");
|
|
310
|
+
const contextWindow =
|
|
311
|
+
(typeof model.context_window === "number" && model.context_window > 0
|
|
312
|
+
? model.context_window
|
|
313
|
+
: undefined) ??
|
|
314
|
+
(typeof model.max_context_window === "number" && model.max_context_window > 0
|
|
315
|
+
? model.max_context_window
|
|
316
|
+
: undefined) ??
|
|
317
|
+
DEFAULT_CONTEXT_WINDOW;
|
|
318
|
+
|
|
319
|
+
const cost = costCatalog ? matchModelCost(id, costCatalog) : { ...ZERO_COST };
|
|
320
|
+
|
|
321
|
+
const maxTokens =
|
|
322
|
+
(typeof model.max_tokens === "number" &&
|
|
323
|
+
Number.isFinite(model.max_tokens) &&
|
|
324
|
+
model.max_tokens > 0
|
|
325
|
+
? model.max_tokens
|
|
326
|
+
: undefined) ??
|
|
327
|
+
(typeof model.max_output_tokens === "number" &&
|
|
328
|
+
Number.isFinite(model.max_output_tokens) &&
|
|
329
|
+
model.max_output_tokens > 0
|
|
330
|
+
? model.max_output_tokens
|
|
331
|
+
: undefined) ??
|
|
332
|
+
(typeof model.max_completion_tokens === "number" &&
|
|
333
|
+
Number.isFinite(model.max_completion_tokens) &&
|
|
334
|
+
model.max_completion_tokens > 0
|
|
335
|
+
? model.max_completion_tokens
|
|
336
|
+
: undefined) ??
|
|
337
|
+
DEFAULT_MAX_TOKENS;
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
id,
|
|
341
|
+
name: (model.display_name ?? model.name ?? id).trim() || id,
|
|
342
|
+
reasoning: hasReasoning,
|
|
343
|
+
input: buildInputModalities(model),
|
|
344
|
+
cost,
|
|
345
|
+
contextWindow,
|
|
346
|
+
maxTokens,
|
|
347
|
+
thinkingLevelMap: buildThinkingLevelMap(efforts),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** HTTP error from /v1/models (used to detect 401). */
|
|
352
|
+
export class ModelsHttpError extends Error {
|
|
353
|
+
readonly status: number;
|
|
354
|
+
readonly statusText: string;
|
|
355
|
+
|
|
356
|
+
constructor(status: number, statusText: string, body: string) {
|
|
357
|
+
super(
|
|
358
|
+
`models request failed: ${status} ${statusText}${body ? ` body=${body.slice(0, 200)}` : ""}`,
|
|
359
|
+
);
|
|
360
|
+
this.name = "ModelsHttpError";
|
|
361
|
+
this.status = status;
|
|
362
|
+
this.statusText = statusText;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function isUnauthorizedModelsError(error: unknown): boolean {
|
|
367
|
+
return error instanceof ModelsHttpError && error.status === 401;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function fetchCodexModels(
|
|
371
|
+
modelsUrl: string,
|
|
372
|
+
apiKey: string,
|
|
373
|
+
timeoutMs = MODELS_REQUEST_TIMEOUT_MS,
|
|
374
|
+
signal?: AbortSignal,
|
|
375
|
+
): Promise<CodexClientModel[]> {
|
|
376
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
377
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
378
|
+
const response = await fetch(modelsUrl, {
|
|
379
|
+
headers: {
|
|
380
|
+
Authorization: `Bearer ${apiKey}`,
|
|
381
|
+
Accept: "application/json",
|
|
382
|
+
},
|
|
383
|
+
signal: requestSignal,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Login validation only requires HTTP 200; non-2xx means credentials/baseUrl failed.
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
const body = await response.text().catch(() => "");
|
|
389
|
+
throw new ModelsHttpError(response.status, response.statusText, body);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Status 200 is enough for success, even when the catalog is empty or non-JSON.
|
|
393
|
+
let payload: unknown;
|
|
394
|
+
try {
|
|
395
|
+
payload = await response.json();
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (Array.isArray(payload)) {
|
|
401
|
+
return payload as CodexClientModel[];
|
|
402
|
+
}
|
|
403
|
+
if (payload && typeof payload === "object") {
|
|
404
|
+
const obj = payload as CodexClientModelsResponse;
|
|
405
|
+
if (Array.isArray(obj.models)) {
|
|
406
|
+
return obj.models;
|
|
407
|
+
}
|
|
408
|
+
if (Array.isArray(obj.data)) {
|
|
409
|
+
return obj.data;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const MODEL_NAMESPACE_PREFIX =
|
|
416
|
+
/^(openai|anthropic|google(?:-vertex)?|xai|deepseek|mistral|cohere|zhipuai|moonshotai|minimax|meta)[/:.]/i;
|
|
417
|
+
|
|
418
|
+
const MODEL_PROVIDER_PREFERENCES: Array<{ pattern: RegExp; providers: string[] }> = [
|
|
419
|
+
{
|
|
420
|
+
pattern: /^(?:gpt-|o[134](?:-|$)|chatgpt-|codex-)/,
|
|
421
|
+
providers: ["openai", "openai-codex", "opencode"],
|
|
422
|
+
},
|
|
423
|
+
{ pattern: /^claude-/, providers: ["anthropic"] },
|
|
424
|
+
{ pattern: /^(?:gemini-|gemma-)/, providers: ["google", "google-vertex"] },
|
|
425
|
+
{ pattern: /^grok-/, providers: ["xai"] },
|
|
426
|
+
{ pattern: /^deepseek-/, providers: ["deepseek"] },
|
|
427
|
+
{ pattern: /^mistral-/, providers: ["mistral"] },
|
|
428
|
+
{ pattern: /^command-/, providers: ["cohere"] },
|
|
429
|
+
{ pattern: /^glm-/, providers: ["zhipuai"] },
|
|
430
|
+
{ pattern: /^(?:kimi-|moonshot-)/, providers: ["moonshotai"] },
|
|
431
|
+
{ pattern: /^minimax-/, providers: ["minimax"] },
|
|
432
|
+
{ pattern: /^llama-/, providers: ["meta"] },
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
/** Explicit aliases for proxy-specific model ids whose billable base model is known. */
|
|
436
|
+
const MODEL_PRICE_ALIASES: Record<string, string[]> = {
|
|
437
|
+
"gemini-pro-agent": ["gemini-3.1-pro-preview"],
|
|
438
|
+
"gemini-3.1-pro-low": ["gemini-3.1-pro-preview"],
|
|
439
|
+
"gemini-3.6-flash-high": ["gemini-3.6-flash"],
|
|
440
|
+
"gemini-3-flash-agent": ["gemini-3.5-flash"],
|
|
441
|
+
"grok-composer-2.5-fast": ["grok-4.3"],
|
|
442
|
+
"grok-3-mini": ["xai/grok-3-mini"],
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
446
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
447
|
+
? (value as Record<string, unknown>)
|
|
448
|
+
: undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
452
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function readCostRate(
|
|
456
|
+
source: Record<string, unknown>,
|
|
457
|
+
key: "input" | "output" | "cacheRead" | "cacheWrite",
|
|
458
|
+
fallback: number,
|
|
459
|
+
): number {
|
|
460
|
+
const rawKey = key === "cacheRead" ? "cache_read" : key === "cacheWrite" ? "cache_write" : key;
|
|
461
|
+
return finiteNumber(source[key] ?? source[rawKey]) ?? fallback;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function parseModelsDevCost(raw: ModelsDevCostPayload | undefined): ModelCost | undefined {
|
|
465
|
+
if (!raw) return undefined;
|
|
466
|
+
const source = raw as Record<string, unknown>;
|
|
467
|
+
const input = finiteNumber(source.input);
|
|
468
|
+
const output = finiteNumber(source.output);
|
|
469
|
+
if (input === undefined && output === undefined) return undefined;
|
|
470
|
+
|
|
471
|
+
const cost: ModelCost = {
|
|
472
|
+
input: input ?? 0,
|
|
473
|
+
output: output ?? 0,
|
|
474
|
+
cacheRead: readCostRate(source, "cacheRead", 0),
|
|
475
|
+
cacheWrite: readCostRate(source, "cacheWrite", 0),
|
|
476
|
+
};
|
|
477
|
+
const tiers = new Map<number, ModelCostTier>();
|
|
478
|
+
|
|
479
|
+
const addTier = (rawTier: unknown, fallbackThreshold?: number): void => {
|
|
480
|
+
const tierSource = asRecord(rawTier);
|
|
481
|
+
if (!tierSource) return;
|
|
482
|
+
const descriptor = asRecord(tierSource.tier);
|
|
483
|
+
if (descriptor?.type !== undefined && descriptor.type !== "context") return;
|
|
484
|
+
const threshold =
|
|
485
|
+
finiteNumber(tierSource.inputTokensAbove) ??
|
|
486
|
+
finiteNumber(descriptor?.size) ??
|
|
487
|
+
fallbackThreshold;
|
|
488
|
+
if (threshold === undefined || threshold <= 0) return;
|
|
489
|
+
tiers.set(threshold, {
|
|
490
|
+
input: readCostRate(tierSource, "input", cost.input),
|
|
491
|
+
output: readCostRate(tierSource, "output", cost.output),
|
|
492
|
+
cacheRead: readCostRate(tierSource, "cacheRead", cost.cacheRead),
|
|
493
|
+
cacheWrite: readCostRate(tierSource, "cacheWrite", cost.cacheWrite),
|
|
494
|
+
inputTokensAbove: threshold,
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
if (Array.isArray(source.tiers)) {
|
|
499
|
+
for (const tier of source.tiers) addTier(tier);
|
|
500
|
+
}
|
|
501
|
+
if (tiers.size === 0) {
|
|
502
|
+
// Older models.dev records may expose only this compatibility shortcut.
|
|
503
|
+
addTier(source.context_over_200k, 200000);
|
|
504
|
+
}
|
|
505
|
+
if (tiers.size > 0) {
|
|
506
|
+
cost.tiers = Array.from(tiers.values()).toSorted(
|
|
507
|
+
(a, b) => a.inputTokensAbove - b.inputTokensAbove,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
return cost;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function cloneCost(cost: ModelCost): ModelCost {
|
|
514
|
+
return {
|
|
515
|
+
input: cost.input,
|
|
516
|
+
output: cost.output,
|
|
517
|
+
cacheRead: cost.cacheRead,
|
|
518
|
+
cacheWrite: cost.cacheWrite,
|
|
519
|
+
...(cost.tiers ? { tiers: cost.tiers.map((tier) => ({ ...tier })) } : {}),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function stripModelNamespace(modelId: string): string {
|
|
524
|
+
return modelId.trim().toLowerCase().replace(MODEL_NAMESPACE_PREFIX, "");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function normalizeModelKey(modelId: string): string {
|
|
528
|
+
return stripModelNamespace(modelId).replace(/[^a-z0-9]/g, "");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function uniqueStrings(values: string[]): string[] {
|
|
532
|
+
return Array.from(new Set(values.filter((value) => value.length > 0)));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function preferredProvidersForModel(modelId: string): string[] {
|
|
536
|
+
const normalizedId = stripModelNamespace(modelId);
|
|
537
|
+
const namespace = modelId.trim().toLowerCase().match(MODEL_NAMESPACE_PREFIX)?.[1];
|
|
538
|
+
const familyProviders =
|
|
539
|
+
MODEL_PROVIDER_PREFERENCES.find(({ pattern }) => pattern.test(normalizedId))?.providers ?? [];
|
|
540
|
+
return uniqueStrings([namespace ?? "", ...familyProviders]);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function addCatalogEntry(
|
|
544
|
+
catalog: Map<string, ModelsDevCostEntry[]>,
|
|
545
|
+
key: string,
|
|
546
|
+
entry: ModelsDevCostEntry,
|
|
547
|
+
): void {
|
|
548
|
+
if (!key) return;
|
|
549
|
+
const entries = catalog.get(key) ?? [];
|
|
550
|
+
if (
|
|
551
|
+
!entries.some(
|
|
552
|
+
(candidate) =>
|
|
553
|
+
candidate.providerId === entry.providerId && candidate.modelId === entry.modelId,
|
|
554
|
+
)
|
|
555
|
+
) {
|
|
556
|
+
entries.push(entry);
|
|
557
|
+
catalog.set(key, entries);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function addModelsDevEntry(catalog: ModelsDevCostCatalog, entry: ModelsDevCostEntry): void {
|
|
562
|
+
const rawId = entry.modelId.trim().toLowerCase();
|
|
563
|
+
const strippedId = stripModelNamespace(rawId);
|
|
564
|
+
for (const key of uniqueStrings([rawId, strippedId])) {
|
|
565
|
+
addCatalogEntry(catalog.exact, key, entry);
|
|
566
|
+
}
|
|
567
|
+
addCatalogEntry(catalog.normalized, normalizeModelKey(rawId), entry);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function sameCostVariants(entries: ModelsDevCostEntry[]): boolean {
|
|
571
|
+
const fingerprints = new Set(entries.map((entry) => JSON.stringify(entry.standard)));
|
|
572
|
+
return fingerprints.size === 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function selectModelsDevEntry(
|
|
576
|
+
entries: ModelsDevCostEntry[],
|
|
577
|
+
modelId: string,
|
|
578
|
+
): ModelsDevCostEntry | undefined {
|
|
579
|
+
if (entries.length === 0) return undefined;
|
|
580
|
+
const preferredProviders = preferredProvidersForModel(modelId);
|
|
581
|
+
for (const providerId of preferredProviders) {
|
|
582
|
+
const match = entries.find((entry) => entry.providerId === providerId);
|
|
583
|
+
if (match) return match;
|
|
584
|
+
}
|
|
585
|
+
if (entries.length === 1 || sameCostVariants(entries)) {
|
|
586
|
+
return entries.toSorted((a, b) => a.providerId.localeCompare(b.providerId))[0];
|
|
587
|
+
}
|
|
588
|
+
// Do not silently pick an arbitrary reseller price when the source is ambiguous.
|
|
589
|
+
return undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function findDirectModelsDevEntry(
|
|
593
|
+
modelId: string,
|
|
594
|
+
catalog: ModelsDevCostCatalog,
|
|
595
|
+
): ModelsDevCostEntry | undefined {
|
|
596
|
+
const rawId = modelId.trim().toLowerCase();
|
|
597
|
+
const exactKeys = uniqueStrings([rawId, stripModelNamespace(rawId)]);
|
|
598
|
+
for (const key of exactKeys) {
|
|
599
|
+
const match = selectModelsDevEntry(catalog.exact.get(key) ?? [], modelId);
|
|
600
|
+
if (match) return match;
|
|
601
|
+
}
|
|
602
|
+
return selectModelsDevEntry(catalog.normalized.get(normalizeModelKey(rawId)) ?? [], modelId);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function findModelsDevEntry(
|
|
606
|
+
modelId: string,
|
|
607
|
+
catalog: ModelsDevCostCatalog,
|
|
608
|
+
): ModelsDevCostEntry | undefined {
|
|
609
|
+
const rawId = modelId.trim().toLowerCase();
|
|
610
|
+
const lookupIds = uniqueStrings([rawId, ...(MODEL_PRICE_ALIASES[rawId] ?? [])]);
|
|
611
|
+
for (const lookupId of lookupIds) {
|
|
612
|
+
const match = findDirectModelsDevEntry(lookupId, catalog);
|
|
613
|
+
if (match) return match;
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
interface ModelsDevCacheFile {
|
|
619
|
+
timestamp: number;
|
|
620
|
+
providers: Record<string, unknown>;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function getModelsDevCachePath(agentDir?: string): string {
|
|
624
|
+
if (agentDir?.trim()) {
|
|
625
|
+
return join(agentDir, "tmp", "models-dev-cache.json");
|
|
626
|
+
}
|
|
627
|
+
return join(tmpdir(), "pi-cliproxyapi-models-dev-cache.json");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function readModelsDevCacheFile(cachePath: string): ModelsDevCacheFile | null {
|
|
631
|
+
try {
|
|
632
|
+
const raw = readFileSync(cachePath, "utf8");
|
|
633
|
+
const parsed = asRecord(JSON.parse(raw));
|
|
634
|
+
if (!parsed || typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp))
|
|
635
|
+
return null;
|
|
636
|
+
const providers = asRecord(parsed.providers);
|
|
637
|
+
if (!providers || !isModelsDevProviders(providers)) return null;
|
|
638
|
+
return { timestamp: parsed.timestamp, providers };
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function writeModelsDevCacheFile(cachePath: string, providers: Record<string, unknown>): void {
|
|
645
|
+
try {
|
|
646
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
647
|
+
const payload: ModelsDevCacheFile = {
|
|
648
|
+
timestamp: Date.now(),
|
|
649
|
+
providers,
|
|
650
|
+
};
|
|
651
|
+
writeFileSync(cachePath, JSON.stringify(payload), "utf8");
|
|
652
|
+
} catch {
|
|
653
|
+
// Ignore write failure (e.g. read-only filesystem)
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function isModelsDevProviders(value: Record<string, unknown>): boolean {
|
|
658
|
+
return Object.values(value).some((providerValue) => {
|
|
659
|
+
const provider = asRecord(providerValue);
|
|
660
|
+
return asRecord(provider?.models) !== undefined;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function buildCatalogFromProviders(providers: Record<string, unknown>): ModelsDevCostCatalog {
|
|
665
|
+
const catalog: ModelsDevCostCatalog = { exact: new Map(), normalized: new Map() };
|
|
666
|
+
for (const [providerId, providerValue] of Object.entries(providers)) {
|
|
667
|
+
const provider = asRecord(providerValue);
|
|
668
|
+
const models = asRecord(provider?.models);
|
|
669
|
+
if (!models) continue;
|
|
670
|
+
for (const [modelId, modelValue] of Object.entries(models)) {
|
|
671
|
+
const model = asRecord(modelValue) as ModelsDevModelPayload | undefined;
|
|
672
|
+
const standard = parseModelsDevCost(model?.cost);
|
|
673
|
+
if (!standard) continue;
|
|
674
|
+
addModelsDevEntry(catalog, { providerId, modelId, standard });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return catalog;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function fetchModelsDevCostMap(
|
|
681
|
+
agentDir?: string,
|
|
682
|
+
forceRefresh = false,
|
|
683
|
+
signal?: AbortSignal,
|
|
684
|
+
): Promise<ModelsDevCostCatalog> {
|
|
685
|
+
const cachePath = getModelsDevCachePath(agentDir);
|
|
686
|
+
const cached = readModelsDevCacheFile(cachePath);
|
|
687
|
+
|
|
688
|
+
if (!forceRefresh && cached && Date.now() - cached.timestamp < MODELS_DEV_CACHE_TTL_MS) {
|
|
689
|
+
return buildCatalogFromProviders(cached.providers);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const controller = new AbortController();
|
|
693
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
694
|
+
const requestSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
695
|
+
try {
|
|
696
|
+
const response = await fetch("https://models.dev/api.json", { signal: requestSignal });
|
|
697
|
+
if (response.ok) {
|
|
698
|
+
const providers = asRecord(await response.json());
|
|
699
|
+
if (providers && isModelsDevProviders(providers)) {
|
|
700
|
+
writeModelsDevCacheFile(cachePath, providers);
|
|
701
|
+
return buildCatalogFromProviders(providers);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
// Retain stale cache if network/JSON fails
|
|
706
|
+
} finally {
|
|
707
|
+
clearTimeout(timeoutId);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (cached) {
|
|
711
|
+
return buildCatalogFromProviders(cached.providers);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return { exact: new Map(), normalized: new Map() };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function matchModelCost(modelId: string, costCatalog: ModelsDevCostCatalog): ModelCost {
|
|
718
|
+
const entry = findModelsDevEntry(modelId, costCatalog);
|
|
719
|
+
if (!entry) return { ...ZERO_COST };
|
|
720
|
+
return cloneCost(entry.standard);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export async function loadMappedModels(
|
|
724
|
+
baseUrlInput: string,
|
|
725
|
+
apiKey: string,
|
|
726
|
+
timeoutMs = MODELS_REQUEST_TIMEOUT_MS,
|
|
727
|
+
agentDir?: string,
|
|
728
|
+
signal?: AbortSignal,
|
|
729
|
+
): Promise<MappedModels> {
|
|
730
|
+
const endpoints = resolveEndpoints(baseUrlInput);
|
|
731
|
+
const [remoteModels, costCatalog] = await Promise.all([
|
|
732
|
+
fetchCodexModels(endpoints.modelsUrl, apiKey, timeoutMs, signal),
|
|
733
|
+
fetchModelsDevCostMap(agentDir, false, signal),
|
|
734
|
+
]);
|
|
735
|
+
const models = remoteModels
|
|
736
|
+
.map((model) => toPiModel(model, costCatalog))
|
|
737
|
+
.filter((model): model is ProviderModelConfig => model !== null);
|
|
738
|
+
// Empty catalog is valid: credentials passed (HTTP 200), just no usable models yet.
|
|
739
|
+
return {
|
|
740
|
+
models,
|
|
741
|
+
inferenceBaseUrl: endpoints.inferenceBaseUrl,
|
|
742
|
+
modelsUrl: endpoints.modelsUrl,
|
|
743
|
+
};
|
|
744
|
+
}
|