pi-cliproxyapi 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/compat.ts ADDED
@@ -0,0 +1,128 @@
1
+ // Local fallback heuristics + helpers for when /.well-known/pi is not available.
2
+ // When /.well-known/pi succeeds, we trust the server. This module is the
3
+ // secondary classifier (/v1/models path) and a couple of small utilities.
4
+
5
+ import type { Api } from "@earendil-works/pi-ai";
6
+
7
+ import type { CustomProviderModelConfig } from "./config.ts";
8
+
9
+ /** API families we know how to route through CliProxyAPI. */
10
+ export const ALLOWED_APIS: ReadonlySet<Api> = new Set<Api>([
11
+ "openai-completions",
12
+ "openai-responses",
13
+ "anthropic-messages",
14
+ ]);
15
+
16
+ /**
17
+ * For openai-* APIs the proxy endpoint is "https://.../v1" — pass it through.
18
+ * For anthropic-messages the SDK expects the host root, so strip the trailing /v1.
19
+ */
20
+ export function baseUrlFor(api: Api, endpoint: string): string {
21
+ const trimmed = endpoint.replace(/\/+$/, "");
22
+ if (api === "anthropic-messages") {
23
+ return trimmed.replace(/\/v1$/, "");
24
+ }
25
+ return trimmed;
26
+ }
27
+
28
+ /** Match the well-known glob-ish excludes (server-defined patterns like "*:*"). */
29
+ export function isExcluded(id: string, patterns: string[]): boolean {
30
+ if (!id) return true;
31
+ for (const pat of patterns) {
32
+ const rx = new RegExp(
33
+ "^" + pat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
34
+ );
35
+ if (rx.test(id)) return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ /** Defaults used when the catalog has nothing on a model id. */
41
+ export const DEFAULT_CONTEXT_WINDOW = 128_000;
42
+ export const DEFAULT_MAX_TOKENS = 16_000;
43
+ export const DEFAULT_COST = {
44
+ input: 0,
45
+ output: 0,
46
+ cacheRead: 0,
47
+ cacheWrite: 0,
48
+ };
49
+
50
+ const REASONING_RX: RegExp[] = [
51
+ /.*-thinking$/,
52
+ /^gpt-5(\.\d+)?(-.*)?$/,
53
+ /^gemini-3(\.\d+)?(-.*)?$/,
54
+ ];
55
+
56
+ export function reasoningFromId(id: string): boolean {
57
+ return REASONING_RX.some((rx) => rx.test(id));
58
+ }
59
+
60
+ /** owned_by → (suggested provider slug, default api). */
61
+ const SUGGESTED_GROUPS: Array<[Set<string>, string, Api]> = [
62
+ [new Set(["zai"]), "glm", "openai-completions"],
63
+ [new Set(["Mistral"]), "mistral", "openai-completions"],
64
+ [new Set(["google", "antigravity"]), "gemini", "openai-completions"],
65
+ [new Set(["Ollama", "Ollama pay"]), "ollama", "openai-completions"],
66
+ [new Set(["Xiaomi"]), "mimo", "openai-completions"],
67
+ [new Set(["OpenRouter"]), "openrouter", "openai-completions"],
68
+ [new Set(["cerebras"]), "cerebras", "openai-completions"],
69
+ ];
70
+
71
+ export function withProviderPrefix(
72
+ prefix: string | undefined,
73
+ suffix: string,
74
+ ): string {
75
+ const p = (prefix ?? "").trim();
76
+ return p ? `${p}-${suffix}` : suffix;
77
+ }
78
+
79
+ /**
80
+ * Server may suggest names with a legacy prefix.
81
+ * Re-map them to the user's chosen prefix; if the user hasn't set one, drop
82
+ * the legacy prefix entirely so we never surface our private namespace.
83
+ */
84
+ export function normalizeSuggestedProvider(
85
+ suggestedProvider: string,
86
+ prefix: string | undefined,
87
+ ): string {
88
+ const p = (prefix ?? "").trim();
89
+ const m = suggestedProvider.match(/^([a-z0-9]+-)(.*)$/);
90
+ if (m) return p ? `${p}-${m[1]}` : m[1]!;
91
+ return suggestedProvider;
92
+ }
93
+
94
+ export function classifyCustom(
95
+ ownedBy: string,
96
+ prefix?: string,
97
+ ): { slug: string; api: Api } {
98
+ for (const [owners, suffix, api] of SUGGESTED_GROUPS) {
99
+ if (owners.has(ownedBy))
100
+ return { slug: withProviderPrefix(prefix, suffix), api };
101
+ }
102
+ return {
103
+ slug: withProviderPrefix(prefix, "misc"),
104
+ api: "openai-completions",
105
+ };
106
+ }
107
+
108
+ /** Make a friendly display name from an id like "glm-4.7" → "Glm 4.7". */
109
+ export function prettifyName(id: string): string {
110
+ return id
111
+ .replace(/[/:]/g, " ")
112
+ .split(/[-_]/)
113
+ .filter(Boolean)
114
+ .map((p) => p[0]!.toUpperCase() + p.slice(1))
115
+ .join(" ");
116
+ }
117
+
118
+ /** Build a CustomProviderModelConfig from a raw upstream model id + metadata. */
119
+ export function modelDefaults(id: string): CustomProviderModelConfig {
120
+ return {
121
+ id,
122
+ name: prettifyName(id),
123
+ contextWindow: DEFAULT_CONTEXT_WINDOW,
124
+ maxTokens: DEFAULT_MAX_TOKENS,
125
+ reasoning: reasoningFromId(id),
126
+ cost: { ...DEFAULT_COST },
127
+ };
128
+ }
package/src/config.ts ADDED
@@ -0,0 +1,199 @@
1
+ // ~/.config/pi-cliproxyapi/config.json — load, validate, persist.
2
+ //
3
+ // Schema:
4
+ //
5
+ // {
6
+ // "proxy": {
7
+ // "endpoint": "https://your-proxy.example.com/v1",
8
+ // "apiKey": "!cat ~/.config/pi-cliproxyapi/key",
9
+ // "providerPrefix": "myproxy",
10
+ // "usageKey": "!cat ~/.config/pi-cliproxyapi/usage-key"
11
+ // },
12
+ // "builtinProviders": {
13
+ // "openai": { "enabled": true, "apiOverride": null, "models": ["gpt-5.2"] },
14
+ // "anthropic": { "enabled": true, "models": ["claude-opus-4-7"] }
15
+ // },
16
+ // "customProviders": {
17
+ // "myproxy-glm": {
18
+ // "api": "openai-completions",
19
+ // "models": [{ "id": "glm-4.7", "name": "GLM 4.7", "contextWindow": 128000, "maxTokens": 16000, "reasoning": true }]
20
+ // }
21
+ // },
22
+ // "discoveryExcludes": ["*:*"],
23
+ // "overrides": {},
24
+ // "refreshIntervalMinutes": 0,
25
+ // "usageCacheTtlMs": 30000
26
+ // }
27
+
28
+ import { execSync } from "node:child_process";
29
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import { dirname, join } from "node:path";
32
+
33
+ import type { Api } from "@earendil-works/pi-ai";
34
+
35
+ import { log } from "./log.ts";
36
+
37
+ export const CONFIG_DIR = join(homedir(), ".config", "pi-cliproxyapi");
38
+ export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
39
+
40
+ export interface BuiltinProviderConfig {
41
+ enabled: boolean;
42
+ apiOverride?: Api | null;
43
+ models: string[];
44
+ }
45
+
46
+ export interface CustomProviderModelConfig {
47
+ id: string;
48
+ name?: string;
49
+ contextWindow?: number;
50
+ maxTokens?: number;
51
+ reasoning?: boolean;
52
+ cost?: {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ };
58
+ }
59
+
60
+ export interface CustomProviderConfig {
61
+ api: Api;
62
+ models: CustomProviderModelConfig[];
63
+ }
64
+
65
+ export interface ProxyConfig {
66
+ proxy: {
67
+ endpoint: string;
68
+ apiKey: string;
69
+ usageKey?: string;
70
+ /** Prefix used for default custom-provider slugs, e.g. `myproxy` -> `myproxy-glm`. */
71
+ providerPrefix?: string;
72
+ };
73
+ builtinProviders: Record<string, BuiltinProviderConfig>;
74
+ customProviders: Record<string, CustomProviderConfig>;
75
+ discoveryExcludes: string[];
76
+ overrides: Record<string, Partial<CustomProviderModelConfig>>;
77
+ refreshIntervalMinutes: number;
78
+ usageCacheTtlMs: number;
79
+ }
80
+
81
+ const DEFAULT_CONFIG: ProxyConfig = {
82
+ proxy: {
83
+ endpoint: "",
84
+ apiKey: "",
85
+ },
86
+ builtinProviders: {},
87
+ customProviders: {},
88
+ discoveryExcludes: ["*:*"],
89
+ overrides: {},
90
+ refreshIntervalMinutes: 0,
91
+ usageCacheTtlMs: 30_000,
92
+ };
93
+
94
+ export function loadConfig(): ProxyConfig {
95
+ if (!existsSync(CONFIG_PATH)) {
96
+ log.info("config not found, using defaults at", CONFIG_PATH);
97
+ return structuredClone(DEFAULT_CONFIG);
98
+ }
99
+ try {
100
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
101
+ return normalizeConfig(raw);
102
+ } catch (err) {
103
+ log.error("failed to read config:", err, "— using defaults");
104
+ return structuredClone(DEFAULT_CONFIG);
105
+ }
106
+ }
107
+
108
+ export function saveConfig(cfg: ProxyConfig): void {
109
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
110
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf8");
111
+ log.info("config saved to", CONFIG_PATH);
112
+ }
113
+
114
+ function normalizeConfig(raw: unknown): ProxyConfig {
115
+ if (!raw || typeof raw !== "object") return structuredClone(DEFAULT_CONFIG);
116
+ const r = raw as Record<string, unknown>;
117
+ const merged = structuredClone(DEFAULT_CONFIG);
118
+ const proxyBlock =
119
+ (r.proxy as Record<string, unknown> | undefined) ??
120
+ {};
121
+ merged.proxy.endpoint =
122
+ typeof proxyBlock.endpoint === "string"
123
+ ? proxyBlock.endpoint
124
+ : merged.proxy.endpoint;
125
+ merged.proxy.apiKey =
126
+ typeof proxyBlock.apiKey === "string" ? proxyBlock.apiKey : "";
127
+ if (typeof proxyBlock.usageKey === "string")
128
+ merged.proxy.usageKey = proxyBlock.usageKey;
129
+ if (
130
+ typeof proxyBlock.providerPrefix === "string" &&
131
+ proxyBlock.providerPrefix.trim()
132
+ ) {
133
+ merged.proxy.providerPrefix = proxyBlock.providerPrefix.trim();
134
+ }
135
+
136
+ if (r.builtinProviders && typeof r.builtinProviders === "object") {
137
+ merged.builtinProviders = r.builtinProviders as Record<
138
+ string,
139
+ BuiltinProviderConfig
140
+ >;
141
+ }
142
+ if (r.customProviders && typeof r.customProviders === "object") {
143
+ merged.customProviders = r.customProviders as Record<
144
+ string,
145
+ CustomProviderConfig
146
+ >;
147
+ }
148
+ if (Array.isArray(r.discoveryExcludes)) {
149
+ merged.discoveryExcludes = r.discoveryExcludes.filter(
150
+ (x): x is string => typeof x === "string",
151
+ );
152
+ }
153
+ if (r.overrides && typeof r.overrides === "object") {
154
+ merged.overrides = r.overrides as Record<
155
+ string,
156
+ Partial<CustomProviderModelConfig>
157
+ >;
158
+ }
159
+ if (typeof r.refreshIntervalMinutes === "number")
160
+ merged.refreshIntervalMinutes = r.refreshIntervalMinutes;
161
+ if (typeof r.usageCacheTtlMs === "number")
162
+ merged.usageCacheTtlMs = r.usageCacheTtlMs;
163
+ return merged;
164
+ }
165
+
166
+ /**
167
+ * resolveConfigValue — same semantics Pi uses for apiKey:
168
+ * "!cmd ..." → run command, take stdout, trim
169
+ * "$ENV_VAR" → read process.env[ENV_VAR]
170
+ * any other str → return as-is
171
+ * Empty / undefined → "".
172
+ *
173
+ * We re-implement it locally rather than reach into pi internals; resolution
174
+ * happens once at apply time so the resolved key can be passed to
175
+ * pi.registerProvider verbatim.
176
+ */
177
+ export function resolveConfigValue(raw: string | undefined | null): string {
178
+ if (!raw) return "";
179
+ const v = raw.trim();
180
+ if (v.startsWith("!")) {
181
+ try {
182
+ return execSync(v.slice(1), {
183
+ encoding: "utf8",
184
+ stdio: ["ignore", "pipe", "pipe"],
185
+ }).trim();
186
+ } catch (err) {
187
+ // Don't dump stack traces for the common "file not found" case — just say what didn't resolve.
188
+ const msg =
189
+ (err as { stderr?: Buffer | string })?.stderr?.toString?.()?.trim() ??
190
+ (err as Error).message;
191
+ log.warn(`failed to resolve "!" config value (${v}): ${msg}`);
192
+ return "";
193
+ }
194
+ }
195
+ if (v.startsWith("$")) {
196
+ return process.env[v.slice(1)] ?? "";
197
+ }
198
+ return v;
199
+ }
@@ -0,0 +1,66 @@
1
+ // Conflicts: read-only scan of Pi's own ~/.pi/models.json and ~/.pi/auth.json
2
+ // to warn when an extension-registered provider collides with a user-edited
3
+ // entry that Pi merges on top. We never write to these files.
4
+ //
5
+ // Both files are entirely optional (users might never touch them). Missing or
6
+ // malformed → no warnings.
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import type { ProxyConfig } from "./config.ts";
13
+
14
+ export interface ConflictWarning {
15
+ kind: "models.json" | "auth.json";
16
+ provider: string;
17
+ detail: string;
18
+ }
19
+
20
+ const MODELS_JSON = join(homedir(), ".pi", "models.json");
21
+ const AUTH_JSON = join(homedir(), ".pi", "auth.json");
22
+
23
+ function safeRead(path: string): unknown {
24
+ if (!existsSync(path)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(path, "utf8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function detectConflicts(cfg: ProxyConfig): ConflictWarning[] {
33
+ const out: ConflictWarning[] = [];
34
+ const providersInCfg = new Set<string>([
35
+ ...Object.keys(cfg.builtinProviders),
36
+ ...Object.keys(cfg.customProviders),
37
+ ]);
38
+
39
+ const models = safeRead(MODELS_JSON);
40
+ if (models && typeof models === "object") {
41
+ for (const name of Object.keys(models as Record<string, unknown>)) {
42
+ if (providersInCfg.has(name)) {
43
+ out.push({
44
+ kind: "models.json",
45
+ provider: name,
46
+ detail: `provider "${name}" has user overrides in ~/.pi/models.json which Pi merges on top of our registration`,
47
+ });
48
+ }
49
+ }
50
+ }
51
+
52
+ const auth = safeRead(AUTH_JSON);
53
+ if (auth && typeof auth === "object") {
54
+ for (const name of Object.keys(auth as Record<string, unknown>)) {
55
+ if (providersInCfg.has(name)) {
56
+ out.push({
57
+ kind: "auth.json",
58
+ provider: name,
59
+ detail: `provider "${name}" has user-set credentials in ~/.pi/auth.json which take priority over our apiKey`,
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ return out;
66
+ }