@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.
@@ -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
+ };