@eliotstocker/open-harness-plugin-openai-compatible-auto 0.0.0-development

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eliot Stocker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @eliotstocker/open-harness-plugin-openai-compatible-auto
2
+
3
+ Multi-host plugin for OpenAI-compatible endpoints with:
4
+ - Automatic `/v1/models` (and optional LiteLLM `/v1/model/info`) model discovery
5
+ - Browser-based LiteLLM sign-in via CLI code flow (`/.well-known/litellm-cli-auth` PKCE, `/sso/cli/start` + `/sso/cli/poll`, and `/sso/key/generate` fallback)
6
+ - API key + Base URL configuration
7
+
8
+ Supports **OpenCode V2**, **OpenWork V2**, **Pi**, and **DeepSeek Harness (DSH)** from one package.
9
+
10
+ ## Build
11
+
12
+ ```bash
13
+ npm install
14
+ npm run build
15
+ ```
16
+
17
+ ## Install & Use
18
+
19
+ ### OpenCode V2 / OpenWork V2
20
+ Add the package to `~/.config/opencode/opencode.json` (or `opencode.jsonc`):
21
+
22
+ ```jsonc
23
+ {
24
+ "plugin": [
25
+ "@eliotstocker/open-harness-plugin-openai-compatible-auto"
26
+ ]
27
+ }
28
+ ```
29
+
30
+ Then run `/connect` -> **OpenAI-Compatible (Auto)** -> **LiteLLM CLI Code Flow (Browser)** (or **API Key**), and `/models`.
31
+
32
+ ### Pi (`@earendil-works/pi-coding-agent`)
33
+ Install as a Pi package:
34
+
35
+ ```bash
36
+ pi install npm:@eliotstocker/open-harness-plugin-openai-compatible-auto
37
+ ```
38
+
39
+ Or test for one run:
40
+
41
+ ```bash
42
+ pi -e npm:@eliotstocker/open-harness-plugin-openai-compatible-auto
43
+ ```
44
+
45
+ Then run `/login openai-auto` (supports **LiteLLM CLI Code Flow (Browser)** and **OpenAI-Compatible API Key**), and `/model` to select auto-discovered models.
46
+
47
+ ### DeepSeek Harness (`@deepseek-ai/dsh`)
48
+ Add to a DSH profile (e.g. `web`, `acp`, `headless`):
49
+
50
+ ```bash
51
+ dsh plugin --profile web add @eliotstocker/open-harness-plugin-openai-compatible-auto
52
+ ```
53
+
54
+ Then sign in via `/login openai-auto` (or the Web UI Models/Auth panel) using **LiteLLM CLI Code Flow (Browser)** or **API Key + Base URL**, or configure `openai-auto` in `settings.yaml`.
55
+
56
+ ### GitHub Packages (optional)
57
+ To install from GitHub Packages instead of npm, configure `@eliotstocker:registry=https://npm.pkg.github.com` in `~/.npmrc`.
58
+
59
+ ## Exports
60
+
61
+ - `.` — OpenCode V2 plugin (`src/index.ts`)
62
+ - `./pi` — Pi extension (`src/pi.ts`, also declared under `pi.extensions`)
63
+ - `./dsh` — DSH Cordis bundle plugin (`src/dsh.ts`, wired via `cordis.patch.yml` and `dsh.bundle`)
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: openai-auto
3
+ name: '@eliotstocker/open-harness-plugin-openai-compatible-auto/dsh'
@@ -0,0 +1,56 @@
1
+ export declare const NEVER_EXPIRES: number;
2
+ export declare const PKCE_FLOW = "litellm_cli_pkce";
3
+ export interface LiteLLMTokens {
4
+ access: string;
5
+ refresh: string;
6
+ expires: number;
7
+ metadata: Record<string, unknown>;
8
+ }
9
+ export type LiteLLMInteractiveSession = {
10
+ mode: "auto";
11
+ url: string;
12
+ instructions: string;
13
+ expiresAt: number;
14
+ waitForToken(signal?: AbortSignal): Promise<LiteLLMTokens>;
15
+ } | {
16
+ mode: "code";
17
+ url: string;
18
+ instructions: string;
19
+ exchangeCode(code: string): Promise<LiteLLMTokens>;
20
+ };
21
+ export interface LiteLLMLoginCallbacks {
22
+ onAuth(url: string, instructions: string): void;
23
+ onPrompt(message: string, placeholder?: string): Promise<string>;
24
+ signal?: AbortSignal;
25
+ }
26
+ export declare function validateSsoRoot(baseURL: string): string;
27
+ /**
28
+ * Starts browser-based LiteLLM sign-in and returns a host-neutral interactive handle:
29
+ * 1) LiteLLM CLI-auth PKCE (`/.well-known/litellm-cli-auth`)
30
+ * 2) LiteLLM CLI SSO code flow (`/sso/cli/start` + `/sso/cli/poll`)
31
+ * 3) Pasted UI token fallback (`/sso/key/generate` + `/key/generate`)
32
+ */
33
+ export declare function startLiteLLMInteractive(baseURL: string, meta: {
34
+ providerID: string;
35
+ team?: string;
36
+ headers?: Record<string, string>;
37
+ clientName?: string;
38
+ signal?: AbortSignal;
39
+ }): Promise<LiteLLMInteractiveSession>;
40
+ /** Convenience runner for hosts whose login flow pushes notices/prompts directly (Pi, DSH). */
41
+ export declare function runLiteLLMLogin(baseURL: string, meta: {
42
+ providerID: string;
43
+ team?: string;
44
+ headers?: Record<string, string>;
45
+ clientName?: string;
46
+ }, callbacks: LiteLLMLoginCallbacks): Promise<LiteLLMTokens>;
47
+ export declare function refreshLiteLLMTokens(token: {
48
+ access: string;
49
+ refresh: string;
50
+ expires: number;
51
+ metadata?: Record<string, unknown>;
52
+ }, headers?: Record<string, string>, signal?: AbortSignal): Promise<{
53
+ access: string;
54
+ refresh: string;
55
+ expires: number;
56
+ }>;
@@ -0,0 +1,407 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { isIP } from "node:net";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { rootURL } from "./discovery.js";
6
+ const REQUEST_TIMEOUT_MS = 10_000;
7
+ const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
8
+ const POLL_INTERVAL_MS = 2_000;
9
+ const DEFAULT_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
10
+ export const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER;
11
+ export const PKCE_FLOW = "litellm_cli_pkce";
12
+ function isRecord(v) {
13
+ return typeof v === "object" && v !== null && !Array.isArray(v);
14
+ }
15
+ function isToken(v) {
16
+ return typeof v === "string" && /^[\x21-\x7e]+$/.test(v);
17
+ }
18
+ function posNumber(v) {
19
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
20
+ }
21
+ function safeEqual(a, b) {
22
+ if (typeof a !== "string")
23
+ return false;
24
+ const left = Buffer.from(a);
25
+ const right = Buffer.from(b);
26
+ return left.length === right.length && timingSafeEqual(left, right);
27
+ }
28
+ function jwtExpiry(token, fallback) {
29
+ const [, payload] = token.split(".");
30
+ if (!payload)
31
+ return fallback;
32
+ try {
33
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
34
+ const exp = isRecord(claims) ? posNumber(claims.exp) : undefined;
35
+ return exp ? Math.min(Math.floor(exp * 1_000), NEVER_EXPIRES) : fallback;
36
+ }
37
+ catch {
38
+ return fallback;
39
+ }
40
+ }
41
+ export function validateSsoRoot(baseURL) {
42
+ let url;
43
+ try {
44
+ url = new URL(baseURL);
45
+ }
46
+ catch {
47
+ throw new Error(`Invalid LiteLLM base URL: ${baseURL}`);
48
+ }
49
+ const host = url.hostname;
50
+ const loopback = host === "localhost" || host === "[::1]" || (isIP(host) === 4 && host.startsWith("127."));
51
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
52
+ throw new Error("LiteLLM browser sign-in requires https (or http on localhost)");
53
+ }
54
+ return rootURL(url.href);
55
+ }
56
+ function sameOrigin(value, issuer, field) {
57
+ if (typeof value !== "string" || !value.trim())
58
+ throw new Error(`Invalid ${field}`);
59
+ const url = new URL(value);
60
+ if (url.origin !== issuer.origin)
61
+ throw new Error(`Cross-origin ${field}`);
62
+ return value.trim();
63
+ }
64
+ async function listenLoopback() {
65
+ const state = randomBytes(32).toString("base64url");
66
+ const expiresAt = Date.now() + CALLBACK_TIMEOUT_MS;
67
+ const server = createServer();
68
+ let settle;
69
+ const code = new Promise((resolve, reject) => {
70
+ const timer = setTimeout(() => settle({ error: new Error("LiteLLM sign-in timed out") }), CALLBACK_TIMEOUT_MS);
71
+ settle = (res) => {
72
+ settle = () => { };
73
+ clearTimeout(timer);
74
+ server.close();
75
+ if (res.error)
76
+ reject(res.error);
77
+ else
78
+ resolve(res.code);
79
+ };
80
+ });
81
+ code.catch(() => { });
82
+ server.on("request", (req, res) => {
83
+ res.setHeader("Cache-Control", "no-store");
84
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
85
+ if (url.pathname !== "/callback")
86
+ return void res.writeHead(404).end();
87
+ if (!safeEqual(url.searchParams.get("state"), state))
88
+ return void res.writeHead(400).end("Invalid state");
89
+ const err = url.searchParams.get("error");
90
+ if (err) {
91
+ res.writeHead(400).end("Sign-in failed");
92
+ return settle({ error: new Error(`LiteLLM sign-in denied (${err})`) });
93
+ }
94
+ const authCode = url.searchParams.get("code");
95
+ if (!authCode)
96
+ return void res.writeHead(400).end("Missing code");
97
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
98
+ res.end("<!doctype html><title>LiteLLM login complete</title><p>Signed in. You can close this tab.</p>");
99
+ settle({ code: authCode });
100
+ });
101
+ await new Promise((resolve, reject) => {
102
+ server.once("error", reject);
103
+ server.listen(0, "127.0.0.1", () => {
104
+ server.off("error", reject);
105
+ resolve();
106
+ });
107
+ });
108
+ const addr = server.address();
109
+ if (!addr || typeof addr === "string") {
110
+ settle({ error: new Error("Loopback callback failed to bind") });
111
+ throw new Error("Loopback callback failed to bind");
112
+ }
113
+ return {
114
+ redirectUri: `http://127.0.0.1:${addr.port}/callback`,
115
+ state,
116
+ expiresAt,
117
+ code,
118
+ close: () => settle({ error: new Error("LiteLLM sign-in cancelled") }),
119
+ };
120
+ }
121
+ async function discoverPkce(root, headers, signal) {
122
+ const reqSignal = signal
123
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
124
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
125
+ const res = await fetch(`${root}/.well-known/litellm-cli-auth`, {
126
+ headers: { Accept: "application/json", ...headers },
127
+ signal: reqSignal,
128
+ });
129
+ if (res.status === 429 || res.status >= 500) {
130
+ throw new Error(`LiteLLM CLI auth discovery failed (HTTP ${res.status})`);
131
+ }
132
+ if (!res.ok)
133
+ return undefined;
134
+ const data = (await res.json().catch(() => undefined));
135
+ if (!isRecord(data) || data.contract_version !== 1)
136
+ return undefined;
137
+ if (!Array.isArray(data.code_challenge_methods_supported) || !data.code_challenge_methods_supported.includes("S256")) {
138
+ throw new Error("LiteLLM CLI auth discovery missing S256 support");
139
+ }
140
+ const issuer = new URL(String(data.issuer));
141
+ if (rootURL(issuer.href) !== rootURL(root))
142
+ throw new Error("LiteLLM CLI auth issuer mismatch");
143
+ return {
144
+ authorizationEndpoint: sameOrigin(data.authorization_endpoint, issuer, "authorization_endpoint"),
145
+ tokenEndpoint: sameOrigin(data.token_endpoint, issuer, "token_endpoint"),
146
+ registrationEndpoint: sameOrigin(data.registration_endpoint, issuer, "registration_endpoint"),
147
+ resource: sameOrigin(data.resource, issuer, "resource"),
148
+ };
149
+ }
150
+ async function exchangePkce(endpoint, form, headers, fallbackRefresh, signal) {
151
+ const reqSignal = signal
152
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
153
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
154
+ const res = await fetch(endpoint, {
155
+ method: "POST",
156
+ headers: { ...headers, Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
157
+ body: form,
158
+ signal: reqSignal,
159
+ });
160
+ const data = (await res.json().catch(() => undefined));
161
+ if (!res.ok || !isRecord(data)) {
162
+ throw new Error(`LiteLLM token exchange failed (HTTP ${res.status})`);
163
+ }
164
+ const expiresIn = posNumber(data.expires_in);
165
+ if (!isToken(data.access_token) || (!isToken(data.refresh_token) && !isToken(fallbackRefresh)) || !expiresIn) {
166
+ throw new Error("LiteLLM token exchange returned invalid token payload");
167
+ }
168
+ return {
169
+ access: data.access_token,
170
+ refresh: isToken(data.refresh_token) ? data.refresh_token : fallbackRefresh,
171
+ expires: Math.floor(Date.now() + expiresIn * 1_000),
172
+ };
173
+ }
174
+ /**
175
+ * Starts browser-based LiteLLM sign-in and returns a host-neutral interactive handle:
176
+ * 1) LiteLLM CLI-auth PKCE (`/.well-known/litellm-cli-auth`)
177
+ * 2) LiteLLM CLI SSO code flow (`/sso/cli/start` + `/sso/cli/poll`)
178
+ * 3) Pasted UI token fallback (`/sso/key/generate` + `/key/generate`)
179
+ */
180
+ export async function startLiteLLMInteractive(baseURL, meta) {
181
+ const root = validateSsoRoot(baseURL);
182
+ const pkce = await discoverPkce(root, meta.headers, meta.signal);
183
+ if (pkce) {
184
+ const verifier = randomBytes(32).toString("base64url");
185
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
186
+ const cb = await listenLoopback();
187
+ try {
188
+ const regRes = await fetch(pkce.registrationEndpoint, {
189
+ method: "POST",
190
+ headers: { ...meta.headers, Accept: "application/json", "Content-Type": "application/json" },
191
+ body: JSON.stringify({
192
+ client_name: meta.clientName ?? "open-harness-plugin-openai-compatible-auto",
193
+ redirect_uris: [cb.redirectUri],
194
+ token_endpoint_auth_method: "none",
195
+ grant_types: ["authorization_code", "refresh_token"],
196
+ response_types: ["code"],
197
+ }),
198
+ signal: meta.signal
199
+ ? AbortSignal.any([meta.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
200
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
201
+ });
202
+ const reg = (await regRes.json().catch(() => undefined));
203
+ if (!regRes.ok || !isRecord(reg) || !isToken(reg.client_id)) {
204
+ throw new Error(`LiteLLM PKCE registration failed (HTTP ${regRes.status})`);
205
+ }
206
+ const clientId = reg.client_id;
207
+ const authUrl = new URL(pkce.authorizationEndpoint);
208
+ for (const [k, v] of Object.entries({
209
+ client_id: clientId,
210
+ redirect_uri: cb.redirectUri,
211
+ response_type: "code",
212
+ resource: pkce.resource,
213
+ code_challenge: challenge,
214
+ code_challenge_method: "S256",
215
+ state: cb.state,
216
+ })) {
217
+ authUrl.searchParams.set(k, v);
218
+ }
219
+ return {
220
+ mode: "auto",
221
+ url: authUrl.toString(),
222
+ instructions: "Sign in to LiteLLM in your browser.",
223
+ expiresAt: cb.expiresAt,
224
+ waitForToken: async (signal) => {
225
+ const onAbort = () => cb.close();
226
+ signal?.addEventListener("abort", onAbort, { once: true });
227
+ try {
228
+ const code = await cb.code;
229
+ const tok = await exchangePkce(pkce.tokenEndpoint, new URLSearchParams({
230
+ grant_type: "authorization_code",
231
+ code,
232
+ redirect_uri: cb.redirectUri,
233
+ client_id: clientId,
234
+ code_verifier: verifier,
235
+ resource: pkce.resource,
236
+ }), meta.headers, undefined, signal);
237
+ return {
238
+ ...tok,
239
+ metadata: {
240
+ flow: PKCE_FLOW,
241
+ baseURL: root,
242
+ providerID: meta.providerID,
243
+ clientId,
244
+ tokenEndpoint: pkce.tokenEndpoint,
245
+ resource: pkce.resource,
246
+ },
247
+ };
248
+ }
249
+ finally {
250
+ signal?.removeEventListener("abort", onAbort);
251
+ }
252
+ },
253
+ };
254
+ }
255
+ catch (err) {
256
+ cb.close();
257
+ throw err;
258
+ }
259
+ }
260
+ // Fallback 1: LiteLLM CLI SSO code flow (/sso/cli/start -> /sso/cli/poll)
261
+ const startRes = await fetch(`${root}/sso/cli/start`, {
262
+ method: "POST",
263
+ headers: meta.headers,
264
+ signal: meta.signal
265
+ ? AbortSignal.any([meta.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
266
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
267
+ });
268
+ if (startRes.ok) {
269
+ const start = (await startRes.json().catch(() => undefined));
270
+ if (isRecord(start) &&
271
+ typeof start.login_id === "string" &&
272
+ typeof start.poll_secret === "string" &&
273
+ typeof start.user_code === "string") {
274
+ const loginId = start.login_id;
275
+ const pollSecret = start.poll_secret;
276
+ const ttlSec = posNumber(start.expires_in) ?? 600;
277
+ const deadline = Date.now() + ttlSec * 1_000;
278
+ const urlParams = new URLSearchParams({ source: "litellm-cli", key: loginId });
279
+ if (meta.clientName)
280
+ urlParams.set("client", meta.clientName);
281
+ return {
282
+ mode: "auto",
283
+ url: `${root}/sso/key/generate?${urlParams}`,
284
+ instructions: `Sign in to LiteLLM in your browser and confirm code: ${start.user_code}`,
285
+ expiresAt: deadline,
286
+ waitForToken: async (signal) => {
287
+ const pollUrl = new URL(`${root}/sso/cli/poll/${encodeURIComponent(loginId)}`);
288
+ let teamId;
289
+ while (Date.now() < deadline) {
290
+ signal?.throwIfAborted();
291
+ if (teamId)
292
+ pollUrl.searchParams.set("team_id", teamId);
293
+ const pollSignal = signal
294
+ ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
295
+ : AbortSignal.timeout(REQUEST_TIMEOUT_MS);
296
+ const res = await fetch(pollUrl, {
297
+ headers: { ...meta.headers, "x-litellm-cli-poll-secret": pollSecret },
298
+ signal: pollSignal,
299
+ }).catch((err) => {
300
+ if (signal?.aborted)
301
+ throw err;
302
+ return undefined;
303
+ });
304
+ if (!res || res.status === 429 || res.status >= 500) {
305
+ await delay(POLL_INTERVAL_MS, undefined, { signal });
306
+ continue;
307
+ }
308
+ if (!res.ok)
309
+ throw new Error(`LiteLLM CLI SSO poll failed (HTTP ${res.status})`);
310
+ const data = (await res.json().catch(() => undefined));
311
+ if (!isRecord(data))
312
+ throw new Error("Invalid LiteLLM CLI SSO poll response");
313
+ if (data.status === "ready" && isToken(data.key)) {
314
+ const exp = posNumber(data.expires_in);
315
+ return {
316
+ access: data.key,
317
+ refresh: "",
318
+ expires: exp
319
+ ? Math.floor(Date.now() + exp * 1_000)
320
+ : jwtExpiry(data.key, Date.now() + DEFAULT_TOKEN_LIFETIME_MS),
321
+ metadata: { baseURL: root, providerID: meta.providerID },
322
+ };
323
+ }
324
+ if (data.status === "ready" && data.requires_team_selection === true && !teamId) {
325
+ const teams = Array.isArray(data.team_details) ? data.team_details : [];
326
+ const picked = meta.team
327
+ ? teams.find((t) => isRecord(t) && (t.team_id === meta.team || t.team_alias === meta.team))
328
+ : undefined;
329
+ if (isRecord(picked) && typeof picked.team_id === "string") {
330
+ teamId = picked.team_id;
331
+ continue;
332
+ }
333
+ const names = teams
334
+ .map((t) => (isRecord(t) ? String(t.team_alias ?? t.team_id ?? "") : ""))
335
+ .filter(Boolean)
336
+ .join(", ");
337
+ throw new Error(`LiteLLM requires team selection. Reconnect with Team set to one of: ${names}`);
338
+ }
339
+ if (data.status !== "pending")
340
+ throw new Error("LiteLLM CLI SSO login failed");
341
+ await delay(POLL_INTERVAL_MS, undefined, { signal });
342
+ }
343
+ throw new Error("LiteLLM CLI SSO login expired");
344
+ },
345
+ };
346
+ }
347
+ }
348
+ // Fallback 2: User pastes token from /sso/key/generate
349
+ return {
350
+ mode: "code",
351
+ url: `${root}/sso/key/generate`,
352
+ instructions: "Sign in to LiteLLM in your browser, then paste the token as the code.",
353
+ exchangeCode: async (code) => {
354
+ const pasted = code.trim().replace(/^Bearer\s+/i, "").trim();
355
+ if (!isToken(pasted))
356
+ throw new Error("Paste a valid token from LiteLLM UI");
357
+ let access = pasted;
358
+ let expires = jwtExpiry(pasted, NEVER_EXPIRES);
359
+ const genRes = await fetch(`${root}/key/generate`, {
360
+ method: "POST",
361
+ headers: { ...meta.headers, Authorization: `Bearer ${pasted}`, "Content-Type": "application/json" },
362
+ body: JSON.stringify({}),
363
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
364
+ }).catch(() => undefined);
365
+ if (genRes?.ok) {
366
+ const gen = (await genRes.json().catch(() => undefined));
367
+ if (isRecord(gen) && isToken(gen.key)) {
368
+ access = gen.key;
369
+ const parsedExp = typeof gen.expires === "string" ? Date.parse(gen.expires) : NaN;
370
+ if (Number.isSafeInteger(parsedExp))
371
+ expires = parsedExp;
372
+ }
373
+ }
374
+ return {
375
+ access,
376
+ refresh: "",
377
+ expires,
378
+ metadata: { baseURL: root, providerID: meta.providerID },
379
+ };
380
+ },
381
+ };
382
+ }
383
+ /** Convenience runner for hosts whose login flow pushes notices/prompts directly (Pi, DSH). */
384
+ export async function runLiteLLMLogin(baseURL, meta, callbacks) {
385
+ const session = await startLiteLLMInteractive(baseURL, { ...meta, signal: callbacks.signal });
386
+ callbacks.onAuth(session.url, session.instructions);
387
+ if (session.mode === "auto") {
388
+ return session.waitForToken(callbacks.signal);
389
+ }
390
+ const code = await callbacks.onPrompt("Paste the token from LiteLLM UI:", "sk-...");
391
+ return session.exchangeCode(code);
392
+ }
393
+ export async function refreshLiteLLMTokens(token, headers, signal) {
394
+ const meta = token.metadata ?? {};
395
+ if (meta.flow === PKCE_FLOW && typeof meta.tokenEndpoint === "string" && isToken(meta.clientId) && isToken(token.refresh)) {
396
+ return exchangePkce(meta.tokenEndpoint, new URLSearchParams({
397
+ grant_type: "refresh_token",
398
+ refresh_token: token.refresh,
399
+ client_id: meta.clientId,
400
+ ...(typeof meta.resource === "string" ? { resource: meta.resource } : {}),
401
+ }), headers, token.refresh, signal);
402
+ }
403
+ if (Date.now() < token.expires) {
404
+ return { access: token.access, refresh: token.refresh, expires: token.expires };
405
+ }
406
+ throw new Error("LiteLLM sign-in expired; reconnect");
407
+ }
@@ -0,0 +1,41 @@
1
+ export interface ModelCostPer1M {
2
+ input: number;
3
+ output: number;
4
+ cacheRead: number;
5
+ cacheWrite: number;
6
+ }
7
+ export interface DiscoveredModel {
8
+ id: string;
9
+ name: string;
10
+ publisher?: string;
11
+ contextWindow?: number;
12
+ maxTokens?: number;
13
+ reasoning?: boolean;
14
+ reasoningEfforts?: string[];
15
+ supportsTools?: boolean;
16
+ supportsVision?: boolean;
17
+ supportsAudio?: boolean;
18
+ supportsVideo?: boolean;
19
+ supportsPdf?: boolean;
20
+ supportsPromptCaching?: boolean;
21
+ inputModalities?: string[];
22
+ cost?: ModelCostPer1M;
23
+ }
24
+ export interface GatewayUserInfo {
25
+ email?: string;
26
+ gcpBillingLabel?: string;
27
+ keyName?: string;
28
+ monthlyCapUsd?: number;
29
+ settledUsd?: number;
30
+ unsettledUsd?: number;
31
+ effectiveSpendUsd?: number;
32
+ remainingUsd?: number;
33
+ allowed?: boolean;
34
+ }
35
+ /** Strip trailing slashes and optional `/v1` suffix so endpoints resolve consistently. */
36
+ export declare function rootURL(baseURL: string): string;
37
+ export declare function v1URL(baseURL: string): string;
38
+ /** Query `/v1/models` (and optional `/v1/model/info`) and return normalized chat model descriptors. */
39
+ export declare function fetchDiscoveredModels(baseURL: string, apiKey?: string, extraHeaders?: Record<string, string>, signal?: AbortSignal): Promise<DiscoveredModel[]>;
40
+ /** Optional query for `GET /v1/me` on prov-ai gateways; returns `undefined` cleanly on unsupported endpoints. */
41
+ export declare function fetchGatewayUserInfo(baseURL: string, apiKey?: string, extraHeaders?: Record<string, string>, signal?: AbortSignal): Promise<GatewayUserInfo | undefined>;