@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 +21 -0
- package/README.md +63 -0
- package/cordis.patch.yml +3 -0
- package/dist/core/auth.d.ts +56 -0
- package/dist/core/auth.js +407 -0
- package/dist/core/discovery.d.ts +41 -0
- package/dist/core/discovery.js +222 -0
- package/dist/dsh.d.ts +30 -0
- package/dist/dsh.js +583 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +376 -0
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +272 -0
- package/package.json +121 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { Integration, Model, Plugin, Provider } from "@opencode/plugin";
|
|
2
|
+
import { refreshLiteLLMTokens, startLiteLLMInteractive } from "./core/auth.js";
|
|
3
|
+
import { fetchDiscoveredModels, fetchGatewayUserInfo, rootURL, v1URL, } from "./core/discovery.js";
|
|
4
|
+
const PLUGIN_ID = "openai-auto";
|
|
5
|
+
const INTEGRATION_ID = "openai-auto";
|
|
6
|
+
const LITELLM_METHOD_ID = "litellm-cli";
|
|
7
|
+
const OPENAI_COMPAT_PKG = "@opencode/ai/providers/openai-compatible";
|
|
8
|
+
const REFRESH_TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const ALLOWED_INPUTS = new Set(["text", "image", "audio", "video", "pdf"]);
|
|
10
|
+
const COMPATIBLE_PACKAGES = new Set([
|
|
11
|
+
OPENAI_COMPAT_PKG,
|
|
12
|
+
"@opencode/ai/providers/openai-compatible/responses",
|
|
13
|
+
"@ai-sdk/openai-compatible",
|
|
14
|
+
]);
|
|
15
|
+
function asRecord(v) {
|
|
16
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
|
|
17
|
+
}
|
|
18
|
+
function readStr(v) {
|
|
19
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
20
|
+
}
|
|
21
|
+
function readHeaders(v) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, val] of Object.entries(asRecord(v))) {
|
|
24
|
+
if (typeof val === "string")
|
|
25
|
+
out[k] = val;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function withAppHeader(headers) {
|
|
30
|
+
const hasApp = Object.keys(headers).some((k) => k.toLowerCase() === "x-prov-application");
|
|
31
|
+
return hasApp ? headers : { "X-Prov-Application": "opencode", ...headers };
|
|
32
|
+
}
|
|
33
|
+
function formatUserBadge(baseName, user) {
|
|
34
|
+
if (!user?.email)
|
|
35
|
+
return baseName;
|
|
36
|
+
const spend = user.effectiveSpendUsd !== undefined && user.monthlyCapUsd !== undefined
|
|
37
|
+
? ` · $${user.effectiveSpendUsd.toFixed(2)}/$${user.monthlyCapUsd.toFixed(0)}`
|
|
38
|
+
: "";
|
|
39
|
+
return `${baseName} (${user.email}${spend})`;
|
|
40
|
+
}
|
|
41
|
+
function toOAuth(methodID, token) {
|
|
42
|
+
return {
|
|
43
|
+
type: "oauth",
|
|
44
|
+
methodID: Integration.MethodID.make(methodID),
|
|
45
|
+
access: token.access,
|
|
46
|
+
refresh: token.refresh,
|
|
47
|
+
expires: token.expires,
|
|
48
|
+
metadata: token.metadata,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function authorizeOpenCode(baseURL, methodID, meta) {
|
|
52
|
+
const session = await startLiteLLMInteractive(baseURL, { ...meta, clientName: "opencode" });
|
|
53
|
+
if (session.mode === "auto") {
|
|
54
|
+
const callback = session.waitForToken().then((tok) => toOAuth(methodID, tok));
|
|
55
|
+
callback.catch(() => { });
|
|
56
|
+
return {
|
|
57
|
+
mode: "auto",
|
|
58
|
+
url: session.url,
|
|
59
|
+
instructions: session.instructions,
|
|
60
|
+
expiresAt: session.expiresAt,
|
|
61
|
+
callback,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
mode: "code",
|
|
66
|
+
url: session.url,
|
|
67
|
+
instructions: session.instructions,
|
|
68
|
+
callback: async (code) => toOAuth(methodID, await session.exchangeCode(code)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function discoverOpenCodeModels(providerID, baseURL, apiKey, extraHeaders) {
|
|
72
|
+
const [discovered, userInfo] = await Promise.all([
|
|
73
|
+
fetchDiscoveredModels(baseURL, apiKey, extraHeaders),
|
|
74
|
+
fetchGatewayUserInfo(baseURL, apiKey, extraHeaders),
|
|
75
|
+
]);
|
|
76
|
+
const pid = Provider.ID.make(providerID);
|
|
77
|
+
const models = discovered.map((m) => {
|
|
78
|
+
const defaults = Model.Info.default(pid, Model.ID.make(m.id));
|
|
79
|
+
const input = (m.inputModalities ?? ["text"]).filter((x) => ALLOWED_INPUTS.has(x));
|
|
80
|
+
const variants = m.reasoningEfforts?.map((effort) => ({
|
|
81
|
+
id: Model.VariantID.make(effort),
|
|
82
|
+
body: { reasoning_effort: effort },
|
|
83
|
+
})) ?? [];
|
|
84
|
+
return {
|
|
85
|
+
...defaults,
|
|
86
|
+
name: m.name,
|
|
87
|
+
limit: {
|
|
88
|
+
context: m.contextWindow ?? defaults.limit.context,
|
|
89
|
+
output: m.maxTokens ?? defaults.limit.output,
|
|
90
|
+
},
|
|
91
|
+
capabilities: {
|
|
92
|
+
...defaults.capabilities,
|
|
93
|
+
tools: m.supportsTools ?? defaults.capabilities.tools,
|
|
94
|
+
input: input.length > 0 ? input : defaults.capabilities.input,
|
|
95
|
+
},
|
|
96
|
+
...(m.cost
|
|
97
|
+
? {
|
|
98
|
+
cost: [
|
|
99
|
+
{
|
|
100
|
+
input: m.cost.input,
|
|
101
|
+
output: m.cost.output,
|
|
102
|
+
cache: {
|
|
103
|
+
read: m.cost.cacheRead,
|
|
104
|
+
write: m.cost.cacheWrite,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
...(m.reasoning
|
|
111
|
+
? {
|
|
112
|
+
compatibility: {
|
|
113
|
+
reasoningField: "reasoning_content",
|
|
114
|
+
...(m.reasoningEfforts?.length ? { supportsEffortUpdates: true } : {}),
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
118
|
+
...(variants.length > 0 ? { variants } : {}),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
return { models, userInfo };
|
|
122
|
+
}
|
|
123
|
+
function credentialKey(cred) {
|
|
124
|
+
if (cred?.type === "key")
|
|
125
|
+
return cred.key;
|
|
126
|
+
if (cred?.type === "oauth" && cred.methodID === LITELLM_METHOD_ID)
|
|
127
|
+
return cred.access;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
async function readConnection(ctx, integrationID = INTEGRATION_ID) {
|
|
131
|
+
try {
|
|
132
|
+
const active = await ctx.integration.connection.active(integrationID);
|
|
133
|
+
if (!active)
|
|
134
|
+
return undefined;
|
|
135
|
+
const cred = await ctx.integration.connection.resolve(active);
|
|
136
|
+
const apiKey = credentialKey(cred);
|
|
137
|
+
if (!cred || !apiKey)
|
|
138
|
+
return undefined;
|
|
139
|
+
const answers = asRecord(cred.metadata);
|
|
140
|
+
return {
|
|
141
|
+
apiKey,
|
|
142
|
+
baseURL: readStr(answers.baseURL),
|
|
143
|
+
providerID: readStr(answers.providerID),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function isTargetProvider(p, options) {
|
|
151
|
+
const settings = asRecord(p.settings);
|
|
152
|
+
if (settings.autoModels === false)
|
|
153
|
+
return false;
|
|
154
|
+
if (p.id === readStr(options.providerID) || p.id === PLUGIN_ID)
|
|
155
|
+
return true;
|
|
156
|
+
return COMPATIBLE_PACKAGES.has(p.package) && typeof settings.baseURL === "string";
|
|
157
|
+
}
|
|
158
|
+
export default Plugin.define({
|
|
159
|
+
id: PLUGIN_ID,
|
|
160
|
+
async setup(ctx) {
|
|
161
|
+
if (!ctx.provider)
|
|
162
|
+
return;
|
|
163
|
+
const opts = asRecord(ctx.options);
|
|
164
|
+
const sources = new Map();
|
|
165
|
+
await ctx.integration.transform((editor) => {
|
|
166
|
+
editor.update(INTEGRATION_ID, (i) => {
|
|
167
|
+
i.name = "OpenAI-Compatible (Auto)";
|
|
168
|
+
});
|
|
169
|
+
editor.method.update({
|
|
170
|
+
integrationID: INTEGRATION_ID,
|
|
171
|
+
method: {
|
|
172
|
+
type: "key",
|
|
173
|
+
label: "API Key",
|
|
174
|
+
form: [
|
|
175
|
+
{
|
|
176
|
+
key: "baseURL",
|
|
177
|
+
type: "string",
|
|
178
|
+
format: "uri",
|
|
179
|
+
title: "Base URL",
|
|
180
|
+
description: "OpenAI-compatible endpoint URL (e.g. https://llm.example.com/v1)",
|
|
181
|
+
placeholder: "https://llm.example.com/v1",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
key: "providerID",
|
|
185
|
+
type: "string",
|
|
186
|
+
title: "Provider ID",
|
|
187
|
+
default: PLUGIN_ID,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
editor.method.update({
|
|
193
|
+
integrationID: INTEGRATION_ID,
|
|
194
|
+
method: {
|
|
195
|
+
id: LITELLM_METHOD_ID,
|
|
196
|
+
type: "oauth",
|
|
197
|
+
label: "LiteLLM CLI Code Flow (Browser)",
|
|
198
|
+
form: [
|
|
199
|
+
{
|
|
200
|
+
key: "baseURL",
|
|
201
|
+
type: "string",
|
|
202
|
+
format: "uri",
|
|
203
|
+
title: "Base URL",
|
|
204
|
+
description: "LiteLLM proxy URL (leave empty to use configured provider URL or LITELLM_BASE_URL)",
|
|
205
|
+
placeholder: "https://llm.example.com",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
key: "providerID",
|
|
209
|
+
type: "string",
|
|
210
|
+
title: "Provider ID",
|
|
211
|
+
default: PLUGIN_ID,
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
key: "team",
|
|
215
|
+
type: "string",
|
|
216
|
+
title: "Team (optional)",
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
authorize: async (answer) => {
|
|
221
|
+
const providerID = readStr(answer.providerID) ?? readStr(opts.providerID) ?? PLUGIN_ID;
|
|
222
|
+
const providers = (await ctx.provider.list()).data;
|
|
223
|
+
const target = providers.find((p) => p.id === providerID);
|
|
224
|
+
const targetSettings = asRecord(target?.settings);
|
|
225
|
+
const baseURL = readStr(answer.baseURL) ??
|
|
226
|
+
readStr(targetSettings.baseURL) ??
|
|
227
|
+
readStr(opts.baseURL) ??
|
|
228
|
+
readStr(process.env.LITELLM_BASE_URL);
|
|
229
|
+
if (!baseURL) {
|
|
230
|
+
throw new Error("Enter a Base URL or configure providers.<id>.settings.baseURL");
|
|
231
|
+
}
|
|
232
|
+
const headers = withAppHeader({ ...readHeaders(opts.headers), ...readHeaders(target?.headers) });
|
|
233
|
+
return authorizeOpenCode(baseURL, LITELLM_METHOD_ID, {
|
|
234
|
+
providerID,
|
|
235
|
+
team: readStr(answer.team),
|
|
236
|
+
headers,
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
refresh: async (cred) => {
|
|
240
|
+
const tok = await refreshLiteLLMTokens(cred, withAppHeader(readHeaders(opts.headers)));
|
|
241
|
+
return { ...cred, ...tok };
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
const syncSources = async (force = false) => {
|
|
246
|
+
let changed = false;
|
|
247
|
+
const conn = await readConnection(ctx);
|
|
248
|
+
const list = (await ctx.provider.list().catch(() => ({ data: [] }))).data;
|
|
249
|
+
const candidates = list.filter((p) => isTargetProvider(p, opts));
|
|
250
|
+
for (const p of candidates) {
|
|
251
|
+
const settings = asRecord(p.settings);
|
|
252
|
+
const connMatch = conn && (!conn.providerID || conn.providerID === p.id) ? conn : undefined;
|
|
253
|
+
const baseURL = readStr(settings.baseURL) ?? connMatch?.baseURL ?? readStr(opts.baseURL);
|
|
254
|
+
if (!baseURL)
|
|
255
|
+
continue;
|
|
256
|
+
const apiKey = readStr(settings.apiKey) ?? connMatch?.apiKey ?? readStr(opts.apiKey);
|
|
257
|
+
const headers = withAppHeader({ ...readHeaders(opts.headers), ...readHeaders(p.headers) });
|
|
258
|
+
const existing = sources.get(p.id);
|
|
259
|
+
if (!force && existing && Date.now() - existing.lastFetchedAt < REFRESH_TTL_MS && existing.baseURL === rootURL(baseURL) && existing.apiKey === apiKey) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const { models, userInfo } = await discoverOpenCodeModels(p.id, baseURL, apiKey, headers);
|
|
264
|
+
sources.set(p.id, {
|
|
265
|
+
id: p.id,
|
|
266
|
+
name: p.name || p.id,
|
|
267
|
+
baseURL: rootURL(baseURL),
|
|
268
|
+
apiKey,
|
|
269
|
+
headers,
|
|
270
|
+
pkg: p.package || OPENAI_COMPAT_PKG,
|
|
271
|
+
fromConfig: true,
|
|
272
|
+
usesConnection: !readStr(settings.apiKey) && Boolean(connMatch?.apiKey),
|
|
273
|
+
models,
|
|
274
|
+
userInfo,
|
|
275
|
+
ownedIds: existing?.ownedIds ?? new Set(),
|
|
276
|
+
lastFetchedAt: Date.now(),
|
|
277
|
+
});
|
|
278
|
+
changed = true;
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
console.warn(`[${PLUGIN_ID}] Model discovery failed for "${p.id}": ${err instanceof Error ? err.message : String(err)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const fallbackURL = conn?.baseURL ?? readStr(opts.baseURL) ?? readStr(process.env.LITELLM_BASE_URL);
|
|
285
|
+
const fallbackID = conn?.providerID ?? readStr(opts.providerID) ?? PLUGIN_ID;
|
|
286
|
+
if (fallbackURL && !candidates.some((p) => p.id === fallbackID)) {
|
|
287
|
+
const apiKey = conn?.apiKey ?? readStr(opts.apiKey) ?? readStr(process.env.LITELLM_API_KEY);
|
|
288
|
+
const headers = withAppHeader(readHeaders(opts.headers));
|
|
289
|
+
const existing = sources.get(fallbackID);
|
|
290
|
+
if (force || !existing || Date.now() - existing.lastFetchedAt >= REFRESH_TTL_MS || existing.apiKey !== apiKey || existing.baseURL !== rootURL(fallbackURL)) {
|
|
291
|
+
try {
|
|
292
|
+
const { models, userInfo } = await discoverOpenCodeModels(fallbackID, fallbackURL, apiKey, headers);
|
|
293
|
+
sources.set(fallbackID, {
|
|
294
|
+
id: fallbackID,
|
|
295
|
+
name: formatUserBadge("OpenAI-Compatible (Auto)", userInfo),
|
|
296
|
+
baseURL: rootURL(fallbackURL),
|
|
297
|
+
apiKey,
|
|
298
|
+
headers,
|
|
299
|
+
pkg: OPENAI_COMPAT_PKG,
|
|
300
|
+
fromConfig: false,
|
|
301
|
+
usesConnection: Boolean(conn?.apiKey),
|
|
302
|
+
models,
|
|
303
|
+
userInfo,
|
|
304
|
+
ownedIds: existing?.ownedIds ?? new Set(),
|
|
305
|
+
lastFetchedAt: Date.now(),
|
|
306
|
+
});
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
console.warn(`[${PLUGIN_ID}] Model discovery failed for "${fallbackID}": ${err instanceof Error ? err.message : String(err)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return changed;
|
|
315
|
+
};
|
|
316
|
+
await syncSources(true);
|
|
317
|
+
await ctx.provider.transform((editor) => {
|
|
318
|
+
for (const src of sources.values()) {
|
|
319
|
+
const current = editor.get(src.id);
|
|
320
|
+
if (current) {
|
|
321
|
+
editor.update(src.id, (p) => {
|
|
322
|
+
if (!p.package)
|
|
323
|
+
p.package = src.pkg;
|
|
324
|
+
if (src.usesConnection)
|
|
325
|
+
p.integrationID = Integration.ID.make(INTEGRATION_ID);
|
|
326
|
+
p.headers = withAppHeader({ ...p.headers, ...src.headers });
|
|
327
|
+
const settings = { ...(p.settings ?? {}), baseURL: v1URL(src.baseURL) };
|
|
328
|
+
if (src.usesConnection)
|
|
329
|
+
delete settings.apiKey;
|
|
330
|
+
p.settings = settings;
|
|
331
|
+
});
|
|
332
|
+
const discoveredIds = new Set(src.models.map((m) => m.id));
|
|
333
|
+
const kept = [...current.models.values()].filter((m) => !src.ownedIds.has(m.id) || discoveredIds.has(m.id));
|
|
334
|
+
const existingIds = new Set(kept.map((m) => m.id));
|
|
335
|
+
const added = src.models.filter((m) => !existingIds.has(m.id));
|
|
336
|
+
src.ownedIds = new Set([...src.ownedIds].filter((id) => discoveredIds.has(id)).concat(added.map((m) => String(m.id))));
|
|
337
|
+
editor.models.set(src.id, [...kept, ...added]);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
src.ownedIds = new Set(src.models.map((m) => m.id));
|
|
341
|
+
editor.add({
|
|
342
|
+
info: {
|
|
343
|
+
...Provider.Info.empty(Provider.ID.make(src.id)),
|
|
344
|
+
name: src.name,
|
|
345
|
+
activation: "enabled",
|
|
346
|
+
package: src.pkg,
|
|
347
|
+
settings: {
|
|
348
|
+
baseURL: v1URL(src.baseURL),
|
|
349
|
+
...(!src.usesConnection && src.apiKey ? { apiKey: src.apiKey } : {}),
|
|
350
|
+
},
|
|
351
|
+
headers: src.headers,
|
|
352
|
+
...(src.usesConnection ? { integrationID: Integration.ID.make(INTEGRATION_ID) } : {}),
|
|
353
|
+
},
|
|
354
|
+
models: src.models,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
const controller = new AbortController();
|
|
360
|
+
void (async () => {
|
|
361
|
+
for await (const event of ctx.event.subscribe({
|
|
362
|
+
types: ["provider.updated", "credential.switched", "session.created"],
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
})) {
|
|
365
|
+
// Only force-bypass the TTL cache on credential switches; provider.updated is emitted by our own reload().
|
|
366
|
+
const force = event.type === "credential.switched";
|
|
367
|
+
if (await syncSources(force)) {
|
|
368
|
+
await ctx.provider.reload().catch((err) => {
|
|
369
|
+
console.warn(`[${PLUGIN_ID}] provider.reload failed:`, err);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
})().catch(() => { });
|
|
374
|
+
return () => controller.abort();
|
|
375
|
+
},
|
|
376
|
+
});
|
package/dist/pi.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Provider } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type GatewayUserInfo } from "./core/discovery.js";
|
|
4
|
+
export declare function createOpenAIAutoPiProvider(): {
|
|
5
|
+
provider: Provider<"openai-completions">;
|
|
6
|
+
getUserInfo: () => GatewayUserInfo | undefined;
|
|
7
|
+
};
|
|
8
|
+
export default function openaiAutoPiExtension(pi: ExtensionAPI): void;
|
package/dist/pi.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { isModelType, } from "@earendil-works/pi-ai";
|
|
2
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
3
|
+
import { refreshLiteLLMTokens, runLiteLLMLogin } from "./core/auth.js";
|
|
4
|
+
import { fetchDiscoveredModels, fetchGatewayUserInfo, rootURL, v1URL, } from "./core/discovery.js";
|
|
5
|
+
const PROVIDER_ID = "openai-auto";
|
|
6
|
+
const PROVIDER_NAME = "OpenAI-Compatible (Auto)";
|
|
7
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
8
|
+
const DEFAULT_MAX_TOKENS = 16_384;
|
|
9
|
+
const APP_HEADERS = { "X-Prov-Application": "pi" };
|
|
10
|
+
function asRecord(v) {
|
|
11
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
|
|
12
|
+
}
|
|
13
|
+
function readStr(v) {
|
|
14
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
15
|
+
}
|
|
16
|
+
function oauthBaseURL(cred) {
|
|
17
|
+
const rec = asRecord(cred);
|
|
18
|
+
const meta = asRecord(rec.metadata);
|
|
19
|
+
return (readStr(rec.baseURL) ??
|
|
20
|
+
readStr(meta.baseURL) ??
|
|
21
|
+
readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
22
|
+
readStr(process.env.LITELLM_BASE_URL));
|
|
23
|
+
}
|
|
24
|
+
function buildThinkingLevelMap(efforts) {
|
|
25
|
+
if (!efforts || efforts.length === 0)
|
|
26
|
+
return undefined;
|
|
27
|
+
const set = new Set(efforts.map((e) => e.toLowerCase()));
|
|
28
|
+
const map = {};
|
|
29
|
+
const pick = (preferred) => preferred.find((p) => set.has(p)) ?? null;
|
|
30
|
+
map.minimal = pick(["minimal", "low"]);
|
|
31
|
+
map.low = pick(["low", "minimal", "medium", "high"]);
|
|
32
|
+
map.medium = pick(["medium", "high", "low"]);
|
|
33
|
+
map.high = pick(["high", "medium"]);
|
|
34
|
+
map.xhigh = pick(["max", "xhigh", "high"]);
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
function toPiModel(m, baseUrl) {
|
|
38
|
+
const reasoning = m.reasoning ?? false;
|
|
39
|
+
const thinkingLevelMap = reasoning ? buildThinkingLevelMap(m.reasoningEfforts) : undefined;
|
|
40
|
+
const supportsEffort = Boolean(m.reasoningEfforts && m.reasoningEfforts.length > 0);
|
|
41
|
+
return {
|
|
42
|
+
id: m.id,
|
|
43
|
+
name: m.name,
|
|
44
|
+
api: "openai-completions",
|
|
45
|
+
provider: PROVIDER_ID,
|
|
46
|
+
baseUrl: v1URL(baseUrl),
|
|
47
|
+
reasoning,
|
|
48
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
49
|
+
input: m.supportsVision ? ["text", "image"] : ["text"],
|
|
50
|
+
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
51
|
+
contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
52
|
+
maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
53
|
+
compat: {
|
|
54
|
+
supportsStore: false,
|
|
55
|
+
supportsUsageInStreaming: true,
|
|
56
|
+
supportsReasoningEffort: reasoning && supportsEffort,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function resolveFromCredential(credential, fallbackBaseUrl) {
|
|
61
|
+
if (credential?.type === "oauth") {
|
|
62
|
+
const base = oauthBaseURL(credential) ?? fallbackBaseUrl;
|
|
63
|
+
return { baseUrl: base ? v1URL(base) : undefined, apiKey: credential.access };
|
|
64
|
+
}
|
|
65
|
+
if (credential?.type === "api_key") {
|
|
66
|
+
const base = readStr(credential.env?.OPENAI_AUTO_BASE_URL) ??
|
|
67
|
+
readStr(credential.env?.LITELLM_BASE_URL) ??
|
|
68
|
+
fallbackBaseUrl ??
|
|
69
|
+
readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
70
|
+
readStr(process.env.LITELLM_BASE_URL);
|
|
71
|
+
const apiKey = readStr(credential.key) ??
|
|
72
|
+
readStr(process.env.OPENAI_AUTO_API_KEY) ??
|
|
73
|
+
readStr(process.env.LITELLM_API_KEY);
|
|
74
|
+
return { baseUrl: base ? v1URL(base) : undefined, apiKey };
|
|
75
|
+
}
|
|
76
|
+
const base = fallbackBaseUrl ??
|
|
77
|
+
readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
78
|
+
readStr(process.env.LITELLM_BASE_URL);
|
|
79
|
+
const apiKey = readStr(process.env.OPENAI_AUTO_API_KEY) ?? readStr(process.env.LITELLM_API_KEY);
|
|
80
|
+
return { baseUrl: base ? v1URL(base) : undefined, apiKey };
|
|
81
|
+
}
|
|
82
|
+
export function createOpenAIAutoPiProvider() {
|
|
83
|
+
let models = [];
|
|
84
|
+
let lastUserInfo;
|
|
85
|
+
const completionsApi = () => openAICompletionsApi();
|
|
86
|
+
const oauth = {
|
|
87
|
+
name: "LiteLLM CLI Code Flow (Browser)",
|
|
88
|
+
loginLabel: "LiteLLM CLI Code Flow (Browser)",
|
|
89
|
+
login: async (interaction) => {
|
|
90
|
+
const placeholder = readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
91
|
+
readStr(process.env.LITELLM_BASE_URL) ??
|
|
92
|
+
"https://llm.example.com";
|
|
93
|
+
const enteredUrl = await interaction.prompt({
|
|
94
|
+
type: "text",
|
|
95
|
+
message: "LiteLLM proxy URL",
|
|
96
|
+
placeholder,
|
|
97
|
+
});
|
|
98
|
+
const baseURL = readStr(enteredUrl) ??
|
|
99
|
+
readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
100
|
+
readStr(process.env.LITELLM_BASE_URL);
|
|
101
|
+
if (!baseURL) {
|
|
102
|
+
throw new Error("Base URL is required");
|
|
103
|
+
}
|
|
104
|
+
const team = readStr(await interaction.prompt({
|
|
105
|
+
type: "text",
|
|
106
|
+
message: "Team (optional)",
|
|
107
|
+
placeholder: "Leave blank if not required",
|
|
108
|
+
}));
|
|
109
|
+
const tok = await runLiteLLMLogin(baseURL, { providerID: PROVIDER_ID, team, headers: APP_HEADERS, clientName: "pi" }, {
|
|
110
|
+
signal: interaction.signal,
|
|
111
|
+
onAuth: (url, instructions) => {
|
|
112
|
+
interaction.notify({ type: "auth_url", url, instructions });
|
|
113
|
+
},
|
|
114
|
+
onPrompt: (message, p) => interaction.prompt({ type: "manual_code", message, placeholder: p }),
|
|
115
|
+
});
|
|
116
|
+
lastUserInfo = await fetchGatewayUserInfo(baseURL, tok.access, APP_HEADERS, interaction.signal);
|
|
117
|
+
return {
|
|
118
|
+
type: "oauth",
|
|
119
|
+
access: tok.access,
|
|
120
|
+
refresh: tok.refresh,
|
|
121
|
+
expires: tok.expires,
|
|
122
|
+
baseURL: rootURL(baseURL),
|
|
123
|
+
metadata: { ...tok.metadata, ...(lastUserInfo?.email ? { userEmail: lastUserInfo.email } : {}) },
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
refresh: async (credential, signal) => {
|
|
127
|
+
const tok = await refreshLiteLLMTokens({
|
|
128
|
+
access: credential.access,
|
|
129
|
+
refresh: credential.refresh,
|
|
130
|
+
expires: credential.expires,
|
|
131
|
+
metadata: asRecord(credential.metadata),
|
|
132
|
+
}, APP_HEADERS, signal);
|
|
133
|
+
return {
|
|
134
|
+
...credential,
|
|
135
|
+
...tok,
|
|
136
|
+
type: "oauth",
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
toAuth: async (credential) => {
|
|
140
|
+
const baseURL = oauthBaseURL(credential);
|
|
141
|
+
return {
|
|
142
|
+
apiKey: credential.access,
|
|
143
|
+
headers: APP_HEADERS,
|
|
144
|
+
...(baseURL ? { baseUrl: v1URL(baseURL) } : {}),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const apiKey = {
|
|
149
|
+
name: "OpenAI-Compatible API Key",
|
|
150
|
+
login: async (interaction) => {
|
|
151
|
+
const placeholder = readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
152
|
+
readStr(process.env.LITELLM_BASE_URL) ??
|
|
153
|
+
"https://llm.example.com/v1";
|
|
154
|
+
const enteredUrl = await interaction.prompt({
|
|
155
|
+
type: "text",
|
|
156
|
+
message: "OpenAI-compatible Base URL",
|
|
157
|
+
placeholder,
|
|
158
|
+
});
|
|
159
|
+
const baseURL = readStr(enteredUrl) ??
|
|
160
|
+
readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
161
|
+
readStr(process.env.LITELLM_BASE_URL);
|
|
162
|
+
if (!baseURL) {
|
|
163
|
+
throw new Error("Base URL is required");
|
|
164
|
+
}
|
|
165
|
+
const enteredKey = await interaction.prompt({
|
|
166
|
+
type: "secret",
|
|
167
|
+
message: "API key (optional for local endpoints)",
|
|
168
|
+
placeholder: "sk-...",
|
|
169
|
+
});
|
|
170
|
+
const key = readStr(enteredKey);
|
|
171
|
+
await fetchDiscoveredModels(baseURL, key, APP_HEADERS, interaction.signal);
|
|
172
|
+
lastUserInfo = await fetchGatewayUserInfo(baseURL, key, APP_HEADERS, interaction.signal);
|
|
173
|
+
return {
|
|
174
|
+
type: "api_key",
|
|
175
|
+
...(key ? { key } : {}),
|
|
176
|
+
env: { OPENAI_AUTO_BASE_URL: v1URL(baseURL) },
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
check: async ({ ctx, credential }) => {
|
|
180
|
+
const base = readStr(credential?.env?.OPENAI_AUTO_BASE_URL) ??
|
|
181
|
+
readStr(await ctx.env("OPENAI_AUTO_BASE_URL")) ??
|
|
182
|
+
readStr(await ctx.env("LITELLM_BASE_URL"));
|
|
183
|
+
return base ? { type: "api_key", source: credential ? "stored credential" : "env" } : undefined;
|
|
184
|
+
},
|
|
185
|
+
resolve: async ({ ctx, credential }) => {
|
|
186
|
+
const base = readStr(credential?.env?.OPENAI_AUTO_BASE_URL) ??
|
|
187
|
+
readStr(await ctx.env("OPENAI_AUTO_BASE_URL")) ??
|
|
188
|
+
readStr(await ctx.env("LITELLM_BASE_URL"));
|
|
189
|
+
if (!base)
|
|
190
|
+
return undefined;
|
|
191
|
+
const resolvedKey = readStr(credential?.key) ??
|
|
192
|
+
readStr(await ctx.env("OPENAI_AUTO_API_KEY")) ??
|
|
193
|
+
readStr(await ctx.env("LITELLM_API_KEY")) ??
|
|
194
|
+
"local";
|
|
195
|
+
return {
|
|
196
|
+
auth: { apiKey: resolvedKey, baseUrl: v1URL(base), headers: APP_HEADERS },
|
|
197
|
+
env: { ...credential?.env, OPENAI_AUTO_BASE_URL: v1URL(base) },
|
|
198
|
+
source: credential ? "stored credential" : "env",
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const provider = {
|
|
203
|
+
id: PROVIDER_ID,
|
|
204
|
+
name: PROVIDER_NAME,
|
|
205
|
+
baseUrl: v1URL(readStr(process.env.OPENAI_AUTO_BASE_URL) ??
|
|
206
|
+
readStr(process.env.LITELLM_BASE_URL) ??
|
|
207
|
+
"http://localhost:4000"),
|
|
208
|
+
headers: APP_HEADERS,
|
|
209
|
+
auth: { oauth, apiKey },
|
|
210
|
+
getModels: () => models,
|
|
211
|
+
refreshModels: async (context) => {
|
|
212
|
+
if (context.stored) {
|
|
213
|
+
const restored = context.stored.models.filter((m) => m.provider === PROVIDER_ID && isModelType(m, "chat") && m.api === "openai-completions");
|
|
214
|
+
if (!(await context.publish({ update: () => { models = restored; } }))) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!context.allowNetwork || context.signal.aborted)
|
|
219
|
+
return;
|
|
220
|
+
const { baseUrl, apiKey: resolvedKey } = await resolveFromCredential(context.credential, context.baseUrl);
|
|
221
|
+
if (!baseUrl)
|
|
222
|
+
return;
|
|
223
|
+
const [discovered, userInfo] = await Promise.all([
|
|
224
|
+
fetchDiscoveredModels(baseUrl, resolvedKey, APP_HEADERS, context.signal),
|
|
225
|
+
fetchGatewayUserInfo(baseUrl, resolvedKey, APP_HEADERS, context.signal),
|
|
226
|
+
]);
|
|
227
|
+
if (context.signal.aborted)
|
|
228
|
+
return;
|
|
229
|
+
lastUserInfo = userInfo;
|
|
230
|
+
const refreshed = discovered.map((m) => toPiModel(m, baseUrl));
|
|
231
|
+
await context.publish({
|
|
232
|
+
persist: { models: refreshed, checkedAt: Date.now() },
|
|
233
|
+
update: () => {
|
|
234
|
+
models = refreshed;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
stream: (model, context, options) => completionsApi().stream(model, context, options),
|
|
239
|
+
streamSimple: (model, context, options) => completionsApi().streamSimple(model, context, options),
|
|
240
|
+
};
|
|
241
|
+
return { provider, getUserInfo: () => lastUserInfo };
|
|
242
|
+
}
|
|
243
|
+
function formatStatus(info) {
|
|
244
|
+
if (!info?.email)
|
|
245
|
+
return undefined;
|
|
246
|
+
if (info.effectiveSpendUsd !== undefined && info.monthlyCapUsd !== undefined) {
|
|
247
|
+
return `${info.email} ($${info.effectiveSpendUsd.toFixed(2)}/$${info.monthlyCapUsd.toFixed(0)})`;
|
|
248
|
+
}
|
|
249
|
+
return info.email;
|
|
250
|
+
}
|
|
251
|
+
export default function openaiAutoPiExtension(pi) {
|
|
252
|
+
const { provider, getUserInfo } = createOpenAIAutoPiProvider();
|
|
253
|
+
pi.registerProvider(provider);
|
|
254
|
+
const syncStatus = async (ctx) => {
|
|
255
|
+
if (!ctx.hasUI)
|
|
256
|
+
return;
|
|
257
|
+
if (ctx.model?.provider !== PROVIDER_ID) {
|
|
258
|
+
ctx.ui.setStatus(PROVIDER_ID, undefined);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let info = getUserInfo();
|
|
262
|
+
if (!info) {
|
|
263
|
+
const auth = await ctx.modelRegistry.getProviderAuth(PROVIDER_ID).catch(() => undefined);
|
|
264
|
+
if (auth?.auth.baseUrl && auth.auth.apiKey) {
|
|
265
|
+
info = await fetchGatewayUserInfo(auth.auth.baseUrl, auth.auth.apiKey, APP_HEADERS);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
ctx.ui.setStatus(PROVIDER_ID, formatStatus(info));
|
|
269
|
+
};
|
|
270
|
+
pi.on("session_start", (_event, ctx) => void syncStatus(ctx));
|
|
271
|
+
pi.on("model_select", (_event, ctx) => void syncStatus(ctx));
|
|
272
|
+
}
|