pi-provider-litellm 2.2.1 → 3.0.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/README.md +173 -25
- package/package.json +14 -19
- package/src/backend-identity.ts +100 -0
- package/src/cost.ts +52 -0
- package/src/discover.ts +1318 -0
- package/src/gcloud-token.ts +160 -0
- package/src/index.ts +2005 -0
- package/src/mcp-tools.ts +1341 -0
- package/src/model-groups.ts +947 -0
- package/src/protocols.ts +42 -0
- package/src/provider.ts +248 -0
- package/src/public-catalog.ts +272 -0
- package/src/skills.ts +275 -0
- package/src/thinking-levels.ts +35 -0
- package/src/types.ts +162 -0
- package/dist/cost.d.ts +0 -2
- package/dist/cost.js +0 -49
- package/dist/discover.d.ts +0 -12
- package/dist/discover.js +0 -333
- package/dist/gcloud-token-cli.d.ts +0 -1
- package/dist/gcloud-token-cli.js +0 -8
- package/dist/gcloud-token.d.ts +0 -7
- package/dist/gcloud-token.js +0 -133
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -1184
- package/dist/litellm.d.ts +0 -1
- package/dist/litellm.js +0 -10
- package/dist/mcp-tools.d.ts +0 -5
- package/dist/mcp-tools.js +0 -181
- package/dist/provider.d.ts +0 -16
- package/dist/provider.js +0 -40
- package/dist/skills.d.ts +0 -14
- package/dist/skills.js +0 -225
- package/dist/types.d.ts +0 -100
- package/dist/types.js +0 -1
package/src/protocols.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProviderStreams } from "@earendil-works/pi-ai";
|
|
2
|
+
import { anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import { normalizeBaseUrl } from "./discover.js";
|
|
4
|
+
import type { LiteLLMApi } from "./types.js";
|
|
5
|
+
|
|
6
|
+
type LiteLLMProtocol = {
|
|
7
|
+
createApi: () => ProviderStreams;
|
|
8
|
+
modelBaseUrl(root: string): string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const LITELLM_PROTOCOLS = {
|
|
12
|
+
"anthropic-messages": {
|
|
13
|
+
createApi: anthropicMessagesApi,
|
|
14
|
+
modelBaseUrl: (root) => root,
|
|
15
|
+
},
|
|
16
|
+
"openai-completions": {
|
|
17
|
+
createApi: openAICompletionsApi,
|
|
18
|
+
modelBaseUrl: (root) => `${root}/v1`,
|
|
19
|
+
},
|
|
20
|
+
"openai-responses": {
|
|
21
|
+
createApi: openAIResponsesApi,
|
|
22
|
+
modelBaseUrl: (root) => `${root}/v1`,
|
|
23
|
+
},
|
|
24
|
+
} satisfies Record<LiteLLMApi, LiteLLMProtocol>;
|
|
25
|
+
|
|
26
|
+
export const LITELLM_API_NAMES = Object.keys(LITELLM_PROTOCOLS) as LiteLLMApi[];
|
|
27
|
+
|
|
28
|
+
export function isLiteLLMApi(api: unknown): api is LiteLLMApi {
|
|
29
|
+
return typeof api === "string" && Object.hasOwn(LITELLM_PROTOCOLS, api);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveModelBaseUrl(baseUrl: string, api: LiteLLMApi, allowInsecureHttp = false): string {
|
|
33
|
+
return LITELLM_PROTOCOLS[api].modelBaseUrl(normalizeBaseUrl(baseUrl, allowInsecureHttp));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLiteLLMProtocolApis(): Record<LiteLLMApi, ProviderStreams> {
|
|
37
|
+
return {
|
|
38
|
+
"anthropic-messages": LITELLM_PROTOCOLS["anthropic-messages"].createApi(),
|
|
39
|
+
"openai-completions": LITELLM_PROTOCOLS["openai-completions"].createApi(),
|
|
40
|
+
"openai-responses": LITELLM_PROTOCOLS["openai-responses"].createApi(),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ApiStreamOptions,
|
|
3
|
+
type Context,
|
|
4
|
+
type Credential,
|
|
5
|
+
createProvider,
|
|
6
|
+
type Model,
|
|
7
|
+
type Provider,
|
|
8
|
+
type ProviderAuth,
|
|
9
|
+
type SimpleStreamOptions,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
|
|
12
|
+
import { enrichCachedModel, normalizeBaseUrl, restoreCachedModelPolicy } from "./discover.js";
|
|
13
|
+
import { createLiteLLMProtocolApis, isLiteLLMApi, LITELLM_API_NAMES, resolveModelBaseUrl } from "./protocols.js";
|
|
14
|
+
import type { DiscoveredModel, DiscoveryResult, LiteLLMApi, LiteLLMModel } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export type LiteLLMProviderOptions = {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
auth: ProviderAuth;
|
|
21
|
+
/** Catalog discovered during activation. Pi's startup refresh never allows network access, so
|
|
22
|
+
* without a seed the provider stays empty until the user opens /model. */
|
|
23
|
+
models?: readonly LiteLLMModel[];
|
|
24
|
+
allowInsecureHttp?: boolean;
|
|
25
|
+
resolveCredentialRoot: (credential?: Credential, requestBaseUrl?: string, apiKey?: string) => string | undefined;
|
|
26
|
+
discover(credential: Credential, signal?: AbortSignal): Promise<DiscoveryResult & { baseUrl?: string }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function toNativeModels(
|
|
30
|
+
provider: string,
|
|
31
|
+
baseUrl: string,
|
|
32
|
+
models: readonly DiscoveredModel[],
|
|
33
|
+
allowInsecureHttp = false,
|
|
34
|
+
): LiteLLMModel[] {
|
|
35
|
+
return models.map((model) => ({
|
|
36
|
+
...model,
|
|
37
|
+
provider,
|
|
38
|
+
baseUrl: resolveModelBaseUrl(baseUrl, model.api, allowInsecureHttp),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_LITELLM_BASE_URL = "https://litellm.example.com";
|
|
43
|
+
const CHAT_TOOL_CAP = 128;
|
|
44
|
+
const PLACEHOLDER_HOSTNAMES = new Set([canonicalHostname(new URL(DEFAULT_LITELLM_BASE_URL).hostname)]);
|
|
45
|
+
|
|
46
|
+
function canonicalHostname(hostname: string): string {
|
|
47
|
+
return hostname.replace(/\.$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function refreshRequired(message: string): Error {
|
|
51
|
+
return new Error(`${message}; a network refresh with a valid LiteLLM base URL is required`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function proxyRoot(
|
|
55
|
+
baseUrl: string,
|
|
56
|
+
subject: string,
|
|
57
|
+
allowInsecureHttp = false,
|
|
58
|
+
verb = "has",
|
|
59
|
+
): { root: string; canonical: string; hostname: string } {
|
|
60
|
+
try {
|
|
61
|
+
const root = normalizeBaseUrl(baseUrl, allowInsecureHttp);
|
|
62
|
+
const url = new URL(root);
|
|
63
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
|
|
64
|
+
return { root, canonical: url.href, hostname: url.hostname };
|
|
65
|
+
} catch {
|
|
66
|
+
throw refreshRequired(`${subject} ${verb} an invalid LiteLLM model URL`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function activeCredentialRoot(
|
|
71
|
+
root: string,
|
|
72
|
+
allowInsecureHttp = false,
|
|
73
|
+
): { root: string; canonical: string; hostname: string } {
|
|
74
|
+
const active = proxyRoot(root, "Active credentials", allowInsecureHttp, "have");
|
|
75
|
+
if (isPlaceholderHost(active.hostname)) {
|
|
76
|
+
throw refreshRequired("Active credentials use a placeholder LiteLLM model host");
|
|
77
|
+
}
|
|
78
|
+
return active;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isPlaceholderHost(hostname: string): boolean {
|
|
82
|
+
return PLACEHOLDER_HOSTNAMES.has(canonicalHostname(hostname));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function modelRootError(
|
|
86
|
+
model: Model<LiteLLMApi>,
|
|
87
|
+
active: { root: string; canonical: string; hostname: string },
|
|
88
|
+
allowInsecureHttp = false,
|
|
89
|
+
): Error | undefined {
|
|
90
|
+
if (!isLiteLLMApi(model.api)) {
|
|
91
|
+
return new Error(
|
|
92
|
+
`LiteLLM model ${model.id} declares unsupported protocol "${String(model.api)}"; ` +
|
|
93
|
+
`set "api" to one of ${LITELLM_API_NAMES.join(", ")} in models.json`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
let stored: { root: string; canonical: string; hostname: string };
|
|
97
|
+
try {
|
|
98
|
+
stored = proxyRoot(model.baseUrl, "Cached model", allowInsecureHttp);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return error instanceof Error ? error : refreshRequired("Cached model has an invalid LiteLLM model URL");
|
|
101
|
+
}
|
|
102
|
+
if (isPlaceholderHost(stored.hostname)) {
|
|
103
|
+
return refreshRequired("Cached model uses a placeholder LiteLLM model host");
|
|
104
|
+
}
|
|
105
|
+
if (stored.canonical !== active.canonical) {
|
|
106
|
+
return refreshRequired(
|
|
107
|
+
`Cached model has stale LiteLLM model root ${stored.root}; active credentials use ${active.root}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function requestModel(
|
|
113
|
+
provider: string,
|
|
114
|
+
model: Model<LiteLLMApi>,
|
|
115
|
+
context: Context,
|
|
116
|
+
credentialRoot: string | undefined,
|
|
117
|
+
allowInsecureHttp = false,
|
|
118
|
+
): Model<LiteLLMApi> {
|
|
119
|
+
if (!credentialRoot) throw refreshRequired("Active credentials do not identify a LiteLLM model host");
|
|
120
|
+
const active = activeCredentialRoot(credentialRoot, allowInsecureHttp);
|
|
121
|
+
const error = modelRootError(model, active, allowInsecureHttp);
|
|
122
|
+
if (error) throw error;
|
|
123
|
+
if (
|
|
124
|
+
model.api === "openai-completions" &&
|
|
125
|
+
(model as LiteLLMModel).litellmBackendFamily === "openai" &&
|
|
126
|
+
context.tools &&
|
|
127
|
+
context.tools.length > CHAT_TOOL_CAP
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`LiteLLM model ${model.id} uses Chat Completions with ${context.tools.length} tools, exceeding the ` +
|
|
131
|
+
`${CHAT_TOOL_CAP}-tool cap; route this model via Responses or reduce enabled extensions`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return { ...model, provider, baseUrl: resolveModelBaseUrl(active.root, model.api, allowInsecureHttp) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createLiteLLMProvider(options: LiteLLMProviderOptions): Provider<LiteLLMApi> {
|
|
138
|
+
const reportedAvailabilityDiagnostics = new Set<string>();
|
|
139
|
+
const reportUnavailable = (message: string): void => {
|
|
140
|
+
if (reportedAvailabilityDiagnostics.has(message)) return;
|
|
141
|
+
reportedAvailabilityDiagnostics.add(message);
|
|
142
|
+
process.stderr.write(`LiteLLM (${options.id}): ${message}\n`);
|
|
143
|
+
};
|
|
144
|
+
const provider = createProvider<LiteLLMApi>({
|
|
145
|
+
id: options.id,
|
|
146
|
+
name: options.name,
|
|
147
|
+
baseUrl: options.baseUrl,
|
|
148
|
+
auth: options.auth,
|
|
149
|
+
models: options.models ?? [],
|
|
150
|
+
async fetchModels(context) {
|
|
151
|
+
if (!context.credential) throw new Error("LiteLLM model discovery requires a credential");
|
|
152
|
+
const result = await options.discover(context.credential, context.signal);
|
|
153
|
+
return toNativeModels(options.id, result.baseUrl ?? options.baseUrl, result.models, options.allowInsecureHttp);
|
|
154
|
+
},
|
|
155
|
+
filterModels(models, credential) {
|
|
156
|
+
let active: { root: string; canonical: string; hostname: string };
|
|
157
|
+
try {
|
|
158
|
+
const root = options.resolveCredentialRoot(credential);
|
|
159
|
+
if (!root) {
|
|
160
|
+
if (models.length > 0) {
|
|
161
|
+
reportUnavailable(
|
|
162
|
+
`${models.length} model(s) hidden because no LiteLLM base URL is configured; ` +
|
|
163
|
+
"set LITELLM_BASE_URL or run /login litellm",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
active = activeCredentialRoot(root, options.allowInsecureHttp);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
reportUnavailable(error instanceof Error ? error.message : String(error));
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const available: Model<LiteLLMApi>[] = [];
|
|
174
|
+
for (const model of models) {
|
|
175
|
+
const error = modelRootError(model, active, options.allowInsecureHttp);
|
|
176
|
+
if (error) {
|
|
177
|
+
reportUnavailable(error.message);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
available.push({
|
|
181
|
+
...model,
|
|
182
|
+
baseUrl: resolveModelBaseUrl(active.root, model.api, options.allowInsecureHttp),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return available;
|
|
186
|
+
},
|
|
187
|
+
api: createLiteLLMProtocolApis(),
|
|
188
|
+
});
|
|
189
|
+
const refreshModels = provider.refreshModels;
|
|
190
|
+
const guardedProvider: Provider<LiteLLMApi> = {
|
|
191
|
+
...provider,
|
|
192
|
+
stream: <T extends LiteLLMApi>(model: Model<T>, context: Context, requestOptions?: ApiStreamOptions<T>) =>
|
|
193
|
+
provider.stream(
|
|
194
|
+
requestModel(
|
|
195
|
+
options.id,
|
|
196
|
+
model,
|
|
197
|
+
context,
|
|
198
|
+
options.resolveCredentialRoot(undefined, requestOptions?.env?.LITELLM_BASE_URL, requestOptions?.apiKey),
|
|
199
|
+
options.allowInsecureHttp,
|
|
200
|
+
),
|
|
201
|
+
context,
|
|
202
|
+
requestOptions,
|
|
203
|
+
),
|
|
204
|
+
streamSimple: (model: Model<LiteLLMApi>, context: Context, requestOptions?: SimpleStreamOptions) =>
|
|
205
|
+
provider.streamSimple(
|
|
206
|
+
requestModel(
|
|
207
|
+
options.id,
|
|
208
|
+
model,
|
|
209
|
+
context,
|
|
210
|
+
options.resolveCredentialRoot(undefined, requestOptions?.env?.LITELLM_BASE_URL, requestOptions?.apiKey),
|
|
211
|
+
options.allowInsecureHttp,
|
|
212
|
+
),
|
|
213
|
+
context,
|
|
214
|
+
requestOptions,
|
|
215
|
+
),
|
|
216
|
+
};
|
|
217
|
+
if (!refreshModels) return guardedProvider;
|
|
218
|
+
return {
|
|
219
|
+
...guardedProvider,
|
|
220
|
+
refreshModels: async (context) => {
|
|
221
|
+
const storedModels = context.stored?.models ?? [];
|
|
222
|
+
let legacyCount = 0;
|
|
223
|
+
const models = storedModels.map((model) => {
|
|
224
|
+
if ((model as LiteLLMModel).litellmDiscoveryVersion !== LITELLM_DISCOVERY_VERSION) {
|
|
225
|
+
legacyCount++;
|
|
226
|
+
return restoreCachedModelPolicy(model);
|
|
227
|
+
}
|
|
228
|
+
return enrichCachedModel(model);
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
await refreshModels({
|
|
232
|
+
...context,
|
|
233
|
+
// The current pi-ai provider has no freshness gate, but force is the documented refresh contract field.
|
|
234
|
+
force: context.force || (context.allowNetwork && legacyCount > 0),
|
|
235
|
+
stored: context.stored && { ...context.stored, models },
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (context.allowNetwork && legacyCount > 0) {
|
|
239
|
+
reportUnavailable(
|
|
240
|
+
`keeping cached models whose discovery metadata version does not match ` +
|
|
241
|
+
`${LITELLM_DISCOVERY_VERSION} because the required network refresh failed`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import { getModels, getProviders } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import type { BuiltinProvider } from "@earendil-works/pi-ai/providers/all";
|
|
6
|
+
|
|
7
|
+
export const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
8
|
+
const MODELS_DEV_CACHE_TTL_MS = 28 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
10
|
+
const KNOWN_PROVIDERS = new Set<string>(getProviders());
|
|
11
|
+
|
|
12
|
+
export interface PublicCatalogRecord {
|
|
13
|
+
source: "models.dev" | "pi-vendor" | "pi-adapter";
|
|
14
|
+
provider: string;
|
|
15
|
+
modelId: string;
|
|
16
|
+
limits?: { context?: number; output?: number };
|
|
17
|
+
cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
|
|
18
|
+
modalities?: Array<"text" | "image">;
|
|
19
|
+
effortLevels?: string[];
|
|
20
|
+
thinkingLevelMap?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PublicCatalog {
|
|
24
|
+
lookup(provider: string | undefined, id: string): PublicCatalogRecord | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ModelsDevModel {
|
|
28
|
+
modalities?: { input?: string[] };
|
|
29
|
+
limit?: { context?: number; input?: number; output?: number };
|
|
30
|
+
cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number };
|
|
31
|
+
reasoning_options?: { type?: string; values?: string[] } | Array<{ type?: string; values?: string[] }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ModelsDevCatalog = Record<string, { models?: Record<string, ModelsDevModel> }>;
|
|
35
|
+
interface CacheFile {
|
|
36
|
+
fetchedAt: number;
|
|
37
|
+
catalog: ModelsDevCatalog;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LoadPublicCatalogOptions {
|
|
41
|
+
cachePath?: string;
|
|
42
|
+
offline?: boolean;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const memoryCaches = new Map<string, CacheFile>();
|
|
49
|
+
const refreshes = new Map<string, Promise<ModelsDevCatalog | undefined>>();
|
|
50
|
+
|
|
51
|
+
const PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
52
|
+
anthropic: ["anthropic"],
|
|
53
|
+
azure: ["azure", "openai"],
|
|
54
|
+
azure_ai: ["azure", "openai"],
|
|
55
|
+
bedrock: ["amazon-bedrock"],
|
|
56
|
+
bedrock_converse: ["amazon-bedrock"],
|
|
57
|
+
deepseek: ["deepseek"],
|
|
58
|
+
fireworks: ["fireworks-ai"],
|
|
59
|
+
fireworks_ai: ["fireworks-ai"],
|
|
60
|
+
gemini: ["google"],
|
|
61
|
+
moonshot: ["moonshotai"],
|
|
62
|
+
nvidia_nim: ["nvidia"],
|
|
63
|
+
openai: ["openai"],
|
|
64
|
+
together_ai: ["together"],
|
|
65
|
+
vertex_ai: ["google-vertex"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PI_PROVIDER_ALIASES: Readonly<Record<string, readonly string[]>> = {
|
|
69
|
+
"amazon-bedrock": ["amazon-bedrock"],
|
|
70
|
+
azure: ["azure-openai-responses"],
|
|
71
|
+
chatgpt: ["openai-codex"],
|
|
72
|
+
"fireworks-ai": ["fireworks"],
|
|
73
|
+
openai: ["openai"],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function finite(value: unknown): number | undefined {
|
|
81
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A token price below zero is malformed catalog data, not a discount; treating it as
|
|
85
|
+
// missing lets the normal incomplete-cost handling apply instead of publishing it.
|
|
86
|
+
function price(value: unknown): number | undefined {
|
|
87
|
+
const parsed = finite(value);
|
|
88
|
+
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeCatalog(value: unknown): ModelsDevCatalog | undefined {
|
|
92
|
+
if (!isRecord(value)) return undefined;
|
|
93
|
+
const result: ModelsDevCatalog = {};
|
|
94
|
+
for (const [provider, rawProvider] of Object.entries(value)) {
|
|
95
|
+
if (!isRecord(rawProvider) || !isRecord(rawProvider.models)) continue;
|
|
96
|
+
const models: Record<string, ModelsDevModel> = {};
|
|
97
|
+
for (const [id, rawModel] of Object.entries(rawProvider.models)) {
|
|
98
|
+
if (!isRecord(rawModel)) continue;
|
|
99
|
+
models[id] = rawModel as ModelsDevModel;
|
|
100
|
+
}
|
|
101
|
+
result[provider] = { models };
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
109
|
+
await writeFile(temporaryPath, JSON.stringify(value, null, 2), "utf8");
|
|
110
|
+
await rename(temporaryPath, path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readCache(path: string): Promise<CacheFile | undefined> {
|
|
114
|
+
try {
|
|
115
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
116
|
+
if (!isRecord(parsed)) return undefined;
|
|
117
|
+
const fetchedAt = finite(parsed.fetchedAt);
|
|
118
|
+
if (fetchedAt === undefined || fetchedAt < 0 || fetchedAt > Date.now()) return undefined;
|
|
119
|
+
const catalog = normalizeCatalog(parsed.catalog);
|
|
120
|
+
return catalog ? { fetchedAt, catalog } : undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function refreshCatalog(key: string, options: LoadPublicCatalogOptions): Promise<ModelsDevCatalog | undefined> {
|
|
127
|
+
const active = refreshes.get(key);
|
|
128
|
+
if (active) return active;
|
|
129
|
+
const refresh = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
132
|
+
const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
133
|
+
const response = await fetchImpl(MODELS_DEV_URL, {
|
|
134
|
+
headers: { Accept: "application/json" },
|
|
135
|
+
signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) return undefined;
|
|
138
|
+
const catalog = normalizeCatalog(await response.json());
|
|
139
|
+
if (!catalog) return undefined;
|
|
140
|
+
const cache = { fetchedAt: Date.now(), catalog };
|
|
141
|
+
memoryCaches.set(key, cache);
|
|
142
|
+
if (options.cachePath) await writeJsonAtomic(options.cachePath, cache).catch(() => undefined);
|
|
143
|
+
return catalog;
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
} finally {
|
|
147
|
+
refreshes.delete(key);
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
refreshes.set(key, refresh);
|
|
151
|
+
return refresh;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function providerCandidates(provider: string | undefined): string[] {
|
|
155
|
+
if (!provider) return [];
|
|
156
|
+
const normalized = provider.trim().toLowerCase();
|
|
157
|
+
return [...new Set(PROVIDER_ALIASES[normalized] ?? [normalized])];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function lookupIds(id: string): string[] {
|
|
161
|
+
const result = new Set([id]);
|
|
162
|
+
if (id.includes("/")) result.add(id.slice(id.indexOf("/") + 1));
|
|
163
|
+
return [...result];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function mapModelsDev(provider: string, modelId: string, model: ModelsDevModel): PublicCatalogRecord {
|
|
167
|
+
const context = finite(model.limit?.context) ?? finite(model.limit?.input);
|
|
168
|
+
const output = finite(model.limit?.output);
|
|
169
|
+
const input = price(model.cost?.input);
|
|
170
|
+
const outputCost = price(model.cost?.output);
|
|
171
|
+
const cacheRead = price(model.cost?.cache_read);
|
|
172
|
+
const cacheWrite = price(model.cost?.cache_write);
|
|
173
|
+
const options = Array.isArray(model.reasoning_options) ? model.reasoning_options : [model.reasoning_options];
|
|
174
|
+
const effortLevels = options
|
|
175
|
+
.filter((option) => option?.type === "effort")
|
|
176
|
+
.flatMap((option) => option?.values ?? [])
|
|
177
|
+
.filter((value): value is string => typeof value === "string");
|
|
178
|
+
return {
|
|
179
|
+
source: "models.dev",
|
|
180
|
+
provider,
|
|
181
|
+
modelId,
|
|
182
|
+
...(context !== undefined || output !== undefined ? { limits: { context, output } } : {}),
|
|
183
|
+
...(input !== undefined || outputCost !== undefined || cacheRead !== undefined || cacheWrite !== undefined
|
|
184
|
+
? {
|
|
185
|
+
cost: {
|
|
186
|
+
...(input !== undefined ? { input } : {}),
|
|
187
|
+
...(outputCost !== undefined ? { output: outputCost } : {}),
|
|
188
|
+
...(cacheRead !== undefined ? { cacheRead } : {}),
|
|
189
|
+
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
193
|
+
...(Array.isArray(model.modalities?.input)
|
|
194
|
+
? { modalities: model.modalities.input.includes("image") ? ["text", "image"] : ["text"] }
|
|
195
|
+
: {}),
|
|
196
|
+
...(effortLevels.length > 0 ? { effortLevels } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function toPiRecord(source: "pi-vendor" | "pi-adapter", provider: string, model: Model<Api>): PublicCatalogRecord {
|
|
201
|
+
const thinkingLevelMap = model.thinkingLevelMap as Record<string, unknown> | undefined;
|
|
202
|
+
const effortLevels = thinkingLevelMap
|
|
203
|
+
? Object.entries(thinkingLevelMap)
|
|
204
|
+
.filter(([, value]) => value !== null)
|
|
205
|
+
.map(([level]) => level)
|
|
206
|
+
: [];
|
|
207
|
+
return {
|
|
208
|
+
source,
|
|
209
|
+
provider,
|
|
210
|
+
modelId: model.id,
|
|
211
|
+
limits: { context: model.contextWindow, output: model.maxTokens },
|
|
212
|
+
cost: {
|
|
213
|
+
input: model.cost.input,
|
|
214
|
+
output: model.cost.output,
|
|
215
|
+
cacheRead: model.cost.cacheRead,
|
|
216
|
+
cacheWrite: model.cost.cacheWrite,
|
|
217
|
+
},
|
|
218
|
+
modalities: model.input.includes("image") ? ["text", "image"] : ["text"],
|
|
219
|
+
...(effortLevels.length > 0 ? { effortLevels } : {}),
|
|
220
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findPiModel(provider: string, ids: readonly string[]): Model<Api> | undefined {
|
|
225
|
+
if (!KNOWN_PROVIDERS.has(provider)) return undefined;
|
|
226
|
+
const models = getModels(provider as BuiltinProvider);
|
|
227
|
+
return models.find((model) => ids.includes(model.id) || ids.includes(`${provider}/${model.id}`));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function loadPublicCatalog(options: LoadPublicCatalogOptions = {}): Promise<PublicCatalog> {
|
|
231
|
+
const key = options.cachePath ?? MODELS_DEV_URL;
|
|
232
|
+
let cache = memoryCaches.get(key);
|
|
233
|
+
if (!cache && options.cachePath) {
|
|
234
|
+
cache = await readCache(options.cachePath);
|
|
235
|
+
if (cache) memoryCaches.set(key, cache);
|
|
236
|
+
}
|
|
237
|
+
const offline = options.offline ?? process.env.LITELLM_OFFLINE === "1";
|
|
238
|
+
let catalog = cache?.catalog;
|
|
239
|
+
if (!offline) {
|
|
240
|
+
if (!cache) catalog = await refreshCatalog(key, options);
|
|
241
|
+
else if (Date.now() - cache.fetchedAt >= MODELS_DEV_CACHE_TTL_MS) void refreshCatalog(key, options);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
lookup(provider, id) {
|
|
246
|
+
const providers = providerCandidates(provider);
|
|
247
|
+
const ids = lookupIds(id);
|
|
248
|
+
for (const candidate of providers) {
|
|
249
|
+
const models = catalog?.[candidate]?.models;
|
|
250
|
+
for (const modelId of ids) {
|
|
251
|
+
const model = models?.[modelId];
|
|
252
|
+
if (model) return mapModelsDev(candidate, modelId, model);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const vendor = providers.find((candidate) => candidate !== "azure") ?? providers[0];
|
|
256
|
+
if (vendor) {
|
|
257
|
+
for (const piProvider of PI_PROVIDER_ALIASES[vendor] ?? [vendor]) {
|
|
258
|
+
const model = findPiModel(piProvider, ids);
|
|
259
|
+
if (model) return toPiRecord("pi-vendor", piProvider, model);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const adapter = provider?.trim().toLowerCase();
|
|
263
|
+
if (adapter) {
|
|
264
|
+
for (const piProvider of PI_PROVIDER_ALIASES[adapter] ?? [adapter]) {
|
|
265
|
+
const model = findPiModel(piProvider, ids);
|
|
266
|
+
if (model) return toPiRecord("pi-adapter", piProvider, model);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|