pi-provider-litellm 1.2.1 → 1.2.3
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 +37 -2
- package/dist/cost.d.ts +2 -2
- package/dist/cost.js +15 -41
- package/dist/discover.d.ts +2 -0
- package/dist/discover.js +120 -14
- package/dist/index.js +157 -48
- package/dist/types.d.ts +1 -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>
|
|
@@ -66,6 +66,39 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
66
66
|
|
|
67
67
|
`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
68
|
|
|
69
|
+
## Mocked LiteLLM smoke workflow
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
This package requires Node.js `>=22.19.0`. CI currently uses Node `24.16.0`.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm ci
|
|
81
|
+
npm run check
|
|
82
|
+
npm run clean && npm run build
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`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`.
|
|
86
|
+
|
|
87
|
+
Before changing package contents or dependency policy, also run:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm run supply-chain:guard
|
|
91
|
+
npm pack --dry-run
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The published npm package should contain only `dist`, `README.md`, and `LICENSE`.
|
|
95
|
+
|
|
96
|
+
## Release
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
Before tagging a release, keep `package.json` and `package-lock.json` versions in sync and verify the dry-run package contents.
|
|
101
|
+
|
|
69
102
|
## Slash commands
|
|
70
103
|
|
|
71
104
|
- `/litellm-refresh` — force re-fetch the model list, ignoring cache
|
|
@@ -74,6 +107,8 @@ Stored pi credentials for `litellm` take precedence over `LITELLM_API_KEY`; the
|
|
|
74
107
|
|
|
75
108
|
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
109
|
|
|
110
|
+
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.
|
|
111
|
+
|
|
77
112
|
## Troubleshooting
|
|
78
113
|
|
|
79
114
|
| Symptom | Likely cause |
|
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.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { DiscoveryOptions, DiscoveryResult } from "./types.js";
|
|
3
3
|
export declare function normalizeBaseUrl(input: string): string;
|
|
4
|
+
export declare function isMoonshotModel(modelId: string): boolean;
|
|
5
|
+
export declare function shouldSuppressReasoningContent(modelId: string): boolean;
|
|
4
6
|
export declare function buildCompat(modelId: string): ProviderModelConfig["compat"];
|
|
5
7
|
export declare function discoverModels(baseUrl: string, apiKey: string, options?: DiscoveryOptions): Promise<DiscoveryResult>;
|
package/dist/discover.js
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
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;
|
|
18
|
+
const FORCED_THINKING_MODEL_PATTERN = /(?:^|[-/])thinking(?:[-/]|$)/i;
|
|
19
|
+
export function isMoonshotModel(modelId) {
|
|
20
|
+
return MOONSHOT_MODEL_PATTERN.test(modelId);
|
|
21
|
+
}
|
|
22
|
+
export function shouldSuppressReasoningContent(modelId) {
|
|
23
|
+
return isMoonshotModel(modelId) && !FORCED_THINKING_MODEL_PATTERN.test(modelId);
|
|
24
|
+
}
|
|
14
25
|
export function buildCompat(modelId) {
|
|
15
|
-
if (
|
|
26
|
+
if (isMoonshotModel(modelId)) {
|
|
16
27
|
return {
|
|
17
28
|
supportsStore: false,
|
|
18
29
|
supportsDeveloperRole: false,
|
|
@@ -26,6 +37,44 @@ export function buildCompat(modelId) {
|
|
|
26
37
|
}
|
|
27
38
|
return { supportsStore: false };
|
|
28
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
|
+
}
|
|
29
78
|
function withTimeout(timeoutMs, signal) {
|
|
30
79
|
const controller = new AbortController();
|
|
31
80
|
const onAbort = () => controller.abort(signal?.reason);
|
|
@@ -61,6 +110,59 @@ async function fetchJson(url, apiKey, options) {
|
|
|
61
110
|
cancel();
|
|
62
111
|
}
|
|
63
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
|
+
}
|
|
64
166
|
function mapFromModelInfo(entry) {
|
|
65
167
|
const id = entry.model_name;
|
|
66
168
|
if (!id)
|
|
@@ -84,18 +186,21 @@ function mapFromModelInfo(entry) {
|
|
|
84
186
|
compat: buildCompat(id),
|
|
85
187
|
};
|
|
86
188
|
}
|
|
87
|
-
function mapFromModelsList(entry) {
|
|
189
|
+
function mapFromModelsList(entry, modelsDev) {
|
|
88
190
|
const id = entry.id;
|
|
89
191
|
if (!id)
|
|
90
192
|
return undefined;
|
|
193
|
+
const catalogModel = findCatalogModel(id, entry.owned_by);
|
|
194
|
+
const modelsDevMetadata = mapModelsDevMetadata(findModelsDevModel(modelsDev, id, entry.owned_by));
|
|
91
195
|
return {
|
|
92
196
|
id,
|
|
93
|
-
name: `${id} (no metadata)`,
|
|
94
|
-
reasoning: false,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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,
|
|
99
204
|
compat: buildCompat(id),
|
|
100
205
|
};
|
|
101
206
|
}
|
|
@@ -115,8 +220,9 @@ export async function discoverModels(baseUrl, apiKey, options = {}) {
|
|
|
115
220
|
if (!listResult.ok) {
|
|
116
221
|
throw new Error(`/v1/models returned ${listResult.status}`);
|
|
117
222
|
}
|
|
223
|
+
const modelsDev = await getModelsDevCatalog(options);
|
|
118
224
|
const models = (listResult.data.data ?? [])
|
|
119
|
-
.map(mapFromModelsList)
|
|
225
|
+
.map((entry) => mapFromModelsList(entry, modelsDev))
|
|
120
226
|
.filter((m) => m !== undefined);
|
|
121
227
|
return { source: "models_list", models };
|
|
122
228
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { AuthStorage, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { fingerprint, readCache, writeCache } from "./cache.js";
|
|
5
5
|
import { setupLiteLLMCostTracking } from "./cost.js";
|
|
6
|
-
import { discoverModels, normalizeBaseUrl } from "./discover.js";
|
|
6
|
+
import { discoverModels, normalizeBaseUrl, shouldSuppressReasoningContent } from "./discover.js";
|
|
7
7
|
import { getSessionIdFromFile } from "./litellm.js";
|
|
8
8
|
const PROVIDER_NAME = "litellm";
|
|
9
9
|
const ENV_BASE_URL = "LITELLM_BASE_URL";
|
|
@@ -13,6 +13,7 @@ const ENV_OFFLINE = "LITELLM_OFFLINE";
|
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
14
|
const LOGIN_TIMEOUT_MS = 10_000;
|
|
15
15
|
const CACHE_FILENAME = "litellm-models.json";
|
|
16
|
+
const CACHE_STALE_MS = 24 * 60 * 60 * 1000;
|
|
16
17
|
function getAuthPath() {
|
|
17
18
|
return join(getAgentDir(), "auth.json");
|
|
18
19
|
}
|
|
@@ -72,7 +73,7 @@ async function discoverWithFallback(baseUrl, apiKey, options) {
|
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
async function loginLiteLLM(callbacks) {
|
|
76
|
+
async function loginLiteLLM(callbacks, onCacheWrite) {
|
|
76
77
|
const rawBaseUrl = (await callbacks.onPrompt({
|
|
77
78
|
message: "Enter LiteLLM proxy URL (no trailing /v1):",
|
|
78
79
|
placeholder: "https://litellm.example.com",
|
|
@@ -85,13 +86,15 @@ async function loginLiteLLM(callbacks) {
|
|
|
85
86
|
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
86
87
|
signal: callbacks.signal,
|
|
87
88
|
});
|
|
88
|
-
|
|
89
|
+
const cache = {
|
|
89
90
|
baseUrl,
|
|
90
91
|
apiKeyFingerprint: fingerprint(apiKey),
|
|
91
92
|
fetchedAt: Date.now(),
|
|
92
93
|
source,
|
|
93
94
|
models,
|
|
94
|
-
}
|
|
95
|
+
};
|
|
96
|
+
await writeCache(getCachePath(), cache);
|
|
97
|
+
onCacheWrite?.(cache);
|
|
95
98
|
callbacks.onProgress?.(`LiteLLM: ${models.length} models discovered (source: ${source})`);
|
|
96
99
|
return {
|
|
97
100
|
access: apiKey,
|
|
@@ -109,10 +112,92 @@ function modifyLiteLLMModels(models, cred) {
|
|
|
109
112
|
return models;
|
|
110
113
|
return models.map((m) => (m.provider === PROVIDER_NAME ? { ...m, baseUrl: `${baseUrl}/v1` } : m));
|
|
111
114
|
}
|
|
115
|
+
function prepareLiteLLMRequestPayload(payload, modelId, sessionId) {
|
|
116
|
+
let next;
|
|
117
|
+
const update = (key, value) => {
|
|
118
|
+
if (payload[key] !== undefined)
|
|
119
|
+
return;
|
|
120
|
+
next ??= { ...payload };
|
|
121
|
+
next[key] = value;
|
|
122
|
+
};
|
|
123
|
+
if (modelId && shouldSuppressReasoningContent(modelId)) {
|
|
124
|
+
update("include_reasoning", false);
|
|
125
|
+
update("reasoning_content", false);
|
|
126
|
+
update("merge_reasoning_content_in_choices", true);
|
|
127
|
+
update("thinking", { type: "disabled" });
|
|
128
|
+
}
|
|
129
|
+
if (sessionId) {
|
|
130
|
+
next ??= { ...payload };
|
|
131
|
+
next.litellm_session_id = sessionId;
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
function normalizeThinkTags(message) {
|
|
136
|
+
if (message.provider !== PROVIDER_NAME || !shouldSuppressReasoningContent(message.model))
|
|
137
|
+
return;
|
|
138
|
+
let changed = false;
|
|
139
|
+
const content = [];
|
|
140
|
+
const appendText = (text) => {
|
|
141
|
+
if (!text)
|
|
142
|
+
return;
|
|
143
|
+
const last = content.at(-1);
|
|
144
|
+
if (last?.type === "text") {
|
|
145
|
+
last.text += text;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
content.push({ type: "text", text });
|
|
149
|
+
};
|
|
150
|
+
const appendThinking = (thinking) => {
|
|
151
|
+
if (!thinking)
|
|
152
|
+
return;
|
|
153
|
+
const last = content.at(-1);
|
|
154
|
+
if (last?.type === "thinking") {
|
|
155
|
+
last.thinking += thinking;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
content.push({ type: "thinking", thinking });
|
|
159
|
+
};
|
|
160
|
+
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
|
|
161
|
+
const block = message.content[blockIndex];
|
|
162
|
+
if (block.type !== "text") {
|
|
163
|
+
content.push(block);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let index = 0;
|
|
167
|
+
while (index < block.text.length) {
|
|
168
|
+
const start = block.text.indexOf("<think>", index);
|
|
169
|
+
if (start === -1) {
|
|
170
|
+
appendText(block.text.slice(index));
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
changed = true;
|
|
174
|
+
appendText(block.text.slice(index, start));
|
|
175
|
+
const thinkingStart = start + "<think>".length;
|
|
176
|
+
const end = block.text.indexOf("</think>", thinkingStart);
|
|
177
|
+
if (end === -1) {
|
|
178
|
+
const isBeforeNonTextContent = message.content
|
|
179
|
+
.slice(blockIndex + 1)
|
|
180
|
+
.some((nextBlock) => nextBlock.type !== "text");
|
|
181
|
+
if (isBeforeNonTextContent)
|
|
182
|
+
appendThinking(block.text.slice(thinkingStart));
|
|
183
|
+
else
|
|
184
|
+
appendText(block.text.slice(thinkingStart));
|
|
185
|
+
index = block.text.length;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
appendThinking(block.text.slice(thinkingStart, end));
|
|
189
|
+
index = end + "</think>".length;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!changed)
|
|
193
|
+
return;
|
|
194
|
+
return { ...message, content };
|
|
195
|
+
}
|
|
112
196
|
export default async function (pi) {
|
|
113
197
|
const creds = await resolveCredentials();
|
|
114
198
|
const cache = await readCache(getCachePath());
|
|
115
199
|
const fp = creds.apiKey ? fingerprint(creds.apiKey) : undefined;
|
|
200
|
+
let cacheFetchedAt = cache?.fetchedAt ?? 0;
|
|
116
201
|
const cacheValid = cache !== null &&
|
|
117
202
|
creds.baseUrl !== undefined &&
|
|
118
203
|
fp !== undefined &&
|
|
@@ -147,6 +232,7 @@ export default async function (pi) {
|
|
|
147
232
|
models: result.models,
|
|
148
233
|
};
|
|
149
234
|
await writeCache(getCachePath(), next);
|
|
235
|
+
cacheFetchedAt = next.fetchedAt;
|
|
150
236
|
if (isListModelsMode()) {
|
|
151
237
|
process.stderr.write(`LiteLLM: ${result.models.length} models discovered (source: ${result.source}).\n`);
|
|
152
238
|
}
|
|
@@ -155,52 +241,68 @@ export default async function (pi) {
|
|
|
155
241
|
}
|
|
156
242
|
const oauth = {
|
|
157
243
|
name: "LiteLLM",
|
|
158
|
-
login: loginLiteLLM,
|
|
244
|
+
login: (callbacks) => loginLiteLLM(callbacks, (next) => {
|
|
245
|
+
cacheFetchedAt = next.fetchedAt;
|
|
246
|
+
}),
|
|
159
247
|
refreshToken: refreshLiteLLM,
|
|
160
248
|
getApiKey: (cred) => cred.access,
|
|
161
249
|
modifyModels: modifyLiteLLMModels,
|
|
162
250
|
};
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
251
|
+
function registerProvider(baseUrl, models) {
|
|
252
|
+
pi.registerProvider(PROVIDER_NAME, {
|
|
253
|
+
baseUrl: baseUrl ? `${baseUrl}/v1` : "https://litellm.example.com/v1",
|
|
254
|
+
apiKey: ENV_API_KEY,
|
|
255
|
+
api: "openai-completions",
|
|
256
|
+
models,
|
|
257
|
+
oauth,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
registerProvider(creds.baseUrl, models);
|
|
261
|
+
const updateCosts = setupLiteLLMCostTracking(pi, models);
|
|
262
|
+
let refreshInProgress = null;
|
|
263
|
+
function discoveryDisabledReason() {
|
|
264
|
+
if (isOffline())
|
|
265
|
+
return `${ENV_OFFLINE}=1`;
|
|
266
|
+
if (getDiscoveryTimeoutMs() === 0)
|
|
267
|
+
return `${ENV_TIMEOUT}=0`;
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
async function refreshModelsAndCosts() {
|
|
271
|
+
const fresh = await resolveCredentials();
|
|
272
|
+
const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
|
|
273
|
+
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
274
|
+
throw new Error("no credentials. Run /login litellm or set env vars.");
|
|
275
|
+
}
|
|
276
|
+
const result = await discoverModels(fresh.baseUrl, fresh.apiKey, { timeoutMs: getDiscoveryTimeoutMs() });
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
await writeCache(getCachePath(), {
|
|
279
|
+
baseUrl: fresh.baseUrl,
|
|
280
|
+
apiKeyFingerprint: freshFp,
|
|
281
|
+
fetchedAt: now,
|
|
282
|
+
source: result.source,
|
|
283
|
+
models: result.models,
|
|
284
|
+
});
|
|
285
|
+
registerProvider(fresh.baseUrl, result.models);
|
|
286
|
+
updateCosts(result.models);
|
|
287
|
+
cacheFetchedAt = now;
|
|
288
|
+
return { models: result.models, source: result.source };
|
|
289
|
+
}
|
|
290
|
+
function runRefresh() {
|
|
291
|
+
refreshInProgress ??= refreshModelsAndCosts().finally(() => {
|
|
292
|
+
refreshInProgress = null;
|
|
293
|
+
});
|
|
294
|
+
return refreshInProgress;
|
|
295
|
+
}
|
|
170
296
|
pi.registerCommand("litellm-refresh", {
|
|
171
297
|
description: "Re-discover models from the LiteLLM proxy.",
|
|
172
298
|
handler: async (_args, ctx) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
const timeoutMs = getDiscoveryTimeoutMs();
|
|
178
|
-
if (timeoutMs === 0) {
|
|
179
|
-
ctx.ui.notify(`LiteLLM refresh disabled (${ENV_TIMEOUT}=0)`, "warning");
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const fresh = await resolveCredentials();
|
|
183
|
-
const freshFp = fresh.apiKey ? fingerprint(fresh.apiKey) : undefined;
|
|
184
|
-
if (!fresh.baseUrl || !fresh.apiKey || !freshFp) {
|
|
185
|
-
ctx.ui.notify("LiteLLM refresh failed: no credentials. Run /login litellm or set env vars.", "error");
|
|
299
|
+
const disabledReason = discoveryDisabledReason();
|
|
300
|
+
if (disabledReason) {
|
|
301
|
+
ctx.ui.notify(`LiteLLM refresh disabled (${disabledReason})`, "warning");
|
|
186
302
|
return;
|
|
187
303
|
}
|
|
188
304
|
try {
|
|
189
|
-
const result = await
|
|
190
|
-
await writeCache(getCachePath(), {
|
|
191
|
-
baseUrl: fresh.baseUrl,
|
|
192
|
-
apiKeyFingerprint: freshFp,
|
|
193
|
-
fetchedAt: Date.now(),
|
|
194
|
-
source: result.source,
|
|
195
|
-
models: result.models,
|
|
196
|
-
});
|
|
197
|
-
pi.registerProvider(PROVIDER_NAME, {
|
|
198
|
-
baseUrl: `${fresh.baseUrl}/v1`,
|
|
199
|
-
apiKey: ENV_API_KEY,
|
|
200
|
-
api: "openai-completions",
|
|
201
|
-
models: result.models,
|
|
202
|
-
oauth,
|
|
203
|
-
});
|
|
305
|
+
const result = await runRefresh();
|
|
204
306
|
ctx.ui.notify(`LiteLLM: ${result.models.length} models refreshed (source: ${result.source})`, "info");
|
|
205
307
|
}
|
|
206
308
|
catch (error) {
|
|
@@ -209,19 +311,26 @@ export default async function (pi) {
|
|
|
209
311
|
}
|
|
210
312
|
},
|
|
211
313
|
});
|
|
212
|
-
setupLiteLLMCostTracking(pi);
|
|
213
314
|
let sessionId;
|
|
214
|
-
pi.on("session_start",
|
|
315
|
+
pi.on("session_start", (_event, ctx) => {
|
|
215
316
|
sessionId = getSessionIdFromFile(ctx.sessionManager.getSessionFile());
|
|
317
|
+
if (discoveryDisabledReason() || !cacheFetchedAt || Date.now() - cacheFetchedAt <= CACHE_STALE_MS)
|
|
318
|
+
return;
|
|
319
|
+
void runRefresh().catch(() => undefined);
|
|
216
320
|
});
|
|
217
|
-
pi.on("before_provider_request", (event) => {
|
|
218
|
-
if (
|
|
321
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
322
|
+
if (ctx.model?.provider !== PROVIDER_NAME)
|
|
219
323
|
return;
|
|
220
324
|
if (typeof event.payload !== "object" || event.payload === null)
|
|
221
325
|
return;
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
326
|
+
return prepareLiteLLMRequestPayload(event.payload, ctx.model?.id, sessionId);
|
|
327
|
+
});
|
|
328
|
+
pi.on("message_end", (event) => {
|
|
329
|
+
if (event.message.role !== "assistant")
|
|
330
|
+
return;
|
|
331
|
+
const message = normalizeThinkTags(event.message);
|
|
332
|
+
if (!message)
|
|
333
|
+
return;
|
|
334
|
+
return { message };
|
|
226
335
|
});
|
|
227
336
|
}
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-litellm",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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": "^0.
|
|
58
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
58
|
+
"@earendil-works/pi-ai": "^0.75.4",
|
|
59
|
+
"@earendil-works/pi-coding-agent": "^0.75.4"
|
|
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.75.4",
|
|
68
|
+
"@earendil-works/pi-coding-agent": "^0.75.4",
|
|
69
|
+
"@biomejs/biome": "^2.4.15",
|
|
70
|
+
"@types/node": "^25.9.1",
|
|
71
|
+
"@typescript/native-preview": "7.0.0-dev.20260519.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
|
}
|