pi-provider-litellm 4.2.0 → 4.3.1

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/images.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type {
2
+ AssistantImages,
3
+ ImageApi,
4
+ ImageContent,
5
+ ImageModel,
6
+ ImagesOptions,
7
+ ProviderImages,
8
+ Usage,
9
+ } from "@earendil-works/pi-ai";
10
+ import { LITELLM_DISCOVERY_VERSION } from "./backend-identity.js";
11
+ import { wireString } from "./model-groups.js";
12
+ import type { ModelInfoEntry } from "./types.js";
13
+
14
+ /** Image generation through the proxy's OpenAI-compatible `/v1/images/generations` endpoint. */
15
+ export const LITELLM_IMAGES_API = "litellm-images" as const;
16
+ export type LiteLLMImageApi = typeof LITELLM_IMAGES_API;
17
+
18
+ export type DiscoveredImageModel = Omit<ImageModel<LiteLLMImageApi>, "provider" | "baseUrl"> & {
19
+ litellmDiscoveryVersion?: typeof LITELLM_DISCOVERY_VERSION;
20
+ };
21
+
22
+ export type LiteLLMImageModel = ImageModel<LiteLLMImageApi> & {
23
+ litellmDiscoveryVersion?: typeof LITELLM_DISCOVERY_VERSION;
24
+ };
25
+
26
+ const IMAGE_GENERATION_MODE_PATTERN = /^image_generation$/i;
27
+ // An image response carries base64 payloads that can be several MiB each; an
28
+ // error body is only ever read for its message, so cap what is kept of it.
29
+ const MAX_ERROR_DETAIL = 300;
30
+
31
+ export function isImageGenerationMode(mode: unknown): boolean {
32
+ const value = wireString(mode);
33
+ return value !== undefined && IMAGE_GENERATION_MODE_PATTERN.test(value.trim());
34
+ }
35
+
36
+ function perMillion(value: unknown): number | undefined {
37
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value * 1_000_000 : undefined;
38
+ }
39
+
40
+ /**
41
+ * Reduce one `/model/info` route group to an image model. Every deployment behind the
42
+ * route must declare `mode: "image_generation"`: LiteLLM load-balances across them, so a
43
+ * mixed group could answer an image request from a chat or embedding deployment. Groups
44
+ * with no readable mode, or any other mode, are left to the chat reducer, which already
45
+ * withholds every explicitly non-chat route.
46
+ */
47
+ export function mapFromImageModelInfoGroup(
48
+ route: string,
49
+ entries: readonly ModelInfoEntry[],
50
+ ): DiscoveredImageModel | undefined {
51
+ // A wildcard row is not addressable, and `/v1/models` expansion carries no mode evidence.
52
+ if (route.includes("*")) return undefined;
53
+ const deployments = entries.filter((entry) => wireString(entry.model_name) === route);
54
+ if (deployments.length === 0) return undefined;
55
+ if (!deployments.every((entry) => isImageGenerationMode(entry.model_info?.mode))) return undefined;
56
+ // Prices are estimates for Pi's usage display; LiteLLM's `x-litellm-response-cost` header,
57
+ // when present, replaces them per request. Take the highest rate any deployment declares,
58
+ // like the chat reducer, and report 0 when not every deployment declares one.
59
+ const rate = (read: (entry: ModelInfoEntry) => number | undefined): number => {
60
+ const values = deployments.map(read);
61
+ return values.every((value) => value !== undefined) ? Math.max(...(values as number[])) : 0;
62
+ };
63
+ return {
64
+ type: "image",
65
+ id: route,
66
+ name: route,
67
+ api: LITELLM_IMAGES_API,
68
+ // `/v1/images/generations` takes a text prompt only; reference images need `/v1/images/edits`.
69
+ input: ["text"],
70
+ output: ["image"],
71
+ cost: {
72
+ input: rate((entry) => perMillion(entry.model_info?.input_cost_per_token)),
73
+ output: rate(
74
+ (entry) =>
75
+ perMillion(entry.model_info?.output_cost_per_image_token) ??
76
+ perMillion(entry.model_info?.output_cost_per_token),
77
+ ),
78
+ cacheRead: 0,
79
+ cacheWrite: 0,
80
+ },
81
+ litellmDiscoveryVersion: LITELLM_DISCOVERY_VERSION,
82
+ };
83
+ }
84
+
85
+ type ImagesResponseEntry = { b64_json?: unknown; url?: unknown };
86
+ type ImagesResponse = {
87
+ data?: unknown;
88
+ output_format?: unknown;
89
+ usage?: { input_tokens?: unknown; output_tokens?: unknown; prompt_tokens?: unknown; completion_tokens?: unknown };
90
+ };
91
+
92
+ const FORMAT_MIME_TYPES: Record<string, string> = {
93
+ png: "image/png",
94
+ jpeg: "image/jpeg",
95
+ jpg: "image/jpeg",
96
+ webp: "image/webp",
97
+ gif: "image/gif",
98
+ };
99
+
100
+ // Base64 prefixes of each format's magic bytes, for responses without `output_format`
101
+ // (LiteLLM omits it for most non-OpenAI backends, e.g. Gemini returns JPEG).
102
+ const BASE64_SIGNATURES: [prefix: string, mimeType: string][] = [
103
+ ["iVBORw0KGgo", "image/png"],
104
+ ["/9j/", "image/jpeg"],
105
+ ["UklGR", "image/webp"],
106
+ ["R0lGOD", "image/gif"],
107
+ ];
108
+
109
+ function imageMimeType(data: string, outputFormat: unknown): string {
110
+ const declared = wireString(outputFormat)?.trim().toLowerCase();
111
+ if (declared && FORMAT_MIME_TYPES[declared]) return FORMAT_MIME_TYPES[declared];
112
+ return BASE64_SIGNATURES.find(([prefix]) => data.startsWith(prefix))?.[1] ?? "image/png";
113
+ }
114
+
115
+ function tokenCount(...values: unknown[]): number {
116
+ for (const value of values) {
117
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
118
+ }
119
+ return 0;
120
+ }
121
+
122
+ function parseUsage(
123
+ response: ImagesResponse,
124
+ model: ImageModel<ImageApi>,
125
+ responseCost: number | undefined,
126
+ ): Usage | undefined {
127
+ const raw = response.usage;
128
+ if (!raw && responseCost === undefined) return undefined;
129
+ const input = tokenCount(raw?.input_tokens, raw?.prompt_tokens);
130
+ const output = tokenCount(raw?.output_tokens, raw?.completion_tokens);
131
+ const estimated = {
132
+ input: (model.cost.input / 1_000_000) * input,
133
+ output: (model.cost.output / 1_000_000) * output,
134
+ };
135
+ // LiteLLM's own cost is authoritative: it knows per-image prices that a token rate cannot express.
136
+ const total = responseCost ?? estimated.input + estimated.output;
137
+ const estimatedTotal = estimated.input + estimated.output;
138
+ const share = (part: number): number =>
139
+ responseCost === undefined ? part : estimatedTotal > 0 ? (responseCost * part) / estimatedTotal : 0;
140
+ const outputCost = responseCost !== undefined && estimatedTotal === 0 ? responseCost : share(estimated.output);
141
+ return {
142
+ input,
143
+ output,
144
+ cacheRead: 0,
145
+ cacheWrite: 0,
146
+ totalTokens: input + output,
147
+ cost: { input: share(estimated.input), output: outputCost, cacheRead: 0, cacheWrite: 0, total },
148
+ };
149
+ }
150
+
151
+ function requestHeaders(
152
+ apiKey: string,
153
+ ...sources: (Record<string, string | null | undefined> | undefined)[]
154
+ ): Record<string, string> {
155
+ // Header names are case-insensitive: `fetch` would join `X-Tenant` and `x-tenant` into one
156
+ // comma-separated value, and a capitalized `Authorization` would merge with the bearer below.
157
+ const headers: Record<string, string> = {};
158
+ for (const source of sources) {
159
+ for (const [name, value] of Object.entries(source ?? {})) {
160
+ const normalizedName = name.toLowerCase();
161
+ if (typeof value === "string") headers[normalizedName] = value;
162
+ else delete headers[normalizedName];
163
+ }
164
+ }
165
+ headers.authorization = `Bearer ${apiKey}`;
166
+ headers["content-type"] = "application/json";
167
+ return headers;
168
+ }
169
+
170
+ function errorDetail(body: string): string {
171
+ try {
172
+ const parsed = JSON.parse(body) as { error?: { message?: unknown } | unknown };
173
+ const message =
174
+ typeof parsed.error === "object" && parsed.error !== null
175
+ ? wireString((parsed.error as { message?: unknown }).message)
176
+ : wireString(parsed.error);
177
+ if (message) return message.slice(0, MAX_ERROR_DETAIL);
178
+ } catch {
179
+ // Not JSON: fall through to the raw text.
180
+ }
181
+ return body.slice(0, MAX_ERROR_DETAIL);
182
+ }
183
+
184
+ function requestSignal(options: ImagesOptions | undefined): AbortSignal | undefined {
185
+ const signals = [
186
+ options?.signal,
187
+ options?.timeoutMs !== undefined ? AbortSignal.timeout(options.timeoutMs) : undefined,
188
+ ].filter((signal): signal is AbortSignal => signal !== undefined);
189
+ return signals.length > 1 ? AbortSignal.any(signals) : signals[0];
190
+ }
191
+
192
+ /** Generate images through LiteLLM's `/v1/images/generations`. Never rejects. */
193
+ export const generateImages: ProviderImages["generateImages"] = async (model, context, options) => {
194
+ const result: AssistantImages = {
195
+ api: model.api,
196
+ provider: model.provider,
197
+ model: model.id,
198
+ output: [],
199
+ stopReason: "stop",
200
+ timestamp: Date.now(),
201
+ };
202
+ try {
203
+ const apiKey = options?.apiKey;
204
+ if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
205
+ if (context.input.some((item) => item.type === "image")) {
206
+ throw new Error(
207
+ `LiteLLM image model ${model.id} takes a text prompt only; ` +
208
+ "reference images need /v1/images/edits, which this provider does not call",
209
+ );
210
+ }
211
+ const prompt = context.input
212
+ .map((item) => (item.type === "text" ? item.text : ""))
213
+ .filter((text) => text.trim().length > 0)
214
+ .join("\n\n");
215
+ if (!prompt) throw new Error(`LiteLLM image model ${model.id} needs a non-empty text prompt`);
216
+ let params: Record<string, unknown> = { model: model.id, prompt, n: 1 };
217
+ const nextParams = await options?.onPayload?.(params, model);
218
+ if (nextParams !== undefined) params = nextParams as Record<string, unknown>;
219
+ const response = await (options?.fetch ?? fetch)(`${model.baseUrl}/images/generations`, {
220
+ method: "POST",
221
+ headers: requestHeaders(apiKey, model.headers, options?.headers),
222
+ body: JSON.stringify(params),
223
+ signal: requestSignal(options),
224
+ });
225
+ const responseHeaders = Object.fromEntries(response.headers.entries());
226
+ await options?.onResponse?.({ status: response.status, headers: responseHeaders }, model);
227
+ const body = await response.text();
228
+ if (!response.ok) throw new Error(`LiteLLM images request failed (${response.status}): ${errorDetail(body)}`);
229
+ const parsed = JSON.parse(body) as ImagesResponse;
230
+ const entries = Array.isArray(parsed.data) ? (parsed.data as ImagesResponseEntry[]) : [];
231
+ for (const entry of entries) {
232
+ const data = wireString(entry?.b64_json);
233
+ if (!data) continue;
234
+ const image: ImageContent = { type: "image", data, mimeType: imageMimeType(data, parsed.output_format) };
235
+ result.output.push(image);
236
+ }
237
+ if (result.output.length === 0) {
238
+ // Remote URLs are not fetched: the URL host is chosen by the backend, not the proxy.
239
+ throw new Error(
240
+ entries.some((entry) => wireString(entry?.url))
241
+ ? `LiteLLM image model ${model.id} returned image URLs instead of base64 data`
242
+ : `LiteLLM image model ${model.id} returned no images`,
243
+ );
244
+ }
245
+ const costHeader = Number.parseFloat(responseHeaders["x-litellm-response-cost"] ?? "");
246
+ const usage = parseUsage(parsed, model, Number.isFinite(costHeader) && costHeader >= 0 ? costHeader : undefined);
247
+ if (usage) result.usage = usage;
248
+ return result;
249
+ } catch (error) {
250
+ result.stopReason = options?.signal?.aborted ? "aborted" : "error";
251
+ result.errorMessage = error instanceof Error ? error.message : String(error);
252
+ return result;
253
+ }
254
+ };
255
+
256
+ export function createLiteLLMImageApis(): Record<LiteLLMImageApi, ProviderImages> {
257
+ return { [LITELLM_IMAGES_API]: { generateImages } };
258
+ }