pi-clinepass 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/src/errors.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ClinePass error classification — maps provider error text to friendly,
3
+ * actionable messages surfaced through pi's UI.
4
+ *
5
+ * Classification order matters: the free-model route returns plain
6
+ * `403 Forbidden` when its Cline-CLI header gate rejects us, which must not
7
+ * be reported as a subscription problem, and free-limit errors may arrive
8
+ * wrapped in a generic 403/429 shell.
9
+ */
10
+
11
+ import { isFreeDeepSeekModel } from "./headers.js";
12
+
13
+ export type ClinePassErrorType =
14
+ | "not_subscribed"
15
+ | "auth_expired"
16
+ | "rate_limited"
17
+ | "free_limit_reached"
18
+ | "free_route_forbidden"
19
+ | "unknown";
20
+
21
+ function matchesAny(text: string, patterns: string[]): boolean {
22
+ return patterns.some((p) => text.includes(p));
23
+ }
24
+
25
+ export const CLINEPASS_ERROR_MESSAGES: Record<ClinePassErrorType, string> = {
26
+ not_subscribed:
27
+ "ClinePass subscription required — or the organization account cannot use ClinePass. " +
28
+ "Visit app.cline.bot to subscribe / switch to your personal account, or run `pi /login`.",
29
+ auth_expired:
30
+ "ClinePass authentication expired. Run `pi /login` and select ClinePass to refresh credentials.",
31
+ rate_limited:
32
+ "ClinePass rate limit reached. Wait a moment and try again, or check your plan at app.cline.bot.",
33
+ free_limit_reached:
34
+ "Free model rate limit reached. Please wait a few moments and try again.",
35
+ free_route_forbidden:
36
+ "Free model route unavailable (HTTP 403). The free DeepSeek route is gated to Cline product " +
37
+ "surfaces — retry in a moment, or switch to a ClinePass model.",
38
+ unknown: "ClinePass request failed. Check your subscription at app.cline.bot or run `pi /login`.",
39
+ };
40
+
41
+ /**
42
+ * Classify a provider error string. `modelId` (the model of the failed
43
+ * request, e.g. "deepseek/deepseek-v4-flash") disambiguates 403s on the
44
+ * free route from subscription problems.
45
+ */
46
+ export function classifyClinePassError(
47
+ errorMessage: string,
48
+ modelId?: string,
49
+ ): {
50
+ type: ClinePassErrorType;
51
+ message: string;
52
+ } {
53
+ const lower = errorMessage.toLowerCase();
54
+
55
+ if (matchesAny(lower, ["401", "unauthorized", "invalid api key", "invalid_api_key"])) {
56
+ return { type: "auth_expired", message: CLINEPASS_ERROR_MESSAGES.auth_expired };
57
+ }
58
+ if (matchesAny(lower, ["429", "rate limit", "too many requests", "rate_limit"])) {
59
+ return { type: "rate_limited", message: CLINEPASS_ERROR_MESSAGES.rate_limited };
60
+ }
61
+ if (matchesAny(lower, ["free limit reached", "free limit", "try again in"])) {
62
+ return { type: "free_limit_reached", message: CLINEPASS_ERROR_MESSAGES.free_limit_reached };
63
+ }
64
+ if (
65
+ matchesAny(lower, [
66
+ "403",
67
+ "forbidden",
68
+ "subscription required",
69
+ "not subscribed",
70
+ "organization accounts cannot use",
71
+ ])
72
+ ) {
73
+ if (modelId && isFreeDeepSeekModel(modelId)) {
74
+ return { type: "free_route_forbidden", message: CLINEPASS_ERROR_MESSAGES.free_route_forbidden };
75
+ }
76
+ return { type: "not_subscribed", message: CLINEPASS_ERROR_MESSAGES.not_subscribed };
77
+ }
78
+ return { type: "unknown", message: CLINEPASS_ERROR_MESSAGES.unknown };
79
+ }
80
+
81
+ export interface ErrorContext {
82
+ hasUI: boolean;
83
+ ui: {
84
+ notify: (msg: string, type: "info" | "warning" | "error") => void;
85
+ };
86
+ model?: { provider?: string };
87
+ }
88
+
89
+ /** Handle a message_end event: classify ClinePass errors and notify. */
90
+ export function handleClinePassError(
91
+ event: { message: unknown },
92
+ ctx: ErrorContext,
93
+ ): void {
94
+ if (!event.message) return;
95
+ const msg = event.message as {
96
+ stopReason?: string;
97
+ errorMessage?: string;
98
+ provider?: string;
99
+ model?: string;
100
+ };
101
+ if (msg.stopReason !== "error" || !msg.errorMessage) return;
102
+
103
+ const provider = msg.provider ?? ctx.model?.provider;
104
+ if (provider !== "clinepass" && provider !== "cline-pass") return;
105
+
106
+ const { message: friendly } = classifyClinePassError(msg.errorMessage, msg.model);
107
+ if (ctx.hasUI) {
108
+ ctx.ui.notify(friendly, "error");
109
+ } else {
110
+ console.error(`[pi-clinepass] ${friendly}`);
111
+ }
112
+ }
package/src/headers.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Free-model request headers.
3
+ *
4
+ * The gateway gates the free `deepseek/deepseek-v4-flash` route behind
5
+ * "Cline product surfaces" (HTTP 403 without them). We identify as the
6
+ * Cline CLI with a current version fetched from the npm registry, cached
7
+ * for 24h with a bundled fallback. Only the free deepseek model is affected.
8
+ */
9
+
10
+ import { release } from "node:os";
11
+
12
+ export const FREE_DEEPSEEK_MODEL = "deepseek/deepseek-v4-flash";
13
+ const FALLBACK_CLINE_VERSION = "3.0.54";
14
+ const VERSION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
15
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org/cline/latest";
16
+
17
+ let cachedVersion: string | undefined;
18
+ let cachedAt = 0;
19
+ let inflightVersion: Promise<string> | undefined;
20
+
21
+ export async function getClineVersion(
22
+ fetchFn: typeof globalThis.fetch = globalThis.fetch,
23
+ ): Promise<string> {
24
+ if (cachedVersion && Date.now() - cachedAt < VERSION_CACHE_TTL_MS) return cachedVersion;
25
+ // Deduplicate concurrent callers (e.g. session_start + a command racing).
26
+ if (inflightVersion) return inflightVersion;
27
+ inflightVersion = (async () => {
28
+ try {
29
+ const res = await fetchFn(NPM_REGISTRY_URL);
30
+ if (res.ok) {
31
+ const data = (await res.json()) as { version?: unknown };
32
+ if (typeof data.version === "string" && data.version) {
33
+ cachedVersion = data.version;
34
+ cachedAt = Date.now();
35
+ return cachedVersion;
36
+ }
37
+ }
38
+ } catch {
39
+ // fall through to bundled default
40
+ }
41
+ // Registry unreachable: use the bundled fallback but do not cache it
42
+ // for the full TTL — the next session retries so a transient outage
43
+ // doesn't pin an outdated version for a day.
44
+ cachedVersion = FALLBACK_CLINE_VERSION;
45
+ cachedAt = 0;
46
+ return cachedVersion;
47
+ })();
48
+ try {
49
+ return await inflightVersion;
50
+ } finally {
51
+ inflightVersion = undefined;
52
+ }
53
+ }
54
+
55
+ export function isFreeDeepSeekModel(modelId: string): boolean {
56
+ return modelId.includes(FREE_DEEPSEEK_MODEL) && !modelId.includes("cline-pass/");
57
+ }
58
+
59
+ /** Whether the given model id needs the Cline-CLI identifying headers. */
60
+ export function needsFreeModelHeaders(modelId: string): boolean {
61
+ return isFreeDeepSeekModel(modelId);
62
+ }
63
+
64
+ /**
65
+ * Build the headers that make the free deepseek route servable.
66
+ * Synchronous: uses the cached version (pre-warmed at startup or from a
67
+ * previous request), falling back to the bundled default. Never fetches
68
+ * inside a request path.
69
+ */
70
+ export function buildFreeModelHeadersSync(): Record<string, string> {
71
+ const version = cachedVersion ?? FALLBACK_CLINE_VERSION;
72
+ return {
73
+ "x-client-type": "cli",
74
+ "x-client-version": version,
75
+ "x-core-version": version,
76
+ "x-platform": process.platform,
77
+ "x-platform-version": release(),
78
+ "user-agent": `Cline/${version}`,
79
+ };
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * pi-clinepass — ClinePass provider for pi.
3
+ *
4
+ * Registers the `clinepass` provider (13 paid models with measured billing
5
+ * prices + 3 free models) and wires the hooks that keep pi's numbers real:
6
+ * - message_end → server-truth cost meter + session total + error surface
7
+ * - before_provider_headers → free deepseek route headers
8
+ * - model_select / session_start → immediate meter + default model sync
9
+ * - /clinepass → price table + plan limit report
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { MODELS } from "./catalog.js";
14
+ import { DEFAULT_API_BASE, WORKOS_TOKEN_PREFIX } from "./workos.js";
15
+ import { getApiKey, login, refreshToken } from "./auth.js";
16
+ import {
17
+ getCapReport,
18
+ handleInitialMeter,
19
+ handleUsageTracking,
20
+ PROVIDER_NAME,
21
+ } from "./usage.js";
22
+ import { buildFreeModelHeadersSync, getClineVersion, needsFreeModelHeaders } from "./headers.js";
23
+ import { handleClinePassError } from "./errors.js";
24
+ import { savePiDefaultModel } from "./settings.js";
25
+
26
+ export default async function (pi: ExtensionAPI) {
27
+ pi.registerProvider(PROVIDER_NAME, {
28
+ name: "ClinePass",
29
+ baseUrl: `${DEFAULT_API_BASE}/api/v1`,
30
+ authHeader: true,
31
+ // ClinePass is OpenAI-compatible; pi's built-in openai-completions
32
+ // streaming handles SSE, tools, and usage. No custom streamSimple.
33
+ api: "openai-completions",
34
+ oauth: {
35
+ name: "ClinePass",
36
+ isSubscription: true,
37
+ login,
38
+ refreshToken,
39
+ getApiKey,
40
+ },
41
+ models: MODELS.map((model) => ({
42
+ ...model,
43
+ input: [...model.input],
44
+ })),
45
+ });
46
+
47
+ // Persist the per-turn server bill as a custom session entry so the
48
+ // session total survives resume/fork, then surface it in the status meter.
49
+ const writeCostEntry = (usage: { id: string; costUsd: number; model: string }): void => {
50
+ pi.appendEntry("clinepass-cost", {
51
+ usageId: usage.id,
52
+ costUsd: usage.costUsd,
53
+ model: usage.model,
54
+ });
55
+ };
56
+
57
+ pi.on("message_end", (event, ctx) => {
58
+ handleClinePassError(event, ctx);
59
+ // Billing tracking is queued in the background: message_end handlers
60
+ // are awaited inline by pi and gate message finalization + the agent
61
+ // loop, so polling the usage API (with its server flush delay) must
62
+ // never run on this path.
63
+ void handleUsageTracking(event, ctx, writeCostEntry);
64
+ });
65
+
66
+ // The free deepseek route requires Cline-CLI identifying headers. The
67
+ // version is pre-warmed at session_start (the factory must stay
68
+ // network-free) so the sync handler never fetches.
69
+ pi.on("before_provider_headers", (event, ctx) => {
70
+ const modelId = ctx.model?.id ?? "";
71
+ if (!needsFreeModelHeaders(modelId)) return;
72
+ Object.assign(event.headers, buildFreeModelHeadersSync());
73
+ });
74
+
75
+ pi.on("model_select", (event, ctx) => {
76
+ const { provider, id } = event.model;
77
+ const modelId = id.startsWith(`${provider}/`) ? id.slice(provider.length + 1) : id;
78
+ // Persist the global default only for explicit selections of our models:
79
+ // model cycling (Ctrl+P) and old-session restores must not rewrite the
80
+ // user's global default, and other providers manage their own settings.
81
+ if (event.source === "set" && (provider === PROVIDER_NAME || provider === "cline-pass")) {
82
+ void savePiDefaultModel(provider, modelId);
83
+ }
84
+ void handleInitialMeter(ctx);
85
+ });
86
+
87
+ pi.on("session_start", (_event, ctx) => {
88
+ // Pre-warm the Cline CLI version here instead of the factory: the
89
+ // factory runs for every invocation (including --list-models). The
90
+ // sync header builder falls back to the bundled version until this
91
+ // fetch completes.
92
+ void getClineVersion().catch(() => {});
93
+ void handleInitialMeter(ctx);
94
+ });
95
+
96
+ pi.registerCommand("clinepass", {
97
+ description: "Show ClinePass model rates and plan limit utilization",
98
+ handler: async (_args, ctx) => {
99
+ const report = await getCapReport();
100
+ if (ctx.hasUI && ctx.ui.notify) {
101
+ ctx.ui.notify(report, "info");
102
+ } else {
103
+ console.log(`\n${report}\n`);
104
+ }
105
+ },
106
+ });
107
+ }
108
+
109
+ export { MODELS, PROVIDER_NAME, WORKOS_TOKEN_PREFIX };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Persist the selected model as pi's global default (settings.json).
3
+ *
4
+ * pi keeps model selection session-scoped; syncing it here means new
5
+ * sessions start with the model actually used. Best-effort: never throws.
6
+ */
7
+
8
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ export const PI_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
13
+
14
+ export async function savePiDefaultModel(provider: string, modelId: string): Promise<void> {
15
+ try {
16
+ if (!existsSync(PI_SETTINGS_PATH)) return;
17
+ // pi's settings-manager holds `<path>.lock` while writing; wait briefly
18
+ // (without blocking the event loop) so we don't interleave with its
19
+ // read-modify-write cycle.
20
+ for (let attempt = 0; attempt < 5 && existsSync(`${PI_SETTINGS_PATH}.lock`); attempt++) {
21
+ await sleep(100);
22
+ }
23
+ const settings = JSON.parse(readFileSync(PI_SETTINGS_PATH, "utf8")) as Record<string, unknown>;
24
+ if (settings.defaultProvider === provider && settings.defaultModel === modelId) return;
25
+ settings.defaultProvider = provider;
26
+ settings.defaultModel = modelId;
27
+ atomicWrite(PI_SETTINGS_PATH, JSON.stringify(settings, null, 2));
28
+ } catch (err) {
29
+ console.warn(
30
+ `[pi-clinepass] failed to save default model: ${err instanceof Error ? err.message : String(err)}`,
31
+ );
32
+ }
33
+ }
34
+
35
+ function sleep(ms: number): Promise<void> {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+
39
+ function atomicWrite(filePath: string, data: string): void {
40
+ const tmp = `${filePath}.tmp`;
41
+ writeFileSync(tmp, data, "utf8");
42
+ // rename replaces atomically on POSIX; on Windows it may fail if the
43
+ // target is open — fall back to direct write in that case.
44
+ try {
45
+ renameSync(tmp, filePath);
46
+ } catch {
47
+ writeFileSync(filePath, data, "utf8");
48
+ }
49
+ }