pi-cliproxyapi-native 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/extensions/index.ts +53 -0
- package/package.json +52 -0
- package/src/catalog.ts +248 -0
- package/src/config.ts +64 -0
- package/src/media-defaults.ts +79 -0
- package/src/media.ts +571 -0
- package/src/picker.ts +310 -0
- package/src/provider.ts +221 -0
package/src/picker.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Input,
|
|
6
|
+
SelectList,
|
|
7
|
+
type TuiMouseEvent,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { backendLabel, mediaCapability, parseCatalog, routedName } from "./catalog.ts";
|
|
11
|
+
import type { Config } from "./config.ts";
|
|
12
|
+
import { mediaKey } from "./media.ts";
|
|
13
|
+
import {
|
|
14
|
+
effectiveMediaDefaults,
|
|
15
|
+
MEDIA_DEFAULTS_ENTRY,
|
|
16
|
+
type MediaDefaults,
|
|
17
|
+
readMediaDefaults,
|
|
18
|
+
} from "./media-defaults.ts";
|
|
19
|
+
import { fetchCatalog } from "./provider.ts";
|
|
20
|
+
|
|
21
|
+
export function pickerCatalog(value: unknown) {
|
|
22
|
+
const rows = new Map<
|
|
23
|
+
string,
|
|
24
|
+
{
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
backend: string;
|
|
29
|
+
purpose: "image" | "video";
|
|
30
|
+
supported: boolean;
|
|
31
|
+
disabledReason?: string;
|
|
32
|
+
}
|
|
33
|
+
>();
|
|
34
|
+
for (const entry of parseCatalog(value)) {
|
|
35
|
+
if (entry.hidden || rows.has(entry.id)) continue;
|
|
36
|
+
const media = mediaCapability(entry.id);
|
|
37
|
+
if (!media) continue;
|
|
38
|
+
rows.set(entry.id, {
|
|
39
|
+
id: entry.id,
|
|
40
|
+
name: routedName(entry.id, media.name),
|
|
41
|
+
owner: entry.owner ?? "unknown owner",
|
|
42
|
+
backend: backendLabel(entry.id),
|
|
43
|
+
purpose: media.purpose,
|
|
44
|
+
supported: !media.disabledReason,
|
|
45
|
+
disabledReason: media.disabledReason,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return [...rows.values()];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type PickerRow = ReturnType<typeof pickerCatalog>[number];
|
|
52
|
+
type PickerItem = { value: string; label: string; description: string; supported: boolean };
|
|
53
|
+
|
|
54
|
+
export function searchPickerItems(items: readonly PickerItem[], query: string) {
|
|
55
|
+
const terms = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
56
|
+
return items.filter((item) =>
|
|
57
|
+
terms.every((term) => `${item.label} ${item.value} ${item.description}`.toLowerCase().includes(term)),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultsLabel(defaults: MediaDefaults, automatic?: string) {
|
|
62
|
+
const label = (id?: string | null) => (id === null ? "invalid / disabled" : id ? routedName(id) : "none");
|
|
63
|
+
return `Image: ${label(defaults.image)}${automatic ? " (automatic default)" : ""} | Video: ${label(defaults.video)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pickerItems(rows: PickerRow[], defaults: MediaDefaults) {
|
|
67
|
+
return [
|
|
68
|
+
...rows.map((row) => ({
|
|
69
|
+
value: row.id,
|
|
70
|
+
label: `${row.name} (${row.id})`,
|
|
71
|
+
description: `${row.owner} | ${row.purpose}${row.disabledReason ? ` | ${row.disabledReason}` : ""}${defaults.image === row.id || defaults.video === row.id ? " | selected" : ""}`,
|
|
72
|
+
supported: row.supported,
|
|
73
|
+
})),
|
|
74
|
+
...(["image", "video"] as const).map((purpose) => ({
|
|
75
|
+
value: `clear ${purpose}`,
|
|
76
|
+
label: `Clear ${purpose} default`,
|
|
77
|
+
description: defaults[purpose] === null ? "invalid / disabled" : (defaults[purpose] ?? "none"),
|
|
78
|
+
supported: true,
|
|
79
|
+
})),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class ModelPicker extends Container implements Focusable {
|
|
84
|
+
private input = new Input({
|
|
85
|
+
prompt: "Search: ",
|
|
86
|
+
placeholder: "name, ID, backend, owner, image/video",
|
|
87
|
+
});
|
|
88
|
+
private items: PickerItem[];
|
|
89
|
+
private selected = 0;
|
|
90
|
+
private list: SelectList;
|
|
91
|
+
private visible = 1;
|
|
92
|
+
private allItems: PickerItem[];
|
|
93
|
+
private summary: string;
|
|
94
|
+
private theme: ExtensionContext["ui"]["theme"];
|
|
95
|
+
private kb: Pick<KeybindingsManager, "matches" | "getKeys">;
|
|
96
|
+
private height: () => number;
|
|
97
|
+
private rerender: () => void;
|
|
98
|
+
private done: (value: string | undefined) => void;
|
|
99
|
+
get focused() {
|
|
100
|
+
return this.input.focused;
|
|
101
|
+
}
|
|
102
|
+
set focused(value: boolean) {
|
|
103
|
+
this.input.focused = value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
constructor(
|
|
107
|
+
allItems: PickerItem[],
|
|
108
|
+
query: string,
|
|
109
|
+
summary: string,
|
|
110
|
+
theme: ExtensionContext["ui"]["theme"],
|
|
111
|
+
kb: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
112
|
+
height: () => number,
|
|
113
|
+
rerender: () => void,
|
|
114
|
+
done: (value: string | undefined) => void,
|
|
115
|
+
) {
|
|
116
|
+
super();
|
|
117
|
+
this.allItems = allItems;
|
|
118
|
+
this.summary = summary;
|
|
119
|
+
this.theme = theme;
|
|
120
|
+
this.kb = kb;
|
|
121
|
+
this.height = height;
|
|
122
|
+
this.rerender = rerender;
|
|
123
|
+
this.done = done;
|
|
124
|
+
this.input.setValue(query);
|
|
125
|
+
this.items = searchPickerItems(allItems, query);
|
|
126
|
+
this.list = this.createList();
|
|
127
|
+
this.addChild(this.input);
|
|
128
|
+
this.addChild(this.list);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private createList() {
|
|
132
|
+
const list = new SelectList(
|
|
133
|
+
this.items,
|
|
134
|
+
this.visible,
|
|
135
|
+
{
|
|
136
|
+
selectedPrefix: (text) => this.theme.fg("accent", text),
|
|
137
|
+
selectedText: (text) => this.theme.fg("accent", text),
|
|
138
|
+
description: (text) => this.theme.fg("muted", text),
|
|
139
|
+
scrollInfo: (text) => this.theme.fg("dim", text),
|
|
140
|
+
noMatch: (text) => this.theme.fg("warning", text),
|
|
141
|
+
},
|
|
142
|
+
{ minPrimaryColumnWidth: 32, maxPrimaryColumnWidth: 80 },
|
|
143
|
+
);
|
|
144
|
+
list.setSelectedIndex(this.selected);
|
|
145
|
+
return list;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private rebuild() {
|
|
149
|
+
this.removeChild(this.list);
|
|
150
|
+
this.list = this.createList();
|
|
151
|
+
this.addChild(this.list);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
override handleMouse(_event: TuiMouseEvent) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
handleInput(data: string) {
|
|
159
|
+
if (this.kb.matches(data, "tui.select.cancel")) {
|
|
160
|
+
this.done(undefined);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (this.kb.matches(data, "tui.select.confirm")) {
|
|
164
|
+
const item = this.items[this.selected];
|
|
165
|
+
if (item?.supported) this.done(item.value);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (this.kb.matches(data, "tui.select.up")) this.selected = Math.max(0, this.selected - 1);
|
|
169
|
+
else if (this.kb.matches(data, "tui.select.down"))
|
|
170
|
+
this.selected = Math.min(this.items.length - 1, this.selected + 1);
|
|
171
|
+
else if (this.kb.matches(data, "tui.select.pageUp"))
|
|
172
|
+
this.selected = Math.max(0, this.selected - this.visible);
|
|
173
|
+
else if (this.kb.matches(data, "tui.select.pageDown"))
|
|
174
|
+
this.selected = Math.min(this.items.length - 1, this.selected + this.visible);
|
|
175
|
+
else {
|
|
176
|
+
const before = this.input.getValue();
|
|
177
|
+
this.input.handleInput(data);
|
|
178
|
+
if (before !== this.input.getValue()) {
|
|
179
|
+
this.items = searchPickerItems(this.allItems, this.input.getValue());
|
|
180
|
+
this.selected = 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.rebuild();
|
|
184
|
+
this.rerender();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
override render(width: number) {
|
|
188
|
+
const height = Math.max(1, this.height());
|
|
189
|
+
const visible = Math.max(1, Math.min(10, height - 7));
|
|
190
|
+
if (visible !== this.visible) {
|
|
191
|
+
this.visible = visible;
|
|
192
|
+
this.rebuild();
|
|
193
|
+
}
|
|
194
|
+
const item = this.items[this.selected];
|
|
195
|
+
const hint = `${this.kb.getKeys("tui.select.confirm").join("/")} select | ${this.kb.getKeys("tui.select.cancel").join("/")} cancel`;
|
|
196
|
+
return [
|
|
197
|
+
this.theme.fg("accent", "CLIProxyAPI images and videos (keyboard only; selection does not generate)"),
|
|
198
|
+
this.summary,
|
|
199
|
+
...super.render(Math.max(4, width)),
|
|
200
|
+
item ? `ID: ${item.value}` : "No matching models",
|
|
201
|
+
item?.description ?? "",
|
|
202
|
+
this.theme.fg("dim", hint),
|
|
203
|
+
]
|
|
204
|
+
.slice(0, height)
|
|
205
|
+
.map((line) => truncateToWidth(line, Math.max(0, width)));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function registerModelPicker(pi: ExtensionAPI, config: Config) {
|
|
210
|
+
pi.registerCommand("cli:model", {
|
|
211
|
+
description: "Search CLIProxyAPI image/video models and select a session media default",
|
|
212
|
+
getArgumentCompletions: (prefix) =>
|
|
213
|
+
["list", "search ", "select ", "clear image", "clear video"]
|
|
214
|
+
.filter((value) => value.startsWith(prefix))
|
|
215
|
+
.map((value) => ({ value, label: value })),
|
|
216
|
+
async handler(args, ctx) {
|
|
217
|
+
const report = (content: string, error = false) => {
|
|
218
|
+
if (ctx.hasUI) ctx.ui.notify(content, error ? "error" : "info");
|
|
219
|
+
else if (ctx.mode === "print") console.error(content);
|
|
220
|
+
else
|
|
221
|
+
pi.sendMessage(
|
|
222
|
+
{ customType: "cliproxyapi-models", content, display: true },
|
|
223
|
+
{ triggerTurn: false },
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
const input = args.trim();
|
|
228
|
+
const clear = input === "clear image" ? "image" : input === "clear video" ? "video" : undefined;
|
|
229
|
+
const save = (choice: string) => {
|
|
230
|
+
const defaults = readMediaDefaults(config, ctx);
|
|
231
|
+
if (choice === "clear image") delete defaults.image;
|
|
232
|
+
else if (choice === "clear video") delete defaults.video;
|
|
233
|
+
else {
|
|
234
|
+
const capability = mediaCapability(choice);
|
|
235
|
+
if (!capability || capability.disabledReason)
|
|
236
|
+
throw new Error("Unsupported CLIProxyAPI media selection.");
|
|
237
|
+
defaults[capability.purpose] = choice;
|
|
238
|
+
}
|
|
239
|
+
pi.appendEntry(MEDIA_DEFAULTS_ENTRY, { version: 1, endpoint: config.baseUrl, defaults });
|
|
240
|
+
const effective = effectiveMediaDefaults(config, ctx);
|
|
241
|
+
report(defaultsLabel(effective.defaults, effective.automatic));
|
|
242
|
+
};
|
|
243
|
+
if (clear) {
|
|
244
|
+
save(`clear ${clear}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (input.startsWith("clear")) throw new Error("Usage: /cli:model clear image|video");
|
|
248
|
+
const disabledReason = input.startsWith("select ")
|
|
249
|
+
? mediaCapability(input.slice(7).trim())?.disabledReason
|
|
250
|
+
: undefined;
|
|
251
|
+
if (disabledReason) {
|
|
252
|
+
report(disabledReason, true);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const signal = AbortSignal.any([...(ctx.signal ? [ctx.signal] : []), AbortSignal.timeout(15000)]);
|
|
256
|
+
const key = await mediaKey(ctx, signal);
|
|
257
|
+
const rows = pickerCatalog(await fetchCatalog(config, key, signal));
|
|
258
|
+
const { defaults, automatic } = effectiveMediaDefaults(config, ctx);
|
|
259
|
+
const items = pickerItems(rows, defaults);
|
|
260
|
+
const explicit = input.startsWith("select ") ? input.slice(7).trim() : undefined;
|
|
261
|
+
const query = input === "list" ? "" : input.startsWith("search ") ? input.slice(7) : input;
|
|
262
|
+
let choice = explicit;
|
|
263
|
+
if (choice === undefined) {
|
|
264
|
+
if (ctx.mode === "tui" && input !== "list") {
|
|
265
|
+
choice = await ctx.ui.custom<string | undefined>(
|
|
266
|
+
(tui, theme, kb, done) =>
|
|
267
|
+
new ModelPicker(
|
|
268
|
+
items,
|
|
269
|
+
query,
|
|
270
|
+
defaultsLabel(defaults, automatic),
|
|
271
|
+
theme,
|
|
272
|
+
kb,
|
|
273
|
+
() => tui.terminal.rows,
|
|
274
|
+
() => tui.requestRender(),
|
|
275
|
+
done,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
const matches = searchPickerItems(items, query);
|
|
280
|
+
report(
|
|
281
|
+
[
|
|
282
|
+
defaultsLabel(defaults, automatic),
|
|
283
|
+
...matches.slice(0, 100).map((item) => `${item.label} | ${item.description}`),
|
|
284
|
+
...(matches.length > 100
|
|
285
|
+
? [`Showing 100 of ${matches.length}; narrow with /cli:model search <query>.`]
|
|
286
|
+
: []),
|
|
287
|
+
"Use /cli:model select <exact ID> or /cli:model clear image|video. Selection does not generate.",
|
|
288
|
+
].join("\n"),
|
|
289
|
+
);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (choice === undefined) return;
|
|
294
|
+
if (choice === "clear image" || choice === "clear video") {
|
|
295
|
+
save(choice);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const row = rows.find((row) => row.id === choice);
|
|
299
|
+
if (!row?.supported)
|
|
300
|
+
throw new Error("Model unavailable or unsupported. Use /cli:model to list supported IDs.");
|
|
301
|
+
save(row.id);
|
|
302
|
+
} catch {
|
|
303
|
+
report(
|
|
304
|
+
"CLIProxyAPI model selection failed: unavailable/unsupported model, invalid command, authentication, or connection error. Check /login cliproxyapi. Use /cli:model list, select <exact ID>, or clear image|video.",
|
|
305
|
+
true,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
type Api,
|
|
4
|
+
createProvider,
|
|
5
|
+
envApiKeyAuth,
|
|
6
|
+
lazyStream,
|
|
7
|
+
type Model,
|
|
8
|
+
type ProviderStreams,
|
|
9
|
+
type RefreshModelsContext,
|
|
10
|
+
type SimpleStreamOptions,
|
|
11
|
+
} from "@earendil-works/pi-ai";
|
|
12
|
+
// Pi's SDK loader aliases these entry points; deep pi-ai imports resolve to invalid paths.
|
|
13
|
+
import {
|
|
14
|
+
anthropicMessagesApi,
|
|
15
|
+
googleGenerativeAIApi,
|
|
16
|
+
openAICompletionsApi,
|
|
17
|
+
openAIResponsesApi,
|
|
18
|
+
} from "@earendil-works/pi-ai/compat";
|
|
19
|
+
import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
|
|
20
|
+
import { type CpaApi, endpoint, mapCatalog, modelRoute } from "./catalog.ts";
|
|
21
|
+
import { type Config, isRecord, PROVIDER_ID } from "./config.ts";
|
|
22
|
+
|
|
23
|
+
export function builtinCatalog() {
|
|
24
|
+
return [
|
|
25
|
+
...getBuiltinModels("anthropic"),
|
|
26
|
+
...getBuiltinModels("openai"),
|
|
27
|
+
...getBuiltinModels("openai-codex"),
|
|
28
|
+
...getBuiltinModels("google"),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateKey(key: string) {
|
|
33
|
+
if (!/^[!-~]{1,4096}$/.test(key)) {
|
|
34
|
+
throw new Error("CLIProxyAPI requires a non-empty API key without whitespace or control characters.");
|
|
35
|
+
}
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) {
|
|
40
|
+
validateKey(key);
|
|
41
|
+
const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
|
|
42
|
+
let response: Response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetch(`${config.baseUrl}/v1/models`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
46
|
+
redirect: "error",
|
|
47
|
+
signal: boundedSignal,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
throw new Error("CLIProxyAPI discovery failed: connection, timeout, or redirect error.");
|
|
52
|
+
}
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
await response.body?.cancel();
|
|
55
|
+
throw new Error(`CLIProxyAPI discovery failed (HTTP ${response.status}).`);
|
|
56
|
+
}
|
|
57
|
+
let payload: unknown;
|
|
58
|
+
const reader = response.body?.getReader();
|
|
59
|
+
if (!reader) throw new Error("CLIProxyAPI discovery returned an empty response.");
|
|
60
|
+
try {
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let text = "";
|
|
63
|
+
let size = 0;
|
|
64
|
+
while (true) {
|
|
65
|
+
const chunk = await reader.read();
|
|
66
|
+
if (chunk.done) break;
|
|
67
|
+
size += chunk.value.byteLength;
|
|
68
|
+
if (size > 4 * 1024 * 1024) {
|
|
69
|
+
await reader.cancel();
|
|
70
|
+
throw new Error("Catalog too large.");
|
|
71
|
+
}
|
|
72
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
73
|
+
}
|
|
74
|
+
payload = JSON.parse(text + decoder.decode());
|
|
75
|
+
} catch {
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
throw new Error("CLIProxyAPI catalog is invalid, incomplete, or larger than 4 MiB.");
|
|
78
|
+
} finally {
|
|
79
|
+
reader.releaseLock();
|
|
80
|
+
}
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function discover(
|
|
85
|
+
config: Config,
|
|
86
|
+
key: string,
|
|
87
|
+
signal: AbortSignal,
|
|
88
|
+
known: readonly Model<Api>[],
|
|
89
|
+
) {
|
|
90
|
+
return mapCatalog(await fetchCatalog(config, key, signal), config, known);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function nonStrictTools(payload: unknown) {
|
|
94
|
+
if (!isRecord(payload) || !Array.isArray(payload.tools)) return payload;
|
|
95
|
+
return {
|
|
96
|
+
...payload,
|
|
97
|
+
tools: payload.tools.map((tool: unknown) =>
|
|
98
|
+
isRecord(tool) && tool.type === "function" && tool.strict === undefined
|
|
99
|
+
? { ...tool, strict: null }
|
|
100
|
+
: tool,
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function route(streams: ProviderStreams, baseUrl: string): ProviderStreams {
|
|
106
|
+
const wrap =
|
|
107
|
+
(stream: ProviderStreams["streamSimple"]): ProviderStreams["streamSimple"] =>
|
|
108
|
+
(selected, context, options) => {
|
|
109
|
+
// A resumed selection must not send the current key to an old endpoint.
|
|
110
|
+
const model = { ...selected, baseUrl: endpoint(baseUrl, selected.api) };
|
|
111
|
+
const { metadataId, backend } = modelRoute(model.id);
|
|
112
|
+
const google = backend && model.api === "google-generative-ai";
|
|
113
|
+
const adapterModel = google ? { ...model, id: metadataId } : model;
|
|
114
|
+
// Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route.
|
|
115
|
+
const adapterContext = google
|
|
116
|
+
? {
|
|
117
|
+
...context,
|
|
118
|
+
messages: context.messages.map((message) =>
|
|
119
|
+
message.role === "assistant" && message.provider === model.provider
|
|
120
|
+
? {
|
|
121
|
+
...message,
|
|
122
|
+
model:
|
|
123
|
+
message.model === model.id
|
|
124
|
+
? metadataId
|
|
125
|
+
: message.model === metadataId
|
|
126
|
+
? model.id
|
|
127
|
+
: message.model,
|
|
128
|
+
}
|
|
129
|
+
: message,
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
: context;
|
|
133
|
+
const onPayload: SimpleStreamOptions["onPayload"] = async (payload) => {
|
|
134
|
+
// Responses defaults omitted strictness to strict; preserve optional tool arguments.
|
|
135
|
+
const adapted =
|
|
136
|
+
google && isRecord(payload)
|
|
137
|
+
? { ...payload, model: model.id }
|
|
138
|
+
: model.api === "openai-responses"
|
|
139
|
+
? nonStrictTools(payload)
|
|
140
|
+
: payload;
|
|
141
|
+
const result = await options?.onPayload?.(adapted, model);
|
|
142
|
+
return result === undefined ? adapted : result;
|
|
143
|
+
};
|
|
144
|
+
const run = () =>
|
|
145
|
+
stream(adapterModel, adapterContext, {
|
|
146
|
+
...options,
|
|
147
|
+
onPayload,
|
|
148
|
+
onResponse: options?.onResponse ? (response) => options.onResponse?.(response, model) : undefined,
|
|
149
|
+
});
|
|
150
|
+
if (!backend) return run();
|
|
151
|
+
return lazyStream(model, async () => ({
|
|
152
|
+
async *[Symbol.asyncIterator]() {
|
|
153
|
+
for await (const event of run()) {
|
|
154
|
+
if (event.type === "done") yield { ...event, message: { ...event.message, model: model.id } };
|
|
155
|
+
else if (event.type === "error") yield { ...event, error: { ...event.error, model: model.id } };
|
|
156
|
+
else yield { ...event, partial: { ...event.partial, model: model.id } };
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
};
|
|
161
|
+
return { stream: wrap(streams.stream), streamSimple: wrap(streams.streamSimple) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createCliproxyProvider(config: Config, known: readonly Model<Api>[] = builtinCatalog()) {
|
|
165
|
+
const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
|
|
166
|
+
const scope = createHash("sha256")
|
|
167
|
+
.update(JSON.stringify([4, config]))
|
|
168
|
+
.digest("hex");
|
|
169
|
+
const provider = createProvider<CpaApi>({
|
|
170
|
+
id: PROVIDER_ID,
|
|
171
|
+
name: "CLIProxyAPI",
|
|
172
|
+
baseUrl: config.baseUrl,
|
|
173
|
+
auth: {
|
|
174
|
+
apiKey: {
|
|
175
|
+
...standardAuth,
|
|
176
|
+
async login(interaction) {
|
|
177
|
+
const credential = await standardAuth.login?.(interaction);
|
|
178
|
+
if (!credential?.key) throw new Error("CLIProxyAPI requires an API key.");
|
|
179
|
+
validateKey(credential.key);
|
|
180
|
+
await discover(config, credential.key, interaction.signal, known);
|
|
181
|
+
return credential;
|
|
182
|
+
},
|
|
183
|
+
async resolve(input) {
|
|
184
|
+
const result = await standardAuth.resolve(input);
|
|
185
|
+
if (!result?.auth.apiKey) return undefined;
|
|
186
|
+
const key = validateKey(result.auth.apiKey);
|
|
187
|
+
return { ...result, auth: { apiKey: key, headers: { Authorization: `Bearer ${key}` } } };
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
models: [],
|
|
192
|
+
async fetchModels(context) {
|
|
193
|
+
if (context.credential?.type !== "api_key" || !context.credential.key) {
|
|
194
|
+
throw new Error("Use /login cliproxyapi or set CLIPROXYAPI_API_KEY before refreshing.");
|
|
195
|
+
}
|
|
196
|
+
return (await discover(config, context.credential.key, context.signal, known)).models;
|
|
197
|
+
},
|
|
198
|
+
api: {
|
|
199
|
+
"anthropic-messages": route(anthropicMessagesApi(), config.baseUrl),
|
|
200
|
+
"openai-responses": route(openAIResponsesApi(), config.baseUrl),
|
|
201
|
+
"openai-completions": route(openAICompletionsApi(), config.baseUrl),
|
|
202
|
+
"google-generative-ai": route(googleGenerativeAIApi(), config.baseUrl),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const refresh = provider.refreshModels;
|
|
206
|
+
return {
|
|
207
|
+
...provider,
|
|
208
|
+
async refreshModels(context: RefreshModelsContext) {
|
|
209
|
+
await refresh?.({
|
|
210
|
+
...context,
|
|
211
|
+
// Pi owns storage and publication; the validator isolates endpoint/config changes.
|
|
212
|
+
stored: context.stored?.etag === scope ? context.stored : { models: [] },
|
|
213
|
+
publish: (publication) =>
|
|
214
|
+
context.publish({
|
|
215
|
+
...publication,
|
|
216
|
+
persist: publication.persist ? { ...publication.persist, etag: scope } : publication.persist,
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|