pi-provider-litellm 1.2.2 → 1.2.4
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/README.md +39 -2
- package/dist/cost.d.ts +2 -2
- package/dist/cost.js +15 -41
- package/dist/discover.js +112 -13
- package/dist/index.js +192 -83
- package/dist/types.d.ts +2 -0
- package/package.json +13 -12
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ pi -e npm:pi-provider-litellm
|
|
|
24
24
|
```bash
|
|
25
25
|
git clone https://github.com/balcsida/pi-provider-litellm.git ~/.pi/agent/extensions/pi-provider-litellm
|
|
26
26
|
cd ~/.pi/agent/extensions/pi-provider-litellm
|
|
27
|
-
npm
|
|
28
|
-
npm run build
|
|
27
|
+
npm ci
|
|
28
|
+
npm run clean && npm run build
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
</details>
|
|
@@ -61,11 +61,45 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
61
61
|
|
|
62
62
|
| Variable | Default | Effect |
|
|
63
63
|
|---|---|---|
|
|
64
|
+
| `LITELLM_API_KEY_HELPER` | unset | Command that prints a fresh LiteLLM bearer token. Takes precedence over `LITELLM_API_KEY`. Registered as a `!command` provider key; Pi re-runs it on every request (the per-request auth path is uncached), so rotating/short-lived tokens stay fresh. |
|
|
64
65
|
| `LITELLM_OFFLINE` | unset | If `1`, skip discovery on this start; use cache only |
|
|
65
66
|
| `LITELLM_DISCOVERY_TIMEOUT_MS` | `5000` | Discovery fetch timeout in ms; `0` to skip discovery |
|
|
66
67
|
|
|
67
68
|
`LITELLM_DISCOVERY_TIMEOUT_MS=0` only disables startup and refresh model discovery. It does not replace the base URL or API key settings required to send requests when you are not using `/login litellm`.
|
|
68
69
|
|
|
70
|
+
## Mocked LiteLLM smoke workflow
|
|
71
|
+
|
|
72
|
+
The `LiteLLM Smoke` GitHub Actions workflow starts VidaiMock and a real LiteLLM proxy on the runner. LiteLLM exposes OpenAI-compatible and Anthropic routes whose upstreams are served by VidaiMock, then this extension's smoke runner discovers those models through LiteLLM and sends `/v1/chat/completions` requests through the proxy.
|
|
73
|
+
|
|
74
|
+
This keeps the LiteLLM integration path under test but does not call real LLM APIs. No provider API keys or GitHub Models permission are required. The workflow also runs a non-interactive Pi CLI smoke with `--list-models` and `-p` so extension loading, model discovery, and a real completion path are covered without opening the TUI.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm ci
|
|
82
|
+
npm run check
|
|
83
|
+
npm run clean && npm run build
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`npm run check` runs Biome, type checking, and the Vitest suite. Runtime changes must be built before local Pi smoke checks because the extension entrypoint is `./dist/index.js`.
|
|
87
|
+
|
|
88
|
+
Before changing package contents or dependency policy, also run:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run supply-chain:guard
|
|
92
|
+
npm pack --dry-run
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The published npm package should contain only `dist`, `README.md`, and `LICENSE`.
|
|
96
|
+
|
|
97
|
+
## Release
|
|
98
|
+
|
|
99
|
+
Releases are driven by semver tags named `v*.*.*`. The GitHub release workflow installs from the lockfile, runs the checks, builds `dist`, verifies the package tarball, publishes to npm with provenance, and creates a GitHub release.
|
|
100
|
+
|
|
101
|
+
Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
|
|
102
|
+
|
|
69
103
|
## Slash commands
|
|
70
104
|
|
|
71
105
|
- `/litellm-refresh` — force re-fetch the model list, ignoring cache
|
|
@@ -74,6 +108,8 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
74
108
|
|
|
75
109
|
The model list is cached at `~/.pi/agent/litellm-models.json` with a keyed fingerprint of the base URL + API key. Changing either invalidates the cache automatically.
|
|
76
110
|
|
|
111
|
+
If the cache is older than 24 hours, the extension refreshes it in the background on session start (non-blocking). Failures are silent — the cached models remain in use. Run `/litellm-refresh` to force an immediate refresh.
|
|
112
|
+
|
|
77
113
|
## Troubleshooting
|
|
78
114
|
|
|
79
115
|
| Symptom | Likely cause |
|
|
@@ -82,6 +118,7 @@ The model list is cached at `~/.pi/agent/litellm-models.json` with a keyed finge
|
|
|
82
118
|
| "discovered no models" | Proxy returned an empty list — check pi's startup log for the actual response |
|
|
83
119
|
| `/model/info` returning 401/403/404 | Expected behavior with virtual keys — extension falls back to `/v1/models` |
|
|
84
120
|
| Discovery times out | Increase `LITELLM_DISCOVERY_TIMEOUT_MS` or set `LITELLM_OFFLINE=1` to fall back on cached models |
|
|
121
|
+
| `401 Token expired` | Set `LITELLM_API_KEY_HELPER`. |
|
|
85
122
|
|
|
86
123
|
## License
|
|
87
124
|
|
package/dist/cost.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
export interface ModelCostInfo {
|
|
3
3
|
inputCostPerToken: number;
|
|
4
4
|
outputCostPerToken: number;
|
|
5
5
|
cacheReadCostPerToken: number;
|
|
6
6
|
cacheWriteCostPerToken: number;
|
|
7
7
|
}
|
|
8
|
-
export declare function setupLiteLLMCostTracking(pi: ExtensionAPI): void;
|
|
8
|
+
export declare function setupLiteLLMCostTracking(pi: ExtensionAPI, models: ProviderModelConfig[]): (models: ProviderModelConfig[]) => void;
|
package/dist/cost.js
CHANGED
|
@@ -1,48 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
export function setupLiteLLMCostTracking(pi) {
|
|
1
|
+
export function setupLiteLLMCostTracking(pi, models) {
|
|
3
2
|
const modelCosts = new Map();
|
|
4
3
|
let lastResponseCost = null;
|
|
5
|
-
pi
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
},
|
|
4
|
+
// pi reports model costs per million tokens; cost lookups multiply by raw token counts.
|
|
5
|
+
const updateCosts = (newModels) => {
|
|
6
|
+
modelCosts.clear();
|
|
7
|
+
for (const model of newModels) {
|
|
8
|
+
if (!model.cost)
|
|
9
|
+
continue;
|
|
10
|
+
modelCosts.set(model.id, {
|
|
11
|
+
inputCostPerToken: (model.cost.input ?? 0) / 1_000_000,
|
|
12
|
+
outputCostPerToken: (model.cost.output ?? 0) / 1_000_000,
|
|
13
|
+
cacheReadCostPerToken: (model.cost.cacheRead ?? 0) / 1_000_000,
|
|
14
|
+
cacheWriteCostPerToken: (model.cost.cacheWrite ?? 0) / 1_000_000,
|
|
17
15
|
});
|
|
18
|
-
if (!response.ok) {
|
|
19
|
-
ctx.ui?.notify?.(`LiteLLM cost extension: /model/info returned ${response.status}`, "warning");
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
const payload = (await response.json());
|
|
23
|
-
for (const entry of payload.data) {
|
|
24
|
-
const info = entry.model_info;
|
|
25
|
-
if (!info)
|
|
26
|
-
continue;
|
|
27
|
-
const costInfo = {
|
|
28
|
-
inputCostPerToken: info.input_cost_per_token ?? 0,
|
|
29
|
-
outputCostPerToken: info.output_cost_per_token ?? 0,
|
|
30
|
-
cacheReadCostPerToken: info.cache_read_input_token_cost ?? 0,
|
|
31
|
-
cacheWriteCostPerToken: info.cache_creation_input_token_cost ?? 0,
|
|
32
|
-
};
|
|
33
|
-
modelCosts.set(entry.model_name, costInfo);
|
|
34
|
-
if (entry.litellm_params?.model) {
|
|
35
|
-
modelCosts.set(entry.litellm_params.model, costInfo);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (modelCosts.size > 0) {
|
|
39
|
-
ctx.ui?.notify?.(`LiteLLM cost: loaded pricing for ${modelCosts.size} model(s)`, "info");
|
|
40
|
-
}
|
|
41
16
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
});
|
|
17
|
+
};
|
|
18
|
+
updateCosts(models);
|
|
46
19
|
pi.on("after_provider_response", (event) => {
|
|
47
20
|
const costHeader = event.headers?.["x-litellm-response-cost"] ?? event.headers?.["X-Litellm-Response-Cost"];
|
|
48
21
|
if (costHeader) {
|
|
@@ -132,4 +105,5 @@ export function setupLiteLLMCostTracking(pi) {
|
|
|
132
105
|
}
|
|
133
106
|
return;
|
|
134
107
|
});
|
|
108
|
+
return updateCosts;
|
|
135
109
|
}
|
package/dist/discover.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import { getModels, getProviders } from "@earendil-works/pi-ai";
|
|
1
2
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
2
3
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
3
4
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
5
|
+
const KNOWN_PROVIDER_SET = new Set(getProviders());
|
|
6
|
+
const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
7
|
+
let modelsDevCatalog;
|
|
4
8
|
export function normalizeBaseUrl(input) {
|
|
5
9
|
return input.replace(/\/+$/, "").replace(/\/v1\/?$/i, "");
|
|
6
10
|
}
|
|
7
|
-
// Matches both the conventional `anthropic/...` prefix and
|
|
11
|
+
// Matches both the conventional `anthropic/...` prefix and aliases that
|
|
8
12
|
// LiteLLM deployments commonly assign to Anthropic-backed routes (e.g.
|
|
9
|
-
// `opus-4.7`, `sonnet-4.6`, `haiku-4.5
|
|
10
|
-
// `cacheControlFormat: "anthropic"` flag, pi never relays cache_control
|
|
11
|
-
// through the proxy, so prompt caching silently no-ops on Claude models.
|
|
12
|
-
const ANTHROPIC_MODEL_PATTERN =
|
|
13
|
+
// `google/claude-sonnet-4-6`, `opus-4.7`, `sonnet-4.6`, `haiku-4.5`). Without
|
|
14
|
+
// the `cacheControlFormat: "anthropic"` flag, pi never relays cache_control
|
|
15
|
+
// markers through the proxy, so prompt caching silently no-ops on Claude models.
|
|
16
|
+
const ANTHROPIC_MODEL_PATTERN = /(?:^|[-_/.:])(?:anthropic\/|(?:claude|opus|sonnet|haiku)(?=$|[-_/.:]))/i;
|
|
13
17
|
const MOONSHOT_MODEL_PATTERN = /^(moonshotai\/|moonshot\/|kimi[-/])/i;
|
|
14
18
|
const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
|
|
15
19
|
export function isMoonshotModel(modelId) {
|
|
@@ -33,6 +37,44 @@ export function buildCompat(modelId) {
|
|
|
33
37
|
}
|
|
34
38
|
return { supportsStore: false };
|
|
35
39
|
}
|
|
40
|
+
function toKnownProvider(provider) {
|
|
41
|
+
if (!provider)
|
|
42
|
+
return undefined;
|
|
43
|
+
const normalized = provider.toLowerCase();
|
|
44
|
+
return KNOWN_PROVIDER_SET.has(normalized) ? normalized : undefined;
|
|
45
|
+
}
|
|
46
|
+
function findCatalogModel(id, ownedBy) {
|
|
47
|
+
const prefixProvider = toKnownProvider(id.split("/")[0]);
|
|
48
|
+
const candidates = [toKnownProvider(ownedBy), prefixProvider].filter((provider) => provider !== undefined);
|
|
49
|
+
for (const provider of candidates) {
|
|
50
|
+
const exact = getModels(provider).find((model) => model.id === id);
|
|
51
|
+
if (exact)
|
|
52
|
+
return exact;
|
|
53
|
+
const providerQualified = getModels(provider).find((model) => model.id === `${provider}/${id}`);
|
|
54
|
+
if (providerQualified)
|
|
55
|
+
return providerQualified;
|
|
56
|
+
}
|
|
57
|
+
for (const provider of getProviders()) {
|
|
58
|
+
const exact = getModels(provider).find((model) => model.id === id);
|
|
59
|
+
if (exact)
|
|
60
|
+
return exact;
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function getFallbackProviderAndModel(id, ownedBy) {
|
|
65
|
+
const [prefix, ...rest] = id.split("/");
|
|
66
|
+
const prefixProvider = toKnownProvider(prefix);
|
|
67
|
+
if (prefixProvider && rest.length > 0) {
|
|
68
|
+
return { provider: prefixProvider, modelId: rest.join("/") };
|
|
69
|
+
}
|
|
70
|
+
return { provider: toKnownProvider(ownedBy), modelId: id };
|
|
71
|
+
}
|
|
72
|
+
function findModelsDevModel(catalog, id, ownedBy) {
|
|
73
|
+
const { provider, modelId } = getFallbackProviderAndModel(id, ownedBy);
|
|
74
|
+
if (!provider)
|
|
75
|
+
return undefined;
|
|
76
|
+
return catalog?.[provider]?.models?.[modelId];
|
|
77
|
+
}
|
|
36
78
|
function withTimeout(timeoutMs, signal) {
|
|
37
79
|
const controller = new AbortController();
|
|
38
80
|
const onAbort = () => controller.abort(signal?.reason);
|
|
@@ -68,6 +110,59 @@ async function fetchJson(url, apiKey, options) {
|
|
|
68
110
|
cancel();
|
|
69
111
|
}
|
|
70
112
|
}
|
|
113
|
+
async function fetchPublicJson(url, options) {
|
|
114
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
115
|
+
const { signal, cancel } = withTimeout(timeoutMs, options.signal);
|
|
116
|
+
try {
|
|
117
|
+
const response = await fetch(url, {
|
|
118
|
+
headers: { Accept: "application/json" },
|
|
119
|
+
signal,
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
throw new Error(`${url} returned ${response.status}`);
|
|
123
|
+
return (await response.json());
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
cancel();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function getModelsDevCatalog(options) {
|
|
130
|
+
if (modelsDevCatalog)
|
|
131
|
+
return modelsDevCatalog;
|
|
132
|
+
try {
|
|
133
|
+
modelsDevCatalog = await fetchPublicJson(MODELS_DEV_URL, options);
|
|
134
|
+
return modelsDevCatalog;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function mapModelsDevMetadata(model) {
|
|
141
|
+
if (!model)
|
|
142
|
+
return {};
|
|
143
|
+
const metadata = {};
|
|
144
|
+
if (model.name)
|
|
145
|
+
metadata.name = model.name;
|
|
146
|
+
if (model.reasoning !== undefined)
|
|
147
|
+
metadata.reasoning = model.reasoning;
|
|
148
|
+
if (model.modalities?.input) {
|
|
149
|
+
metadata.input = model.modalities.input.includes("image") ? ["text", "image"] : ["text"];
|
|
150
|
+
}
|
|
151
|
+
const contextWindow = model.limit?.context ?? model.limit?.input;
|
|
152
|
+
if (contextWindow !== undefined)
|
|
153
|
+
metadata.contextWindow = contextWindow;
|
|
154
|
+
if (model.limit?.output !== undefined)
|
|
155
|
+
metadata.maxTokens = model.limit.output;
|
|
156
|
+
if (model.cost) {
|
|
157
|
+
metadata.cost = {
|
|
158
|
+
input: model.cost.input ?? 0,
|
|
159
|
+
output: model.cost.output ?? 0,
|
|
160
|
+
cacheRead: model.cost.cache_read ?? 0,
|
|
161
|
+
cacheWrite: model.cost.cache_write ?? 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return metadata;
|
|
165
|
+
}
|
|
71
166
|
function mapFromModelInfo(entry) {
|
|
72
167
|
const id = entry.model_name;
|
|
73
168
|
if (!id)
|
|
@@ -91,18 +186,21 @@ function mapFromModelInfo(entry) {
|
|
|
91
186
|
compat: buildCompat(id),
|
|
92
187
|
};
|
|
93
188
|
}
|
|
94
|
-
function mapFromModelsList(entry) {
|
|
189
|
+
function mapFromModelsList(entry, modelsDev) {
|
|
95
190
|
const id = entry.id;
|
|
96
191
|
if (!id)
|
|
97
192
|
return undefined;
|
|
193
|
+
const catalogModel = findCatalogModel(id, entry.owned_by);
|
|
194
|
+
const modelsDevMetadata = mapModelsDevMetadata(findModelsDevModel(modelsDev, id, entry.owned_by));
|
|
98
195
|
return {
|
|
99
196
|
id,
|
|
100
|
-
name: `${id} (no metadata)`,
|
|
101
|
-
reasoning: false,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
197
|
+
name: modelsDevMetadata.name ?? catalogModel?.name ?? `${id} (no metadata)`,
|
|
198
|
+
reasoning: modelsDevMetadata.reasoning ?? catalogModel?.reasoning ?? false,
|
|
199
|
+
thinkingLevelMap: catalogModel?.thinkingLevelMap,
|
|
200
|
+
input: modelsDevMetadata.input ?? catalogModel?.input ?? ["text"],
|
|
201
|
+
cost: modelsDevMetadata.cost ?? catalogModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
202
|
+
contextWindow: modelsDevMetadata.contextWindow ?? catalogModel?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
203
|
+
maxTokens: modelsDevMetadata.maxTokens ?? catalogModel?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
106
204
|
compat: buildCompat(id),
|
|
107
205
|
};
|
|
108
206
|
}
|
|
@@ -122,8 +220,9 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
|
122
220
|
if (!listResult.ok) {
|
|
123
221
|
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
124
222
|
}
|
|
223
|
+
const modelsDev = await getModelsDevCatalog(options);
|
|
125
224
|
const models = (listResult.data.data ?? [])
|
|
126
|
-
.map(mapFromModelsList)
|
|
225
|
+
.map((entry) => mapFromModelsList(entry, modelsDev))
|
|
127
226
|
.filter((m) => m !== undefined);
|
|
128
227
|
return { source: "models_list", models };
|
|
129
228
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -8,11 +9,16 @@ import { getSessionIdFromFile } from "./litellm.js";
|
|
|
8
9
|
const PROVIDER_NAME = "litellm";
|
|
9
10
|
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
10
11
|
const ENV_API_KEY = "LITELLM_API_KEY";
|
|
12
|
+
const ENV_API_KEY_HELPER = "LITELLM_API_KEY_HELPER";
|
|
11
13
|
const ENV_TIMEOUT = "LITELLM_DISCOVERY_TIMEOUT_MS";
|
|
12
14
|
const ENV_OFFLINE = "LITELLM_OFFLINE";
|
|
13
15
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
16
|
const LOGIN_TIMEOUT_MS = 10_000;
|
|
15
17
|
const CACHE_FILENAME = "litellm-models.json";
|
|
18
|
+
const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const TOKEN_REFRESH_LEAD_MS = 5 * 60 * 1000;
|
|
20
|
+
const PERMANENT_TOKEN_EXPIRES_AT = Number.MAX_SAFE_INTEGER;
|
|
21
|
+
const EXPIRE_TOKEN_IMMEDIATELY = 0;
|
|
16
22
|
function getAuthPath() {
|
|
17
23
|
return join(getAgentDir(), "auth.json");
|
|
18
24
|
}
|
|
@@ -29,20 +35,74 @@ async function readAuthEntry() {
|
|
|
29
35
|
return undefined;
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
|
-
|
|
38
|
+
function cleanConfig(raw) {
|
|
39
|
+
const trimmed = raw?.trim();
|
|
40
|
+
return trimmed && trimmed !== "undefined" ? trimmed : undefined;
|
|
41
|
+
}
|
|
42
|
+
function normalizeCommand(raw) {
|
|
43
|
+
const trimmed = cleanConfig(raw);
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
return undefined;
|
|
46
|
+
return trimmed.startsWith("!") ? trimmed : `!${trimmed}`;
|
|
47
|
+
}
|
|
48
|
+
function getApiKeyHelperCommand() {
|
|
49
|
+
return normalizeCommand(process.env[ENV_API_KEY_HELPER]);
|
|
50
|
+
}
|
|
51
|
+
function executeApiKeyCommand(commandConfig) {
|
|
52
|
+
const command = commandConfig.startsWith("!") ? commandConfig.slice(1) : commandConfig;
|
|
53
|
+
const output = execSync(command, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
|
|
54
|
+
if (!output)
|
|
55
|
+
throw new Error(`LiteLLM API key helper produced no output: ${command}`);
|
|
56
|
+
return output;
|
|
57
|
+
}
|
|
58
|
+
function tokenExpiresAt(apiKey, opaqueFallback = PERMANENT_TOKEN_EXPIRES_AT) {
|
|
59
|
+
const [, payload] = apiKey.split(".");
|
|
60
|
+
if (!payload)
|
|
61
|
+
return opaqueFallback;
|
|
62
|
+
try {
|
|
63
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
64
|
+
return typeof claims.exp === "number"
|
|
65
|
+
? Math.max(Date.now(), claims.exp * 1000 - TOKEN_REFRESH_LEAD_MS)
|
|
66
|
+
: opaqueFallback;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return opaqueFallback;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function getLiteLLMApiKey(credentials) {
|
|
73
|
+
return credentials.access;
|
|
74
|
+
}
|
|
75
|
+
function resolveOAuthApiKey(credentials) {
|
|
76
|
+
return credentials.refresh.startsWith("!")
|
|
77
|
+
? executeApiKeyCommand(credentials.refresh)
|
|
78
|
+
: getLiteLLMApiKey(credentials);
|
|
79
|
+
}
|
|
80
|
+
async function resolveCredentials({ executeHelpers = true } = {}) {
|
|
33
81
|
const entry = await readAuthEntry();
|
|
34
|
-
const envBase = process.env[ENV_BASE_URL]
|
|
35
|
-
const envKey = process.env[ENV_API_KEY]
|
|
82
|
+
const envBase = cleanConfig(process.env[ENV_BASE_URL]);
|
|
83
|
+
const envKey = cleanConfig(process.env[ENV_API_KEY]);
|
|
84
|
+
const envHelperCommand = getApiKeyHelperCommand();
|
|
36
85
|
const authBase = entry?.type === "oauth" ? entry.baseUrl?.trim() : undefined;
|
|
37
86
|
const authKey = entry?.type === "oauth"
|
|
38
|
-
? entry.access
|
|
87
|
+
? (executeHelpers ? resolveOAuthApiKey(entry) : entry.access).trim()
|
|
39
88
|
: entry?.type === "api_key"
|
|
40
89
|
? (await AuthStorage.create(getAuthPath()).getApiKey(PROVIDER_NAME, { includeFallback: false }))?.trim()
|
|
41
90
|
: undefined;
|
|
91
|
+
const apiKey = authKey || (executeHelpers && envHelperCommand ? executeApiKeyCommand(envHelperCommand) : undefined) || envKey;
|
|
92
|
+
const apiKeyFingerprint = entry?.type === "oauth" && entry.refresh.startsWith("!")
|
|
93
|
+
? fingerprint(entry.refresh)
|
|
94
|
+
: authKey
|
|
95
|
+
? fingerprint(authKey)
|
|
96
|
+
: envHelperCommand
|
|
97
|
+
? fingerprint(envHelperCommand)
|
|
98
|
+
: envKey
|
|
99
|
+
? fingerprint(envKey)
|
|
100
|
+
: undefined;
|
|
42
101
|
const rawBase = authBase || envBase;
|
|
43
102
|
return {
|
|
44
103
|
baseUrl: rawBase ? normalizeBaseUrl(rawBase) : undefined,
|
|
45
|
-
apiKey:
|
|
104
|
+
apiKey: apiKey || undefined,
|
|
105
|
+
apiKeyFingerprint,
|
|
46
106
|
};
|
|
47
107
|
}
|
|
48
108
|
function getDiscoveryTimeoutMs() {
|
|
@@ -72,36 +132,43 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
|
|
|
72
132
|
};
|
|
73
133
|
}
|
|
74
134
|
}
|
|
75
|
-
async function loginLiteLLM(callbacks) {
|
|
135
|
+
async function loginLiteLLM(callbacks, onCacheWrite) {
|
|
76
136
|
const rawBaseUrl = (await callbacks.onPrompt({
|
|
77
137
|
message: "Enter LiteLLM proxy URL (no trailing /v1):",
|
|
78
138
|
placeholder: "https://litellm.example.com",
|
|
79
139
|
})).trim();
|
|
80
|
-
const
|
|
81
|
-
if (!rawBaseUrl || !
|
|
140
|
+
const apiKeyInput = (await callbacks.onPrompt({ message: "Enter API key:" })).trim();
|
|
141
|
+
if (!rawBaseUrl || !apiKeyInput)
|
|
82
142
|
throw new Error("Both base URL and API key are required");
|
|
83
143
|
const baseUrl = normalizeBaseUrl(rawBaseUrl);
|
|
144
|
+
const refresh = apiKeyInput.startsWith("!") ? apiKeyInput : "";
|
|
145
|
+
const apiKey = refresh ? executeApiKeyCommand(refresh) : apiKeyInput;
|
|
84
146
|
const { models, source } = await discoverModels(baseUrl, apiKey, {
|
|
85
147
|
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
86
148
|
signal: callbacks.signal,
|
|
87
149
|
});
|
|
88
|
-
|
|
150
|
+
const cache = {
|
|
89
151
|
baseUrl,
|
|
90
|
-
apiKeyFingerprint: fingerprint(apiKey),
|
|
152
|
+
apiKeyFingerprint: fingerprint(refresh || apiKey),
|
|
91
153
|
fetchedAt: Date.now(),
|
|
92
154
|
source,
|
|
93
155
|
models,
|
|
94
|
-
}
|
|
156
|
+
};
|
|
157
|
+
await writeCache(getCachePath(), cache);
|
|
158
|
+
onCacheWrite?.(cache);
|
|
95
159
|
callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
|
|
96
160
|
return {
|
|
97
161
|
access: apiKey,
|
|
98
|
-
refresh
|
|
99
|
-
expires:
|
|
162
|
+
refresh,
|
|
163
|
+
expires: tokenExpiresAt(apiKey, refresh ? EXPIRE_TOKEN_IMMEDIATELY : PERMANENT_TOKEN_EXPIRES_AT),
|
|
100
164
|
baseUrl,
|
|
101
165
|
};
|
|
102
166
|
}
|
|
103
167
|
async function refreshLiteLLM(credentials) {
|
|
104
|
-
|
|
168
|
+
if (!credentials.refresh.startsWith("!"))
|
|
169
|
+
return credentials;
|
|
170
|
+
const access = executeApiKeyCommand(credentials.refresh);
|
|
171
|
+
return { ...credentials, access, expires: tokenExpiresAt(access, EXPIRE_TOKEN_IMMEDIATELY) };
|
|
105
172
|
}
|
|
106
173
|
function modifyLiteLLMModels(models, cred) {
|
|
107
174
|
const baseUrl = cred.baseUrl;
|
|
@@ -191,97 +258,137 @@ function normalizeThinkTags(message) {
|
|
|
191
258
|
return { ...message, content };
|
|
192
259
|
}
|
|
193
260
|
export default async function (pi) {
|
|
194
|
-
|
|
261
|
+
let creds = await resolveCredentials({ executeHelpers: false });
|
|
195
262
|
const cache = await readCache(getCachePath());
|
|
196
|
-
|
|
263
|
+
let fp = creds.apiKeyFingerprint;
|
|
264
|
+
let cacheFetchedAt = cache?.fetchedAt ?? 0;
|
|
197
265
|
const cacheValid = cache !== null &&
|
|
198
266
|
creds.baseUrl !== undefined &&
|
|
199
267
|
fp !== undefined &&
|
|
200
268
|
cache.baseUrl === creds.baseUrl &&
|
|
201
269
|
cache.apiKeyFingerprint === fp;
|
|
202
270
|
let models = cacheValid && cache ? cache.models : [];
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
271
|
+
const shouldFetch = creds.baseUrl !== undefined &&
|
|
272
|
+
fp !== undefined &&
|
|
273
|
+
!isOffline() &&
|
|
274
|
+
getDiscoveryTimeoutMs() > 0 &&
|
|
275
|
+
(!cacheValid || isListModelsMode());
|
|
276
|
+
let credentialWarning;
|
|
277
|
+
if (shouldFetch) {
|
|
278
|
+
try {
|
|
279
|
+
creds = await resolveCredentials();
|
|
280
|
+
fp = creds.apiKeyFingerprint;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (!cacheValid || !cache)
|
|
284
|
+
throw error;
|
|
285
|
+
credentialWarning = error instanceof Error ? error.message : String(error);
|
|
286
|
+
process.stderr.write(`LiteLLM: discovery failed (${credentialWarning}); using cached models.\n`);
|
|
287
|
+
models = cache.models;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (shouldFetch && !credentialWarning && creds.baseUrl && creds.apiKey && fp) {
|
|
206
291
|
const timeoutMs = getDiscoveryTimeoutMs();
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
models = cache.models;
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
|
|
218
|
-
models = [];
|
|
219
|
-
}
|
|
292
|
+
const { result, warning } = await discoverWithFallback(creds.baseUrl, creds.apiKey, {
|
|
293
|
+
timeoutMs,
|
|
294
|
+
});
|
|
295
|
+
if (warning) {
|
|
296
|
+
if (cacheValid && cache) {
|
|
297
|
+
process.stderr.write(`LiteLLM: discovery failed (${warning}); using cached models.\n`);
|
|
298
|
+
models = cache.models;
|
|
220
299
|
}
|
|
221
300
|
else {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
301
|
+
process.stderr.write(`LiteLLM: discovery failed (${warning}); registering provider with no models.\n`);
|
|
302
|
+
models = [];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
models = result.models;
|
|
307
|
+
const next = {
|
|
308
|
+
baseUrl: creds.baseUrl,
|
|
309
|
+
apiKeyFingerprint: fp,
|
|
310
|
+
fetchedAt: Date.now(),
|
|
311
|
+
source: result.source,
|
|
312
|
+
models: result.models,
|
|
313
|
+
};
|
|
314
|
+
await writeCache(getCachePath(), next);
|
|
315
|
+
cacheFetchedAt = next.fetchedAt;
|
|
316
|
+
if (isListModelsMode()) {
|
|
317
|
+
process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
|
|
234
318
|
}
|
|
235
319
|
}
|
|
236
320
|
}
|
|
237
321
|
const oauth = {
|
|
238
322
|
name: "LiteLLM",
|
|
239
|
-
login: loginLiteLLM,
|
|
323
|
+
login: (callbacks) => loginLiteLLM(callbacks, (next) => {
|
|
324
|
+
cacheFetchedAt = next.fetchedAt;
|
|
325
|
+
}),
|
|
240
326
|
refreshToken: refreshLiteLLM,
|
|
241
|
-
getApiKey:
|
|
327
|
+
getApiKey: getLiteLLMApiKey,
|
|
242
328
|
modifyModels: modifyLiteLLMModels,
|
|
243
329
|
};
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
330
|
+
function registerProvider(baseUrl, models) {
|
|
331
|
+
pi.registerProvider(PROVIDER_NAME, {
|
|
332
|
+
baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
|
|
333
|
+
// When LITELLM_API_KEY_HELPER is set we register the helper as a `!command` provider key.
|
|
334
|
+
// Pi's per-request auth path (ModelRegistry.getApiKeyAndHeaders) resolves provider keys via
|
|
335
|
+
// resolveConfigValueOrThrow -> resolveConfigValueUncached, i.e. the command is re-executed on
|
|
336
|
+
// every request (it does NOT use the process-lifetime command cache, which only applies to
|
|
337
|
+
// resolveConfigValue). So a short-lived/rotating helper token stays fresh. The OAuth hooks
|
|
338
|
+
// remain registered for `/login litellm` users. See the regression test
|
|
339
|
+
// "re-runs the helper command on every request" in tests/index.test.ts.
|
|
340
|
+
apiKey: getApiKeyHelperCommand() ?? `$${ENV_API_KEY}`,
|
|
341
|
+
api: "openai-completions",
|
|
342
|
+
models,
|
|
343
|
+
oauth,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
registerProvider(creds.baseUrl, models);
|
|
347
|
+
const updateCosts = setupLiteLLMCostTracking(pi, models);
|
|
348
|
+
let refreshInProgress = null;
|
|
349
|
+
function discoveryDisabledReason() {
|
|
350
|
+
if (isOffline())
|
|
351
|
+
return `${ENV_OFFLINE}=1`;
|
|
352
|
+
if (getDiscoveryTimeoutMs() === 0)
|
|
353
|
+
return `${ENV_TIMEOUT}=0`;
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
async function refreshModelsAndCosts() {
|
|
357
|
+
const fresh = await resolveCredentials();
|
|
358
|
+
const freshFp = fresh.apiKeyFingerprint;
|
|
359
|
+
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
360
|
+
throw new Error("no credentials. Run /login litellm or set env vars.");
|
|
361
|
+
}
|
|
362
|
+
const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
await writeCache(getCachePath(), {
|
|
365
|
+
baseUrl: fresh.baseUrl,
|
|
366
|
+
apiKeyFingerprint: freshFp,
|
|
367
|
+
fetchedAt: now,
|
|
368
|
+
source: result.source,
|
|
369
|
+
models: result.models,
|
|
370
|
+
});
|
|
371
|
+
registerProvider(fresh.baseUrl, result.models);
|
|
372
|
+
updateCosts(result.models);
|
|
373
|
+
cacheFetchedAt = now;
|
|
374
|
+
return { models: result.models, source: result.source };
|
|
375
|
+
}
|
|
376
|
+
function runRefresh() {
|
|
377
|
+
refreshInProgress ??= refreshModelsAndCosts().finally(() => {
|
|
378
|
+
refreshInProgress = null;
|
|
379
|
+
});
|
|
380
|
+
return refreshInProgress;
|
|
381
|
+
}
|
|
251
382
|
pi.registerCommand("litellm-refresh", {
|
|
252
383
|
description: "Re-discover models from the LiteLLM proxy.",
|
|
253
384
|
handler: async (_args, ctx) => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
const timeoutMs = getDiscoveryTimeoutMs();
|
|
259
|
-
if (timeoutMs === 0) {
|
|
260
|
-
ctx.ui.notify(`LiteLLM refresh disabled (${ENV_TIMEOUT}=0)`, "warning");
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
const fresh = await resolveCredentials();
|
|
264
|
-
const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
|
|
265
|
-
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
266
|
-
ctx.ui.notify("LiteLLM refresh failed: no credentials. Run /login litellm or set env vars.", "error");
|
|
385
|
+
const disabledReason = discoveryDisabledReason();
|
|
386
|
+
if (disabledReason) {
|
|
387
|
+
ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
|
|
267
388
|
return;
|
|
268
389
|
}
|
|
269
390
|
try {
|
|
270
|
-
const result = await
|
|
271
|
-
await writeCache(getCachePath(), {
|
|
272
|
-
baseUrl: fresh.baseUrl,
|
|
273
|
-
apiKeyFingerprint: freshFp,
|
|
274
|
-
fetchedAt: Date.now(),
|
|
275
|
-
source: result.source,
|
|
276
|
-
models: result.models,
|
|
277
|
-
});
|
|
278
|
-
pi.registerProvider(PROVIDER_NAME, {
|
|
279
|
-
baseUrl: `${fresh.baseUrl}/v1`,
|
|
280
|
-
apiKey: ENV_API_KEY,
|
|
281
|
-
api: "openai-completions",
|
|
282
|
-
models: result.models,
|
|
283
|
-
oauth,
|
|
284
|
-
});
|
|
391
|
+
const result = await runRefresh();
|
|
285
392
|
ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
|
|
286
393
|
}
|
|
287
394
|
catch (error) {
|
|
@@ -290,10 +397,12 @@ export default async function (pi) {
|
|
|
290
397
|
}
|
|
291
398
|
},
|
|
292
399
|
});
|
|
293
|
-
setupLiteLLMCostTracking(pi);
|
|
294
400
|
let sessionId;
|
|
295
|
-
pi.on("session_start",
|
|
401
|
+
pi.on("session_start", (_event, ctx) => {
|
|
296
402
|
sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
|
|
403
|
+
if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
|
|
404
|
+
return;
|
|
405
|
+
void runRefresh().catch(() => undefined);
|
|
297
406
|
});
|
|
298
407
|
pi.on("before_provider_request", (event, ctx) => {
|
|
299
408
|
if (ctx.model?.provider !== PROVIDER_NAME)
|
package/dist/types.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface ModelInfoResponse {
|
|
|
34
34
|
}
|
|
35
35
|
export interface ModelsListEntry {
|
|
36
36
|
id?: string;
|
|
37
|
+
owned_by?: string;
|
|
37
38
|
}
|
|
38
39
|
export interface ModelsListResponse {
|
|
39
40
|
data?: ModelsListEntry[];
|
|
@@ -51,4 +52,5 @@ export type AuthFileEntry = {
|
|
|
51
52
|
export interface ResolvedCredentials {
|
|
52
53
|
baseUrl?: string;
|
|
53
54
|
apiKey?: string;
|
|
55
|
+
apiKeyFingerprint?: string;
|
|
54
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "LiteLLM proxy provider extension for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,15 +47,16 @@
|
|
|
47
47
|
"lint": "biome check --error-on-warnings .",
|
|
48
48
|
"format": "biome check --write .",
|
|
49
49
|
"test": "vitest --run",
|
|
50
|
+
"supply-chain:guard": "tsx scripts/supply-chain-guard.ts",
|
|
50
51
|
"check": "npm run lint && npm run typecheck && npm test",
|
|
51
|
-
"prepublishOnly": "npm run check && npm run clean && npm run build"
|
|
52
|
+
"prepublishOnly": "npm run check && npm run clean && npm run build && npm run supply-chain:guard"
|
|
52
53
|
},
|
|
53
54
|
"engines": {
|
|
54
|
-
"node": ">=
|
|
55
|
+
"node": ">=22.19.0"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
|
-
"@earendil-works/pi-ai": "
|
|
58
|
-
"@earendil-works/pi-coding-agent": "
|
|
58
|
+
"@earendil-works/pi-ai": "*",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
59
60
|
},
|
|
60
61
|
"overrides": {
|
|
61
62
|
"basic-ftp": "6.0.1",
|
|
@@ -63,14 +64,14 @@
|
|
|
63
64
|
"protobufjs": "8.2.1"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
|
-
"@earendil-works/pi-ai": "^0.
|
|
67
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
68
|
-
"@biomejs/biome": "^
|
|
69
|
-
"@types/node": "^
|
|
70
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
67
|
+
"@earendil-works/pi-ai": "^0.78.1",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "^0.78.1",
|
|
69
|
+
"@biomejs/biome": "^2.4.15",
|
|
70
|
+
"@types/node": "^25.9.1",
|
|
71
|
+
"@typescript/native-preview": "7.0.0-dev.20260607.1",
|
|
71
72
|
"shx": "^0.4.0",
|
|
72
73
|
"tsx": "^4.21.0",
|
|
73
|
-
"typescript": "^
|
|
74
|
-
"vitest": "^
|
|
74
|
+
"typescript": "^6.0.3",
|
|
75
|
+
"vitest": "^4.1.7"
|
|
75
76
|
}
|
|
76
77
|
}
|