@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.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.
- package/LICENSE +21 -0
- package/package.json +51 -4
- package/src/core/catalog.ts +400 -0
- package/src/core/connection.ts +119 -0
- package/src/core/costs.ts +477 -0
- package/src/core/index.ts +189 -0
- package/src/core/oauth.ts +279 -0
- package/src/core/streams.ts +89 -0
- package/src/core/usage.ts +813 -0
- package/src/extension/completions.ts +20 -0
- package/src/extension/config.ts +314 -0
- package/src/extension/fast.ts +81 -0
- package/src/extension/files.ts +146 -0
- package/src/extension/footer.ts +229 -0
- package/src/extension/index.ts +477 -0
- package/src/extension/oauth.ts +203 -0
- package/src/extension/pause.ts +126 -0
- package/src/extension/prices.ts +205 -0
- package/src/extension/prompt-cache.ts +17 -0
- package/src/extension/provider.ts +386 -0
- package/src/extension/retry.ts +41 -0
- package/src/extension/status.ts +166 -0
- package/src/extension/tps.ts +215 -0
- package/src/extension/usage.ts +609 -0
- package/src/extension/web-search.ts +307 -0
- package/src/web-search/endpoint.ts +21 -0
- package/src/web-search/index.ts +21 -0
- package/src/web-search/model-selector.ts +93 -0
- package/src/web-search/search.ts +571 -0
- package/src/web-search/sse.ts +144 -0
- package/README.md +0 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
pollOAuthStatus,
|
|
8
|
+
startOAuth,
|
|
9
|
+
submitOAuthCallback,
|
|
10
|
+
} from "../core/index.ts";
|
|
11
|
+
import { completeFrom } from "./completions.ts";
|
|
12
|
+
import { MANAGEMENT_KEY_ENV } from "./usage.ts";
|
|
13
|
+
import type { UsageTracker } from "./usage.ts";
|
|
14
|
+
|
|
15
|
+
export interface RegisterOAuthLoginOptions {
|
|
16
|
+
providerId: string;
|
|
17
|
+
tracker: UsageTracker;
|
|
18
|
+
/** Refreshes the model catalog the way /cliproxyapi-refresh does. */
|
|
19
|
+
refresh: (ctx: ExtensionContext) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Built-in CLIProxyAPI login providers. Plugins can add more ids. */
|
|
23
|
+
const LOGIN_PROVIDERS = [
|
|
24
|
+
"claude",
|
|
25
|
+
"codex",
|
|
26
|
+
"antigravity",
|
|
27
|
+
"kimi",
|
|
28
|
+
"kimi-ai",
|
|
29
|
+
"xai",
|
|
30
|
+
"devin",
|
|
31
|
+
"meta",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const KNOWN_PROVIDERS = `${LOGIN_PROVIDERS.join(", ")}, or a plugin provider id`;
|
|
35
|
+
|
|
36
|
+
const PROVIDER_WITH_SPACE = /\s/u;
|
|
37
|
+
|
|
38
|
+
/** The provider argument, or a picked one when bare with a UI. `undefined` means the picker was cancelled. */
|
|
39
|
+
const chooseProvider = async (
|
|
40
|
+
args: string,
|
|
41
|
+
ctx: ExtensionContext
|
|
42
|
+
): Promise<string | undefined> => {
|
|
43
|
+
const provider = args.trim();
|
|
44
|
+
|
|
45
|
+
if (provider || !ctx.hasUI) {
|
|
46
|
+
return provider;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return await ctx.ui.select("CLIProxyAPI account provider", [
|
|
50
|
+
...LOGIN_PROVIDERS,
|
|
51
|
+
]);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Registers /cliproxyapi-login <provider>: starts an upstream account login
|
|
56
|
+
* on the CLIProxyAPI server, asks for the browser's redirect URL, submits
|
|
57
|
+
* it, and waits for the server to finish the credential exchange.
|
|
58
|
+
*/
|
|
59
|
+
export const registerOAuthLogin = (
|
|
60
|
+
pi: ExtensionAPI,
|
|
61
|
+
options: RegisterOAuthLoginOptions
|
|
62
|
+
): void => {
|
|
63
|
+
pi.registerCommand("cliproxyapi-login", {
|
|
64
|
+
description: `Add an upstream account to CLIProxyAPI (/cliproxyapi-login <provider>: ${KNOWN_PROVIDERS}).`,
|
|
65
|
+
getArgumentCompletions: (prefix) => completeFrom(LOGIN_PROVIDERS, prefix),
|
|
66
|
+
handler: async (args, ctx) => {
|
|
67
|
+
// Check the setup first, so the picker never opens for a login that cannot run.
|
|
68
|
+
if (!options.tracker.hasManagementKey()) {
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
`CLIProxyAPI management key is not set. Set ${MANAGEMENT_KEY_ENV} or add "managementKey" to cliproxyapi.json, then run /cliproxyapi-login again.`,
|
|
71
|
+
"error"
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const access = options.tracker.managementAccess();
|
|
78
|
+
|
|
79
|
+
if (!access) {
|
|
80
|
+
ctx.ui.notify(
|
|
81
|
+
`CLIProxyAPI is not configured. Run /login ${options.providerId} first.`,
|
|
82
|
+
"error"
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const provider = await chooseProvider(args, ctx);
|
|
89
|
+
|
|
90
|
+
if (provider === undefined) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!provider || PROVIDER_WITH_SPACE.test(provider)) {
|
|
95
|
+
ctx.ui.notify(
|
|
96
|
+
`Usage: /cliproxyapi-login <provider> (${KNOWN_PROVIDERS})`,
|
|
97
|
+
"error"
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { baseUrl, managementKey } = access;
|
|
104
|
+
const { signal } = ctx;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const start = await startOAuth({
|
|
108
|
+
baseUrl,
|
|
109
|
+
managementKey,
|
|
110
|
+
provider,
|
|
111
|
+
signal,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ctx.ui.notify(
|
|
115
|
+
`Open this URL in a browser to sign in to ${provider}:\n${start.url}`,
|
|
116
|
+
"info"
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (start.flow === "device") {
|
|
120
|
+
// Device logins (Kimi, xAI, Meta) never redirect back: the server
|
|
121
|
+
// polls the provider itself, so only the status needs polling.
|
|
122
|
+
if (start.userCode) {
|
|
123
|
+
ctx.ui.notify(`Enter this code: ${start.userCode}`, "info");
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
const redirectUrl = await ctx.ui.input(
|
|
127
|
+
`Paste the full redirect URL for ${provider}`,
|
|
128
|
+
"http://127.0.0.1/callback?code=...&state=...",
|
|
129
|
+
signal ? { signal } : undefined
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (!redirectUrl?.trim()) {
|
|
133
|
+
ctx.ui.notify(
|
|
134
|
+
`CLIProxyAPI login for ${provider} was cancelled.`,
|
|
135
|
+
"warning"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await submitOAuthCallback({
|
|
142
|
+
baseUrl,
|
|
143
|
+
managementKey,
|
|
144
|
+
provider,
|
|
145
|
+
redirectUrl: redirectUrl.trim(),
|
|
146
|
+
signal,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
`Completing the ${provider} login with CLIProxyAPI...`,
|
|
152
|
+
"info"
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const result = await pollOAuthStatus({
|
|
156
|
+
baseUrl,
|
|
157
|
+
managementKey,
|
|
158
|
+
signal,
|
|
159
|
+
state: start.state,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (result.status !== "ok") {
|
|
163
|
+
ctx.ui.notify(
|
|
164
|
+
`CLIProxyAPI login for ${provider} failed: ${result.error ?? "the server reported an error"}`,
|
|
165
|
+
"error"
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await options.refresh(ctx);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
ctx.ui.notify(
|
|
175
|
+
`Added the ${provider} account to CLIProxyAPI, but refreshing models failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
176
|
+
"warning"
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ctx.ui.notify(
|
|
183
|
+
`Added the ${provider} account to CLIProxyAPI and refreshed the model catalog.`,
|
|
184
|
+
"info"
|
|
185
|
+
);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (signal?.aborted) {
|
|
188
|
+
ctx.ui.notify(
|
|
189
|
+
`CLIProxyAPI login for ${provider} was cancelled.`,
|
|
190
|
+
"warning"
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ctx.ui.notify(
|
|
197
|
+
`CLIProxyAPI login for ${provider} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
+
"error"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* oxlint-disable eslint/no-await-in-loop -- Pause polling must read each setting change before the next timed wait. */
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ExtensionAPI,
|
|
6
|
+
ExtensionCommandContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
import { loadCliproxyConfig, saveCliproxyConfig } from "./config.ts";
|
|
10
|
+
|
|
11
|
+
const POLL_INTERVAL_MS = 200;
|
|
12
|
+
|
|
13
|
+
export class PauseController {
|
|
14
|
+
private enabled: boolean;
|
|
15
|
+
private pauseStartedAt: number | undefined;
|
|
16
|
+
private pausedDuration = 0;
|
|
17
|
+
|
|
18
|
+
constructor(enabled = false) {
|
|
19
|
+
this.enabled = enabled;
|
|
20
|
+
|
|
21
|
+
if (enabled) {
|
|
22
|
+
this.pauseStartedAt = Date.now();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setEnabled(enabled: boolean, now = Date.now()): void {
|
|
27
|
+
if (enabled === this.enabled) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (enabled) {
|
|
32
|
+
this.pauseStartedAt = now;
|
|
33
|
+
} else if (this.pauseStartedAt !== undefined) {
|
|
34
|
+
this.pausedDuration += Math.max(0, now - this.pauseStartedAt);
|
|
35
|
+
this.pauseStartedAt = undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.enabled = enabled;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isEnabled(): boolean {
|
|
42
|
+
return this.enabled;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getPausedDuration(now = Date.now()): number {
|
|
46
|
+
return (
|
|
47
|
+
this.pausedDuration +
|
|
48
|
+
(this.pauseStartedAt === undefined
|
|
49
|
+
? 0
|
|
50
|
+
: Math.max(0, now - this.pauseStartedAt))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getElapsed(start: number, pausedAtStart: number, now = Date.now()): number {
|
|
55
|
+
return Math.max(
|
|
56
|
+
0,
|
|
57
|
+
now - start - Math.max(0, this.getPausedDuration(now) - pausedAtStart)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const waitForPauseToEnd = async (
|
|
63
|
+
agentDir: string,
|
|
64
|
+
pause: PauseController,
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
): Promise<void> => {
|
|
67
|
+
while (true) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
pause.setEnabled(loadCliproxyConfig(agentDir).pause ?? false);
|
|
72
|
+
} catch {
|
|
73
|
+
// Keep the last in-memory setting when the configuration cannot be read.
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!pause.isEnabled()) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await delay(POLL_INTERVAL_MS);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const registerPause = (
|
|
85
|
+
pi: ExtensionAPI,
|
|
86
|
+
agentDir: string,
|
|
87
|
+
pause: PauseController
|
|
88
|
+
): void => {
|
|
89
|
+
const setPause = (
|
|
90
|
+
enabled: boolean,
|
|
91
|
+
args: string,
|
|
92
|
+
ctx: ExtensionCommandContext
|
|
93
|
+
): void => {
|
|
94
|
+
if (args.trim()) {
|
|
95
|
+
ctx.ui.notify(`Usage: /${enabled ? "pause" : "continue"}`, "error");
|
|
96
|
+
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
saveCliproxyConfig(agentDir, { pause: enabled });
|
|
102
|
+
pause.setEnabled(enabled);
|
|
103
|
+
ctx.ui.notify(
|
|
104
|
+
enabled ? "Requests are paused." : "Requests are continued.",
|
|
105
|
+
"info"
|
|
106
|
+
);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
ctx.ui.notify(
|
|
109
|
+
`Failed to save pause mode: ${error instanceof Error ? error.message : String(error)}`,
|
|
110
|
+
"error"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
pi.registerCommand("pause", {
|
|
116
|
+
description: "Pause provider requests until /continue is used.",
|
|
117
|
+
handler: (args, ctx) => Promise.resolve(setPause(true, args, ctx)),
|
|
118
|
+
});
|
|
119
|
+
pi.registerCommand("continue", {
|
|
120
|
+
description: "Continue provider requests paused by /pause.",
|
|
121
|
+
handler: (args, ctx) => Promise.resolve(setPause(false, args, ctx)),
|
|
122
|
+
});
|
|
123
|
+
pi.on("before_provider_request", async (_event, ctx) => {
|
|
124
|
+
await waitForPauseToEnd(agentDir, pause, ctx.signal);
|
|
125
|
+
});
|
|
126
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { Static } from "typebox";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
costCatalogFromPrices,
|
|
8
|
+
DEFAULT_COSTS_URL,
|
|
9
|
+
fetchCostsConditional,
|
|
10
|
+
resolvePrices,
|
|
11
|
+
} from "../core/index.ts";
|
|
12
|
+
import type { CostCatalog } from "../core/index.ts";
|
|
13
|
+
import { readJsonMemo, writeJsonAtomic } from "./files.ts";
|
|
14
|
+
|
|
15
|
+
export const PRICES_FILE_NAME = "cliproxyapi-prices.json";
|
|
16
|
+
|
|
17
|
+
/** models.dev prices change rarely, so they are revalidated at most once a day. */
|
|
18
|
+
export const PRICES_TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const MINUTE_MS = 60_000;
|
|
21
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
22
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
23
|
+
|
|
24
|
+
const RateFields = {
|
|
25
|
+
cacheRead: Type.Number(),
|
|
26
|
+
cacheWrite: Type.Number(),
|
|
27
|
+
input: Type.Number(),
|
|
28
|
+
output: Type.Number(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** pi-ai's ModelCost. pi does not expose pi-ai's model-schema module to extensions, and typecheck holds this to ModelCost where the file feeds costCatalogFromPrices. */
|
|
32
|
+
const ModelCostSchema = Type.Object({
|
|
33
|
+
...RateFields,
|
|
34
|
+
tiers: Type.Optional(
|
|
35
|
+
Type.Array(Type.Object({ ...RateFields, inputTokensAbove: Type.Number() }))
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const PricesFileSchema = Type.Object({
|
|
40
|
+
etag: Type.Optional(Type.String()),
|
|
41
|
+
fetchedAt: Type.Number(),
|
|
42
|
+
prices: Type.Record(
|
|
43
|
+
Type.String(),
|
|
44
|
+
Type.Union([
|
|
45
|
+
Type.Null(),
|
|
46
|
+
Type.Object({
|
|
47
|
+
fast: Type.Optional(ModelCostSchema),
|
|
48
|
+
standard: ModelCostSchema,
|
|
49
|
+
}),
|
|
50
|
+
])
|
|
51
|
+
),
|
|
52
|
+
source: Type.String(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
type PricesFile = Static<typeof PricesFileSchema>;
|
|
56
|
+
|
|
57
|
+
export interface PriceCacheOptions {
|
|
58
|
+
agentDir: string;
|
|
59
|
+
/** Defaults to models.dev's api.json. */
|
|
60
|
+
costsUrl?: string;
|
|
61
|
+
fetchFn?: typeof fetch;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PriceLoad {
|
|
66
|
+
catalog: CostCatalog;
|
|
67
|
+
/** Set when models.dev failed. The text is ready to show the user. */
|
|
68
|
+
warning?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface PriceCacheInfo {
|
|
72
|
+
fetchedAt?: number;
|
|
73
|
+
etag?: string;
|
|
74
|
+
/** Catalog ids that have a price. */
|
|
75
|
+
count: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const plural = (count: number, unit: string): string =>
|
|
79
|
+
`${count} ${unit}${count === 1 ? "" : "s"}`;
|
|
80
|
+
|
|
81
|
+
/** "less than a minute", "5 minutes", "25 hours", "3 days". */
|
|
82
|
+
export const formatAge = (ageMs: number): string => {
|
|
83
|
+
if (ageMs < MINUTE_MS) {
|
|
84
|
+
return "less than a minute";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (ageMs < HOUR_MS) {
|
|
88
|
+
return plural(Math.floor(ageMs / MINUTE_MS), "minute");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (ageMs < 2 * DAY_MS) {
|
|
92
|
+
return plural(Math.floor(ageMs / HOUR_MS), "hour");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return plural(Math.floor(ageMs / DAY_MS), "day");
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Keeps the models.dev prices of the catalog's ids in
|
|
100
|
+
* `<agentDir>/cliproxyapi-prices.json`. Prices under 24 h old are used without
|
|
101
|
+
* a request. Older ones are revalidated with If-None-Match, so an unchanged
|
|
102
|
+
* catalog costs one 304 instead of a 5.9 MB download and parse.
|
|
103
|
+
*/
|
|
104
|
+
export class PriceCache {
|
|
105
|
+
private readonly filePath: string;
|
|
106
|
+
private readonly costsUrl: string;
|
|
107
|
+
private readonly fetchFn: typeof fetch | undefined;
|
|
108
|
+
private readonly now: () => number;
|
|
109
|
+
|
|
110
|
+
constructor(options: PriceCacheOptions) {
|
|
111
|
+
this.filePath = path.join(options.agentDir, PRICES_FILE_NAME);
|
|
112
|
+
this.costsUrl = options.costsUrl ?? DEFAULT_COSTS_URL;
|
|
113
|
+
this.fetchFn = options.fetchFn;
|
|
114
|
+
this.now = options.now ?? Date.now;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Prices for these catalog ids, fetching only when needed. Never throws for a models.dev failure. */
|
|
118
|
+
async load(ids: readonly string[], signal?: AbortSignal): Promise<PriceLoad> {
|
|
119
|
+
const file = this.read();
|
|
120
|
+
const now = this.now();
|
|
121
|
+
|
|
122
|
+
const complete =
|
|
123
|
+
file?.source === this.costsUrl && ids.every((id) => id in file.prices);
|
|
124
|
+
|
|
125
|
+
// A 304 cannot price an id the file lacks, so only a complete file is revalidated.
|
|
126
|
+
const revalidating = complete ? file : undefined;
|
|
127
|
+
|
|
128
|
+
if (revalidating) {
|
|
129
|
+
const age = now - revalidating.fetchedAt;
|
|
130
|
+
|
|
131
|
+
if (age >= 0 && age < PRICES_TTL_MS) {
|
|
132
|
+
return { catalog: costCatalogFromPrices(revalidating.prices) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await fetchCostsConditional({
|
|
137
|
+
etag: revalidating?.etag,
|
|
138
|
+
fetch: this.fetchFn,
|
|
139
|
+
signal,
|
|
140
|
+
url: this.costsUrl,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (result.status === "fresh") {
|
|
144
|
+
const prices = resolvePrices(ids, result.costs);
|
|
145
|
+
this.write({ etag: result.etag, fetchedAt: now, prices });
|
|
146
|
+
|
|
147
|
+
return { catalog: costCatalogFromPrices(prices) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (result.status === "not-modified" && revalidating) {
|
|
151
|
+
this.write({ ...revalidating, fetchedAt: now });
|
|
152
|
+
|
|
153
|
+
return { catalog: costCatalogFromPrices(revalidating.prices) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const reason = result.status === "failed" ? result.reason : "HTTP 304";
|
|
157
|
+
|
|
158
|
+
if (!file) {
|
|
159
|
+
return {
|
|
160
|
+
catalog: costCatalogFromPrices({}),
|
|
161
|
+
warning: `Couldn't load prices from models.dev (${reason}). Costs show $0 until prices load.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
catalog: costCatalogFromPrices(file.prices),
|
|
167
|
+
warning: `Couldn't update prices from models.dev (${reason}). Using prices from ${formatAge(Math.max(0, now - file.fetchedAt))} ago.`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The prices on disk, without any request. Undefined before the first successful load. */
|
|
172
|
+
cached(): CostCatalog | undefined {
|
|
173
|
+
const file = this.read();
|
|
174
|
+
|
|
175
|
+
return file ? costCatalogFromPrices(file.prices) : undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** For /cliproxyapi-status. Undefined before the first successful load. */
|
|
179
|
+
describe(): PriceCacheInfo | undefined {
|
|
180
|
+
const file = this.read();
|
|
181
|
+
|
|
182
|
+
if (!file) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
count: Object.values(file.prices).filter(Boolean).length,
|
|
188
|
+
etag: file.etag,
|
|
189
|
+
fetchedAt: file.fetchedAt,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A missing or damaged file reads as no prices, so the next load fetches and rewrites it. */
|
|
194
|
+
private read(): PricesFile | undefined {
|
|
195
|
+
try {
|
|
196
|
+
return readJsonMemo(this.filePath, PricesFileSchema);
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private write(file: Omit<PricesFile, "source">): void {
|
|
203
|
+
writeJsonAtomic(this.filePath, { ...file, source: this.costsUrl });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ModelPromptCache } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
|
3
|
+
|
|
4
|
+
const LIFETIMES = new Map<string, ModelPromptCache | undefined>(
|
|
5
|
+
getBuiltinModels("anthropic").map((model) => [model.id, model.promptCache])
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
/** pi-ai's own Anthropic lifetimes, matched on the id or its segment after the last "/". */
|
|
9
|
+
export const anthropicPromptCache = (
|
|
10
|
+
id: string
|
|
11
|
+
): ModelPromptCache | undefined => {
|
|
12
|
+
const modelId = id.trim().toLowerCase();
|
|
13
|
+
const segment = modelId.slice(modelId.lastIndexOf("/") + 1);
|
|
14
|
+
const lifetimes = LIFETIMES.get(modelId) ?? LIFETIMES.get(segment);
|
|
15
|
+
|
|
16
|
+
return lifetimes ? { ...lifetimes } : undefined;
|
|
17
|
+
};
|