@wuyaos/pi-model-config 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +200 -2
- package/extensions/model-config.ts +27 -0
- package/model-config.schema.json +269 -0
- package/package.json +52 -4
- package/src/command.ts +1053 -0
- package/src/config.ts +438 -0
- package/src/context-probe.ts +90 -0
- package/src/discovery.ts +427 -0
- package/src/i18n.ts +824 -0
- package/src/model-editor.ts +326 -0
- package/src/model-test.ts +212 -0
- package/src/panel.ts +477 -0
- package/src/runtime.ts +298 -0
- package/src/selector.ts +433 -0
- package/src/thinking-defaults.ts +39 -0
- package/src/thinking-probe.ts +84 -0
- package/src/transport.ts +94 -0
- package/src/types.ts +150 -0
- package/src/ui-config.ts +44 -0
- package/src/ui.ts +179 -0
package/src/selector.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { AnyModel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Input,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
type Component,
|
|
11
|
+
type Focusable,
|
|
12
|
+
type TUI,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
15
|
+
import { COMPAT_CONTEXT_WINDOW, COMPAT_MAX_TOKENS } from "./discovery.ts";
|
|
16
|
+
import { MODEL_CONFIG_OVERLAY_OPTIONS, overlayViewportRows, renderActionFooter, renderPanelBox, type FooterAction } from "./panel.ts";
|
|
17
|
+
import type { CandidateModel, ManagedModel, ManagedModelType, ModelMetadataEntry, ProviderDraft, SelectionResult, ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
|
|
18
|
+
import { identityOf } from "./types.ts";
|
|
19
|
+
import { protocolThinkingMap } from "./thinking-defaults.ts";
|
|
20
|
+
import { describeThinking } from "./model-editor.ts";
|
|
21
|
+
|
|
22
|
+
function modelTypeLabel(language: UiLanguage, type: ManagedModelType): string {
|
|
23
|
+
return type === "classifier" ? t(language, "selectorTypeClassifier")
|
|
24
|
+
: type === "image" ? t(language, "selectorTypeImage")
|
|
25
|
+
: t(language, "selectorTypeChat");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pad(line: string, width: number): string {
|
|
29
|
+
const clipped = truncateToWidth(line, Math.max(1, width), "");
|
|
30
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function joinColumns(left: string[], right: string[], leftWidth: number, rightWidth: number): string[] {
|
|
34
|
+
const height = Math.max(left.length, right.length);
|
|
35
|
+
return Array.from({ length: height }, (_, index) => `${pad(left[index] ?? "", leftWidth)} ${pad(right[index] ?? "", rightWidth)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cloneManaged(model: ManagedModel): ManagedModel {
|
|
39
|
+
return structuredClone(model);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripCandidate(model: CandidateModel): ManagedModel {
|
|
43
|
+
const { source: _source, mutableType: _mutable, readOnly: _readOnly, readOnlyReason: _reason, contextSource: _contextSource, maxTokensSource: _maxTokensSource, thinkingSource: _thinkingSource, ...managed } = model;
|
|
44
|
+
return managed as ManagedModel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function candidateFromInstalled(model: AnyModel): CandidateModel {
|
|
48
|
+
const common = {
|
|
49
|
+
id: model.id,
|
|
50
|
+
name: model.name,
|
|
51
|
+
api: model.api,
|
|
52
|
+
baseUrl: model.baseUrl,
|
|
53
|
+
input: [...model.input],
|
|
54
|
+
cost: { ...model.cost },
|
|
55
|
+
...(model.headers ? { headers: { ...model.headers } } : {}),
|
|
56
|
+
source: "installed" as const,
|
|
57
|
+
mutableType: false,
|
|
58
|
+
contextSource: model.type === "image" ? undefined : "catalog" as const,
|
|
59
|
+
maxTokensSource: model.type === "chat" ? "catalog" as const : undefined,
|
|
60
|
+
readOnly: true,
|
|
61
|
+
readOnlyReason: "runtime-catalog" as const,
|
|
62
|
+
};
|
|
63
|
+
if (model.type === "classifier") return { ...common, type: "classifier", contextWindow: model.contextWindow };
|
|
64
|
+
if (model.type === "image") return { ...common, type: "image", output: [...model.output] };
|
|
65
|
+
return {
|
|
66
|
+
...common,
|
|
67
|
+
type: "chat",
|
|
68
|
+
reasoning: model.reasoning,
|
|
69
|
+
...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap }, thinkingSource: "catalog" as const } : {}),
|
|
70
|
+
contextWindow: model.contextWindow,
|
|
71
|
+
maxTokens: model.maxTokens,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function candidateFromManaged(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
|
|
76
|
+
return { ...cloneManaged(model), source: "managed", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined) } as CandidateModel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function candidateFromExternal(model: ManagedModel, metadata?: ModelMetadataEntry): CandidateModel {
|
|
80
|
+
return { ...cloneManaged(model), source: "external", mutableType: false, contextSource: metadata?.contextSource ?? (model.type === "image" ? undefined : "existing"), maxTokensSource: metadata?.maxTokensSource ?? (model.type === "chat" ? "existing" : undefined), thinkingSource: metadata?.thinkingSource ?? (model.type === "chat" && model.thinkingLevelMap ? "existing" : undefined), readOnly: true, readOnlyReason: "external-config" } as CandidateModel;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function mergeCandidates(provider: string, installed: CandidateModel[], managed: CandidateModel[], remote: CandidateModel[]): CandidateModel[] {
|
|
84
|
+
const rows = new Map<string, CandidateModel>();
|
|
85
|
+
for (const model of managed) rows.set(identityOf(model, provider), model);
|
|
86
|
+
for (const model of installed) {
|
|
87
|
+
const key = identityOf(model, provider);
|
|
88
|
+
if (!rows.has(key)) rows.set(key, model);
|
|
89
|
+
}
|
|
90
|
+
for (const model of remote) {
|
|
91
|
+
const key = identityOf(model, provider);
|
|
92
|
+
// Discovery may fill only a subset of metadata. Existing installed/managed/external
|
|
93
|
+
// entries remain authoritative unless a future explicit field editor changes them.
|
|
94
|
+
if (!rows.has(key)) rows.set(key, model);
|
|
95
|
+
}
|
|
96
|
+
return [...rows.values()].sort((a, b) => a.type.localeCompare(b.type) || a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ModelPanelState {
|
|
100
|
+
search: string;
|
|
101
|
+
selectedId?: string;
|
|
102
|
+
focus: "search" | "list" | "details" | "actions";
|
|
103
|
+
actionIndex: number;
|
|
104
|
+
detailScroll: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CycleMetadata {
|
|
108
|
+
contextWindow?: number;
|
|
109
|
+
maxTokens?: number;
|
|
110
|
+
reasoning?: boolean;
|
|
111
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
112
|
+
thinkingSource?: ThinkingMetadataSource;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class ModelSelector implements Component, Focusable {
|
|
116
|
+
private search: Input;
|
|
117
|
+
private cycleMetadata = new WeakMap<object, CycleMetadata>();
|
|
118
|
+
private selected = new Set<string>();
|
|
119
|
+
private filtered: CandidateModel[] = [];
|
|
120
|
+
private index = 0;
|
|
121
|
+
private status = "";
|
|
122
|
+
private detailMaxScroll = 0;
|
|
123
|
+
private _focused = false;
|
|
124
|
+
private finished = false;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly tui: TUI,
|
|
128
|
+
private readonly theme: Theme,
|
|
129
|
+
private readonly language: UiLanguage,
|
|
130
|
+
private readonly provider: string,
|
|
131
|
+
private readonly draft: ProviderDraft,
|
|
132
|
+
private readonly rows: CandidateModel[],
|
|
133
|
+
private readonly initialSelected: ManagedModel[] | undefined,
|
|
134
|
+
private readonly state: ModelPanelState,
|
|
135
|
+
private readonly done: (result: SelectionResult) => void,
|
|
136
|
+
private readonly initialMetadata: ModelMetadataEntry[] | undefined,
|
|
137
|
+
) {
|
|
138
|
+
this.search = new Input({ prompt: t(language, "selectorSearchPrompt"), placeholder: t(language, "selectorSearchPlaceholder") });
|
|
139
|
+
this.search.setValue(state.search);
|
|
140
|
+
if (initialSelected) {
|
|
141
|
+
const selectedModels = new Map(initialSelected.map((model) => [identityOf(model, provider), model]));
|
|
142
|
+
const selectedMetadata = new Map((initialMetadata ?? []).map((entry) => [identityOf(entry, provider), entry]));
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const key = identityOf(row, provider);
|
|
145
|
+
const selected = selectedModels.get(key);
|
|
146
|
+
const metadata = selectedMetadata.get(key);
|
|
147
|
+
if (selected && !row.readOnly) Object.assign(row, cloneManaged(selected));
|
|
148
|
+
if (metadata && !row.readOnly) Object.assign(row, {
|
|
149
|
+
contextSource: metadata.contextSource,
|
|
150
|
+
maxTokensSource: metadata.maxTokensSource,
|
|
151
|
+
thinkingSource: metadata.thinkingSource,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
this.cycleMetadata.set(row, {
|
|
157
|
+
...(row.type !== "image" ? { contextWindow: row.contextWindow } : {}),
|
|
158
|
+
...(row.type === "chat" ? { maxTokens: row.maxTokens, reasoning: row.reasoning, thinkingLevelMap: row.thinkingLevelMap ? { ...row.thinkingLevelMap } : undefined, thinkingSource: row.thinkingSource } : {}),
|
|
159
|
+
});
|
|
160
|
+
if (row.source === "managed" || row.source === "external") this.selected.add(identityOf(row, provider));
|
|
161
|
+
}
|
|
162
|
+
if (initialSelected) {
|
|
163
|
+
for (const row of rows) if (!row.readOnly) this.selected.delete(identityOf(row, provider));
|
|
164
|
+
for (const model of initialSelected) this.selected.add(identityOf(model, provider));
|
|
165
|
+
}
|
|
166
|
+
this.search.onSubmit = () => this.toggleCurrent();
|
|
167
|
+
this.refresh();
|
|
168
|
+
const remembered = state.selectedId ? this.filtered.findIndex((row) => row.id === state.selectedId) : -1;
|
|
169
|
+
if (remembered >= 0) this.index = remembered;
|
|
170
|
+
this.syncCurrent();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get focused(): boolean { return this._focused; }
|
|
174
|
+
set focused(value: boolean) {
|
|
175
|
+
this._focused = value;
|
|
176
|
+
this.search.focused = value && this.state.focus === "search";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private request(): void {
|
|
180
|
+
this.search.focused = this._focused && this.state.focus === "search";
|
|
181
|
+
this.tui.requestRender();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private current(): CandidateModel | undefined { return this.filtered[this.index]; }
|
|
185
|
+
private syncCurrent(): void { this.state.selectedId = this.current()?.id; }
|
|
186
|
+
|
|
187
|
+
private selectedRows(): CandidateModel[] { return this.rows.filter((row) => !row.readOnly && this.selected.has(this.key(row))); }
|
|
188
|
+
private selectedModels(): ManagedModel[] { return this.selectedRows().map(stripCandidate); }
|
|
189
|
+
private selectedMetadata(): ModelMetadataEntry[] { return this.selectedRows().map((row) => ({ type: row.type, id: row.id, contextSource: row.contextSource, maxTokensSource: row.maxTokensSource, thinkingSource: row.type === "chat" ? row.thinkingSource : undefined })); }
|
|
190
|
+
|
|
191
|
+
private refresh(): void {
|
|
192
|
+
const query = this.search.getValue().trim().toLowerCase();
|
|
193
|
+
this.state.search = this.search.getValue();
|
|
194
|
+
this.filtered = query
|
|
195
|
+
? this.rows.filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query))
|
|
196
|
+
: [...this.rows];
|
|
197
|
+
this.index = Math.min(this.index, Math.max(0, this.filtered.length - 1));
|
|
198
|
+
this.syncCurrent();
|
|
199
|
+
this.invalidate();
|
|
200
|
+
this.request();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private key(model: CandidateModel): string { return identityOf(model, this.provider); }
|
|
204
|
+
|
|
205
|
+
private toggleCurrent(): void {
|
|
206
|
+
const row = this.filtered[this.index];
|
|
207
|
+
if (!row) return;
|
|
208
|
+
if (row.readOnly) {
|
|
209
|
+
this.status = t(this.language, row.readOnlyReason === "external-config" ? "selectorReadonlyExternal" : row.readOnlyReason === "runtime-catalog" ? "selectorReadonlyRuntime" : "selectorReadonlyStatus");
|
|
210
|
+
this.tui.requestRender();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.status = "";
|
|
214
|
+
const key = this.key(row);
|
|
215
|
+
if (this.selected.has(key)) this.selected.delete(key); else this.selected.add(key);
|
|
216
|
+
this.tui.requestRender();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private cycleType(): void {
|
|
220
|
+
const row = this.filtered[this.index];
|
|
221
|
+
if (!row?.mutableType) return;
|
|
222
|
+
const types: ManagedModelType[] = ["chat", "classifier", "image"];
|
|
223
|
+
const nextType = types[(types.indexOf(row.type) + 1) % types.length]!;
|
|
224
|
+
const oldKey = this.key(row);
|
|
225
|
+
const metadata = this.cycleMetadata.get(row) ?? {};
|
|
226
|
+
const nextKey = identityOf({ id: row.id, type: nextType }, this.provider);
|
|
227
|
+
if (this.rows.some((candidate) => candidate !== row && this.key(candidate) === nextKey)) {
|
|
228
|
+
this.status = t(this.language, "selectorTypeConflict", { type: modelTypeLabel(this.language, nextType), identity: `${modelTypeLabel(this.language, nextType)}/${row.id}` });
|
|
229
|
+
this.tui.requestRender();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.status = "";
|
|
233
|
+
const wasSelected = this.selected.delete(oldKey);
|
|
234
|
+
const common = {
|
|
235
|
+
id: row.id,
|
|
236
|
+
name: row.name,
|
|
237
|
+
baseUrl: row.baseUrl,
|
|
238
|
+
input: row.input,
|
|
239
|
+
cost: row.cost,
|
|
240
|
+
source: row.source,
|
|
241
|
+
mutableType: true,
|
|
242
|
+
};
|
|
243
|
+
let next: CandidateModel;
|
|
244
|
+
if (nextType === "classifier") {
|
|
245
|
+
next = { ...common, type: nextType, api: this.draft.classifierApi, contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW };
|
|
246
|
+
} else if (nextType === "image") {
|
|
247
|
+
next = { ...common, type: nextType, api: this.draft.imageApi, output: ["image"] };
|
|
248
|
+
} else {
|
|
249
|
+
const protocolMap = metadata.thinkingLevelMap ? undefined : protocolThinkingMap(this.draft.chatApi);
|
|
250
|
+
next = {
|
|
251
|
+
...common,
|
|
252
|
+
type: nextType,
|
|
253
|
+
api: this.draft.chatApi,
|
|
254
|
+
reasoning: metadata.reasoning ?? true,
|
|
255
|
+
...(metadata.thinkingLevelMap ? { thinkingLevelMap: { ...metadata.thinkingLevelMap }, thinkingSource: metadata.thinkingSource }
|
|
256
|
+
: protocolMap ? { thinkingLevelMap: protocolMap, thinkingSource: "protocol" as const } : {}),
|
|
257
|
+
contextWindow: metadata.contextWindow ?? COMPAT_CONTEXT_WINDOW,
|
|
258
|
+
maxTokens: metadata.maxTokens ?? COMPAT_MAX_TOKENS,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
this.cycleMetadata.set(next, metadata);
|
|
262
|
+
const rowIndex = this.rows.indexOf(row);
|
|
263
|
+
if (rowIndex >= 0) this.rows[rowIndex] = next;
|
|
264
|
+
if (wasSelected) this.selected.add(this.key(next));
|
|
265
|
+
this.refresh();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private actions(): FooterAction[] {
|
|
269
|
+
return [
|
|
270
|
+
{ id: "search", label: t(this.language, "modelSearch"), key: "/" },
|
|
271
|
+
{ id: "selectFiltered", label: t(this.language, "modelSelectFiltered"), key: Key.ctrl("a") },
|
|
272
|
+
{ id: "deselectFiltered", label: t(this.language, "modelDeselectFiltered"), key: Key.ctrl("d") },
|
|
273
|
+
{ id: "cycleType", label: t(this.language, "modelCycleType"), key: Key.ctrl("t") },
|
|
274
|
+
{ id: "edit", label: t(this.language, "modelEditParameters"), key: Key.ctrl("e") },
|
|
275
|
+
{ id: "probe", label: t(this.language, "modelProbeContext"), key: Key.ctrl("k") },
|
|
276
|
+
{ id: "probeThinking", label: t(this.language, "modelProbeThinking"), key: Key.ctrl("l") },
|
|
277
|
+
{ id: "fetch", label: t(this.language, "modelFetch"), key: Key.ctrl("r") },
|
|
278
|
+
{ id: "preview", label: t(this.language, "modelPreview"), key: Key.ctrl("s") },
|
|
279
|
+
{ id: "back", label: t(this.language, "modelBack"), key: Key.escape },
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private finish(action: SelectionResult["action"]): void {
|
|
284
|
+
if (this.finished) return;
|
|
285
|
+
this.finished = true;
|
|
286
|
+
const current = this.current();
|
|
287
|
+
this.done({
|
|
288
|
+
action,
|
|
289
|
+
cancelled: action === "back",
|
|
290
|
+
selected: this.selectedModels(),
|
|
291
|
+
metadata: this.selectedMetadata(),
|
|
292
|
+
...(current ? { target: { id: current.id, type: current.type, source: current.source, readOnlyReason: current.readOnlyReason } } : {}),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private activate(action: FooterAction | undefined): void {
|
|
297
|
+
if (!action) return;
|
|
298
|
+
if (action.id === "search") { this.state.focus = "search"; this.request(); return; }
|
|
299
|
+
if (action.id === "selectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row)); this.request(); return; }
|
|
300
|
+
if (action.id === "deselectFiltered") { for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row)); this.request(); return; }
|
|
301
|
+
if (action.id === "cycleType") { this.cycleType(); return; }
|
|
302
|
+
this.finish(action.id as SelectionResult["action"]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
handleInput(data: string): void {
|
|
306
|
+
if (data === "/") { this.state.focus = "search"; this.request(); return; }
|
|
307
|
+
if (matchesKey(data, Key.ctrl("r"))) { this.finish("fetch"); return; }
|
|
308
|
+
if (matchesKey(data, Key.ctrl("e"))) { this.finish("edit"); return; }
|
|
309
|
+
if (matchesKey(data, Key.ctrl("k"))) { this.finish("probe"); return; }
|
|
310
|
+
if (matchesKey(data, Key.ctrl("l"))) { this.finish("probeThinking"); return; }
|
|
311
|
+
if (matchesKey(data, Key.ctrl("s"))) { this.finish("preview"); return; }
|
|
312
|
+
if (matchesKey(data, Key.escape)) { this.finish("back"); return; }
|
|
313
|
+
if (matchesKey(data, Key.ctrl("t"))) { this.cycleType(); return; }
|
|
314
|
+
if (matchesKey(data, Key.ctrl("a"))) {
|
|
315
|
+
for (const row of this.filtered) if (!row.readOnly) this.selected.add(this.key(row));
|
|
316
|
+
this.tui.requestRender();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
320
|
+
for (const row of this.filtered) if (!row.readOnly) this.selected.delete(this.key(row));
|
|
321
|
+
this.tui.requestRender();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab")) {
|
|
325
|
+
const order: Array<ModelPanelState["focus"]> = ["search", "list", "details", "actions"];
|
|
326
|
+
const current = Math.max(0, order.indexOf(this.state.focus));
|
|
327
|
+
const delta = matchesKey(data, "shift+tab") ? -1 : 1;
|
|
328
|
+
this.state.focus = order[(current + delta + order.length) % order.length]!;
|
|
329
|
+
this.request();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (matchesKey(data, Key.left)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex - 1 + actions.length) % actions.length; } else if (this.state.focus === "details") this.state.focus = "list"; this.request(); return; }
|
|
333
|
+
if (matchesKey(data, Key.right)) { if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex + 1) % actions.length; } else if (this.state.focus === "search" || this.state.focus === "list") this.state.focus = "details"; else if (this.state.focus === "details") this.state.focus = "actions"; this.request(); return; }
|
|
334
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
335
|
+
const delta = matchesKey(data, Key.up) ? -1 : 1;
|
|
336
|
+
if (this.state.focus === "actions") { this.request(); return; } else if ((this.state.focus === "search" || this.state.focus === "list" || this.state.focus === "details") && this.filtered.length) {
|
|
337
|
+
this.index = Math.max(0, Math.min(this.filtered.length - 1, this.index + delta));
|
|
338
|
+
this.state.detailScroll = 0;
|
|
339
|
+
this.syncCurrent();
|
|
340
|
+
}
|
|
341
|
+
this.request();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
|
|
345
|
+
this.state.detailScroll = Math.min(this.detailMaxScroll, Math.max(0, this.state.detailScroll + (matchesKey(data, Key.pageUp) ? -5 : 5)));
|
|
346
|
+
this.request();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (matchesKey(data, Key.enter)) {
|
|
350
|
+
if (this.state.focus === "actions") this.activate(this.actions()[this.state.actionIndex]);
|
|
351
|
+
else this.toggleCurrent();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (matchesKey(data, Key.space) && this.state.focus === "actions") {
|
|
355
|
+
this.activate(this.actions()[this.state.actionIndex]);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (this.state.focus !== "search") return;
|
|
359
|
+
this.search.handleInput(data);
|
|
360
|
+
this.refresh();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
render(width: number): string[] {
|
|
364
|
+
const safeWidth = Math.max(4, width);
|
|
365
|
+
if (safeWidth < 24) return renderPanelBox([truncateToWidth(this.theme.fg("warning", t(this.language, "selectorNarrow")), Math.max(1, safeWidth - 2), "")], safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
|
|
366
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
367
|
+
const wide = innerWidth >= 68;
|
|
368
|
+
const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
369
|
+
const actions = this.actions();
|
|
370
|
+
this.state.actionIndex = Math.min(this.state.actionIndex, actions.length - 1);
|
|
371
|
+
const actionFooter = renderActionFooter(actions, this.state.actionIndex, this.state.focus === "actions", innerWidth, this.theme);
|
|
372
|
+
const searchLines = this.search.render(innerWidth);
|
|
373
|
+
const statusLines = this.status ? [this.theme.fg("warning", this.status)] : [];
|
|
374
|
+
const externalCount = this.rows.filter((row) => row.readOnly).length;
|
|
375
|
+
const countLine = this.theme.fg("muted", t(this.language, "selectorFooter", { filtered: this.filtered.length, total: this.rows.length, selected: this.selected.size, readonly: externalCount }));
|
|
376
|
+
const bodyBudget = Math.max(0, contentBudget - searchLines.length - statusLines.length - actionFooter.length - 1);
|
|
377
|
+
const leftWidth = wide ? Math.max(28, Math.floor((innerWidth - 2) * 0.5)) : innerWidth;
|
|
378
|
+
const rightWidth = wide ? innerWidth - leftWidth - 2 : innerWidth;
|
|
379
|
+
const left: string[] = [];
|
|
380
|
+
if (!this.filtered.length) left.push(this.theme.fg("muted", t(this.language, "selectorNoMatches")));
|
|
381
|
+
const visible = Math.max(0, bodyBudget);
|
|
382
|
+
const start = Math.max(0, Math.min(this.index - Math.floor(visible / 2), Math.max(0, this.filtered.length - visible)));
|
|
383
|
+
for (let i = start; i < Math.min(start + visible, this.filtered.length); i++) {
|
|
384
|
+
const row = this.filtered[i]!;
|
|
385
|
+
const checked = this.selected.has(this.key(row));
|
|
386
|
+
const currentRow = i === this.index;
|
|
387
|
+
const indicator = currentRow ? "→ " : " ";
|
|
388
|
+
const check = checked ? this.theme.fg("success", "✓") : " ";
|
|
389
|
+
const source = row.readOnly ? t(this.language, "selectorReadonlyTag") : "";
|
|
390
|
+
const prefix = `${indicator}[${check}] [${modelTypeLabel(this.language, row.type)}] `;
|
|
391
|
+
const suffix = ` · ${row.name}${source ? ` · ${source}` : ""}`;
|
|
392
|
+
const available = Math.max(1, leftWidth - visibleWidth(prefix) - visibleWidth(suffix));
|
|
393
|
+
const content = `${prefix}${truncateToWidth(row.id, available, "…")}${suffix}`;
|
|
394
|
+
left.push(truncateToWidth(currentRow ? this.theme.fg("accent", this.theme.bold(content)) : content, leftWidth, ""));
|
|
395
|
+
}
|
|
396
|
+
const current = this.current();
|
|
397
|
+
const detailText = current ? [
|
|
398
|
+
t(this.language, "panelModelName", { value: current.name }),
|
|
399
|
+
t(this.language, "panelModelId", { value: current.id }),
|
|
400
|
+
t(this.language, "panelModelType", { value: modelTypeLabel(this.language, current.type) }),
|
|
401
|
+
t(this.language, "panelModelApi", { value: current.api }),
|
|
402
|
+
...(current.type !== "image" ? [current.contextSource === "compat" ? t(this.language, "compatContextDisplay") : t(this.language, "panelModelContext", { value: current.contextWindow })] : []),
|
|
403
|
+
...(current.type === "chat" ? [current.maxTokensSource === "compat" ? t(this.language, "compatMaxDisplay") : t(this.language, "panelModelMaxTokens", { value: current.maxTokens }), describeThinking(this.language, current, { thinkingSource: current.thinkingSource })] : []),
|
|
404
|
+
t(this.language, "panelModelInput", { value: current.input.join(", ") || t(this.language, "valueUnset") }),
|
|
405
|
+
...(current.type === "image" ? [t(this.language, "panelModelOutput", { value: current.output.join(", ") || t(this.language, "valueUnset") })] : []),
|
|
406
|
+
t(this.language, "panelModelCost", { input: current.cost.input, output: current.cost.output, cacheRead: current.cost.cacheRead, cacheWrite: current.cost.cacheWrite }),
|
|
407
|
+
t(this.language, "panelModelBaseUrl", { value: current.baseUrl ?? t(this.language, "valueDefault") }),
|
|
408
|
+
...(current.readOnly ? [t(this.language, current.readOnlyReason === "external-config" ? "panelModelReadonlyExternal" : current.readOnlyReason === "runtime-catalog" ? "panelModelReadonlyRuntime" : "panelModelReadonly")] : []),
|
|
409
|
+
] : [];
|
|
410
|
+
const rightAll = detailText.flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
|
|
411
|
+
this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
|
|
412
|
+
this.state.detailScroll = Math.min(this.state.detailScroll, this.detailMaxScroll);
|
|
413
|
+
const right = rightAll.slice(this.state.detailScroll, this.state.detailScroll + bodyBudget);
|
|
414
|
+
const body = wide ? joinColumns(left.slice(0, bodyBudget), right, leftWidth, rightWidth) : this.state.focus === "details" ? right : left.slice(0, bodyBudget);
|
|
415
|
+
const content = [...searchLines, ...statusLines, ...body.slice(0, bodyBudget), countLine, ...actionFooter].slice(0, contentBudget);
|
|
416
|
+
return renderPanelBox(content, safeWidth, t(this.language, "selectorTitle", { provider: this.provider }), this.theme);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
invalidate(): void { this.search.invalidate(); }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function selectModels(
|
|
423
|
+
ctx: ExtensionCommandContext,
|
|
424
|
+
language: UiLanguage,
|
|
425
|
+
provider: string,
|
|
426
|
+
draft: ProviderDraft,
|
|
427
|
+
rows: CandidateModel[],
|
|
428
|
+
initialSelected?: ManagedModel[],
|
|
429
|
+
state: ModelPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 },
|
|
430
|
+
initialMetadata?: ModelMetadataEntry[],
|
|
431
|
+
): Promise<SelectionResult> {
|
|
432
|
+
return ctx.ui.custom<SelectionResult>((tui, theme, _keybindings, done) => new ModelSelector(tui, theme, language, provider, draft, rows, initialSelected, state, done, initialMetadata), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
433
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const PROTOCOL_THINKING_MAPS: Readonly<Record<string, Readonly<ThinkingLevelMap>>> = {
|
|
4
|
+
"anthropic-messages": { off: null, minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
|
5
|
+
// pi-ai accepts these standard OpenAI-compatible reasoning_effort values. xhigh/max
|
|
6
|
+
// remain model-specific opt-ins, so a generic protocol default must not claim support.
|
|
7
|
+
"openai-completions": { off: null, minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: null, max: null },
|
|
8
|
+
"openai-responses": { off: null, minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
|
9
|
+
"openai-codex-responses": { off: "none", minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
|
10
|
+
"azure-openai-responses": { off: null, minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
|
11
|
+
"pi-messages": { off: null, minimal: "minimal", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "max" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function protocolThinkingMap(api: string): ThinkingLevelMap | undefined {
|
|
15
|
+
const map = PROTOCOL_THINKING_MAPS[api];
|
|
16
|
+
return map ? { ...map } : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defaultChatThinking(
|
|
20
|
+
api: string,
|
|
21
|
+
observed: { reasoning?: boolean; thinkingLevelMap?: ThinkingLevelMap },
|
|
22
|
+
observedSource: Extract<ThinkingMetadataSource, "existing" | "remote" | "catalog">,
|
|
23
|
+
): { reasoning: boolean; thinkingLevelMap?: ThinkingLevelMap; thinkingSource?: ThinkingMetadataSource } {
|
|
24
|
+
const observedMap = observed.thinkingLevelMap;
|
|
25
|
+
// An empty local/catalog map conveys no per-level information. Remote discovery,
|
|
26
|
+
// however, can explicitly declare an empty map, which must remain authoritative.
|
|
27
|
+
if (observedMap !== undefined && (observedSource === "remote" || Object.keys(observedMap).length > 0)) {
|
|
28
|
+
return {
|
|
29
|
+
reasoning: observed.reasoning ?? true,
|
|
30
|
+
thinkingLevelMap: { ...observedMap },
|
|
31
|
+
thinkingSource: observedSource,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const protocol = protocolThinkingMap(api);
|
|
35
|
+
return {
|
|
36
|
+
reasoning: observed.reasoning ?? true,
|
|
37
|
+
...(protocol ? { thinkingLevelMap: protocol, thinkingSource: "protocol" as const } : {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AnyModel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { sanitizeError } from "./config.ts";
|
|
5
|
+
import { MODEL_CONFIG_OVERLAY_OPTIONS, renderPanelBox, showConfirmationPanel } from "./panel.ts";
|
|
6
|
+
import { THINKING_LEVELS, type ThinkingLevelName } from "./model-editor.ts";
|
|
7
|
+
import type { ThinkingLevelMap, ThinkingMetadataSource } from "./types.ts";
|
|
8
|
+
import { protocolThinkingMap } from "./thinking-defaults.ts";
|
|
9
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
10
|
+
|
|
11
|
+
export const THINKING_PROBE_RUN_SHORTCUT = "ctrl+l";
|
|
12
|
+
|
|
13
|
+
export { protocolThinkingMap } from "./thinking-defaults.ts";
|
|
14
|
+
|
|
15
|
+
export type ThinkingProbeStatus = "accepted" | "rejected" | "auth" | "rate-limit" | "timeout" | "unsupported" | "aborted" | "error";
|
|
16
|
+
export interface ThinkingProbeStep { level: ThinkingLevelName; status: ThinkingProbeStatus; latencyMs: number; error?: string }
|
|
17
|
+
export interface ThinkingProbeResult { steps: ThinkingProbeStep[]; accepted: ThinkingLevelName[] }
|
|
18
|
+
|
|
19
|
+
export function resolveThinkingMap(explicitRemote: ThinkingLevelMap | undefined, runtime: ThinkingLevelMap | undefined, existing: ThinkingLevelMap | undefined, api: string): { map?: ThinkingLevelMap; source?: ThinkingMetadataSource } {
|
|
20
|
+
// A discovered remote map may explicitly be empty. In contrast, an empty runtime
|
|
21
|
+
// catalog map supplies no resolved wire values and should fall back to the protocol.
|
|
22
|
+
if (explicitRemote !== undefined) return { map: explicitRemote, source: "remote" };
|
|
23
|
+
if (runtime !== undefined && Object.keys(runtime).length > 0) return { map: runtime, source: "catalog" };
|
|
24
|
+
// The draft can contain a deliberate user-edited empty map, so preserve it.
|
|
25
|
+
if (existing !== undefined) return { map: existing, source: "existing" };
|
|
26
|
+
const protocol = protocolThinkingMap(api);
|
|
27
|
+
return protocol ? { map: protocol, source: "protocol" } : {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function acceptedThinkingMap(map: ThinkingLevelMap, accepted: readonly ThinkingLevelName[]): ThinkingLevelMap {
|
|
31
|
+
const acceptedLevels = new Set(accepted);
|
|
32
|
+
return Object.fromEntries(THINKING_LEVELS.flatMap((level) => {
|
|
33
|
+
const value = map[level];
|
|
34
|
+
return typeof value === "string" && acceptedLevels.has(level) ? [[level, value]] : [];
|
|
35
|
+
})) as ThinkingLevelMap;
|
|
36
|
+
}
|
|
37
|
+
function classify(error: unknown, signal: AbortSignal): ThinkingProbeStatus {
|
|
38
|
+
if (signal.aborted) return signal.reason?.name === "TimeoutError" ? "timeout" : "aborted";
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
if (/\b(?:401|403)\b|auth|credential|api.?key/iu.test(message)) return "auth";
|
|
41
|
+
if (/\b429\b|rate.?limit/iu.test(message)) return "rate-limit";
|
|
42
|
+
if (/unsupported|invalid.*reason|thinking.*not|effort.*not/iu.test(message)) return "rejected";
|
|
43
|
+
return "error";
|
|
44
|
+
}
|
|
45
|
+
export async function runThinkingProbe(model: AnyModel, registry: any, map: ThinkingLevelMap, signal: AbortSignal, activeSecrets: Array<string | undefined> = [], timeoutMs = 30_000): Promise<ThinkingProbeResult> {
|
|
46
|
+
const result: ThinkingProbeResult = { steps: [], accepted: [] };
|
|
47
|
+
// streamSimple normally resolves its reasoning level through the runtime catalog.
|
|
48
|
+
// Probe against the selected resolved map instead, never against stale model metadata.
|
|
49
|
+
const probeModel = { ...model, thinkingLevelMap: { ...map } } as AnyModel;
|
|
50
|
+
for (const level of THINKING_LEVELS) {
|
|
51
|
+
if (signal.aborted) { result.steps.push({ level, status: "aborted", latencyMs: 0 }); break; }
|
|
52
|
+
const mapped = map[level];
|
|
53
|
+
if (typeof mapped !== "string") {
|
|
54
|
+
result.steps.push({ level, status: "unsupported", latencyMs: 0 });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const started = performance.now(); const stepSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
|
58
|
+
try {
|
|
59
|
+
if (model.type !== "chat") { result.steps.push({ level, status: "unsupported", latencyMs: 0 }); break; }
|
|
60
|
+
const stream = registry.streamSimple(probeModel, { messages: [{ role: "user", content: [{ type: "text", text: "ok" }], timestamp: Date.now() }] }, { signal: stepSignal, maxTokens: 1, reasoning: level === "off" ? undefined : level });
|
|
61
|
+
let done = false;
|
|
62
|
+
for await (const event of stream) { if (event.type === "error") throw new Error(String(event.error?.errorMessage ?? event.errorMessage ?? event.reason ?? "stream error")); if (event.type === "done") done = true; }
|
|
63
|
+
if (!done) throw new Error("stream ended without done");
|
|
64
|
+
result.accepted.push(level); result.steps.push({ level, status: "accepted", latencyMs: Math.round(performance.now() - started) });
|
|
65
|
+
} catch (error) { const status = classify(error, stepSignal); result.steps.push({ level, status, latencyMs: Math.round(performance.now() - started), error: sanitizeError(error, activeSecrets) }); if (status !== "rejected") break; }
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
class ThinkingRunPanel implements Component {
|
|
70
|
+
private controller = new AbortController();
|
|
71
|
+
constructor(private theme: Theme, private language: UiLanguage, model: AnyModel, registry: any, map: ThinkingLevelMap, secrets: Array<string | undefined>, done: (result: ThinkingProbeResult) => void) { void runThinkingProbe(model, registry, map, this.controller.signal, secrets).then(done); }
|
|
72
|
+
handleInput(data: string): void { if (matchesKey(data, Key.escape)) this.controller.abort(new DOMException("Cancelled", "AbortError")); }
|
|
73
|
+
render(width: number): string[] { return renderPanelBox([t(this.language, "testRunning")], width, t(this.language, "thinkingProbeTitle"), this.theme); }
|
|
74
|
+
invalidate() {}
|
|
75
|
+
dispose() { this.controller.abort(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function showThinkingProbeFlow(ctx: ExtensionCommandContext, language: UiLanguage, model: AnyModel, map: ThinkingLevelMap, activeSecrets: Array<string | undefined> = []): Promise<ThinkingProbeResult | undefined> {
|
|
79
|
+
const confirm = await showConfirmationPanel(ctx, { title: t(language, "thinkingProbeTitle"), summary: [t(language, "thinkingProbeWarning")], defaultActionIndex: 0, cancelActionId: "cancel", actions: [{ id: "cancel", label: t(language, "confirmCancel") }, { id: "run", label: t(language, "probeRun"), shortcut: THINKING_PROBE_RUN_SHORTCUT }] });
|
|
80
|
+
if (confirm !== "run") return undefined;
|
|
81
|
+
const result = await ctx.ui.custom<ThinkingProbeResult>((_tui, theme, _keys, done) => new ThinkingRunPanel(theme, language, model, ctx.modelRegistry, map, activeSecrets, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
82
|
+
const choice = await showConfirmationPanel(ctx, { title: t(language, "thinkingProbeTitle"), summary: [`accepted: ${result.accepted.join(", ") || "none"}`], details: result.steps.map((step) => `${step.level}: ${step.status} · ${step.latencyMs}ms`), defaultActionIndex: 0, cancelActionId: "back", actions: [{ id: "keep", label: t(language, "probeKeep") }, ...(result.accepted.length ? [{ id: "apply", label: t(language, "probeUse"), shortcut: "ctrl+s" }] : []), { id: "back", label: t(language, "probeBack") }] });
|
|
83
|
+
return choice === "apply" ? result : undefined;
|
|
84
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
|
2
|
+
import type { FetchFunction, ProviderEnv, ProviderHeaders, Transport } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ProviderDraft } from "./types.ts";
|
|
4
|
+
import { validateHttpUrl } from "./config.ts";
|
|
5
|
+
import { mcError } from "./i18n.ts";
|
|
6
|
+
|
|
7
|
+
export const ANONYMOUS_SENTINEL = "pi-model-config-anonymous";
|
|
8
|
+
|
|
9
|
+
export const PROXY_CHAT_APIS = new Set([
|
|
10
|
+
"anthropic-messages",
|
|
11
|
+
"openai-completions",
|
|
12
|
+
"openai-responses",
|
|
13
|
+
"azure-openai-responses",
|
|
14
|
+
"openai-codex-responses",
|
|
15
|
+
"mistral-conversations",
|
|
16
|
+
"pi-messages",
|
|
17
|
+
"bedrock-converse-stream",
|
|
18
|
+
]);
|
|
19
|
+
export const CHAT_APIS = new Set([
|
|
20
|
+
...PROXY_CHAT_APIS,
|
|
21
|
+
"google-generative-ai",
|
|
22
|
+
"google-vertex",
|
|
23
|
+
]);
|
|
24
|
+
export const CLASSIFIER_APIS = new Set([
|
|
25
|
+
"typesafe-system-one",
|
|
26
|
+
"cloudflare-workers-ai-system-one",
|
|
27
|
+
"openai-decisions",
|
|
28
|
+
]);
|
|
29
|
+
export const IMAGE_APIS = new Set(["openrouter-images"]);
|
|
30
|
+
|
|
31
|
+
export function assertSupportedProtocol(draft: ProviderDraft, type: "chat" | "classifier" | "image"): void {
|
|
32
|
+
const api = type === "chat" ? draft.chatApi : type === "classifier" ? draft.classifierApi : draft.imageApi;
|
|
33
|
+
const supported = type === "chat" ? CHAT_APIS : type === "classifier" ? CLASSIFIER_APIS : IMAGE_APIS;
|
|
34
|
+
if (!supported.has(api)) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${draft.id}/${type}`, api });
|
|
35
|
+
if (draft.proxyUrl && type === "chat" && !PROXY_CHAT_APIS.has(api)) {
|
|
36
|
+
throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: `${draft.id}/${type}/${api}` });
|
|
37
|
+
}
|
|
38
|
+
if (draft.authMode === "anonymous" && (api === "pi-messages" || api === "bedrock-converse-stream" || api === "google-generative-ai" || api === "google-vertex")) {
|
|
39
|
+
throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${draft.id}/${type}/${api}` });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A provider-local fetch. It never mutates process.env or the global dispatcher. */
|
|
44
|
+
const proxyFetchCache = new Map<string, FetchFunction>();
|
|
45
|
+
|
|
46
|
+
export function createProviderFetch(proxyUrl: string): FetchFunction {
|
|
47
|
+
const normalized = validateHttpUrl(proxyUrl, "proxyUrl");
|
|
48
|
+
const cached = proxyFetchCache.get(normalized);
|
|
49
|
+
if (cached) return cached;
|
|
50
|
+
const dispatcher = new ProxyAgent(normalized);
|
|
51
|
+
const providerFetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
52
|
+
// Do not auto-follow: adapter credentials/custom headers must never cross an origin boundary.
|
|
53
|
+
return undiciFetch(input as Parameters<typeof undiciFetch>[0], {
|
|
54
|
+
...(init as Parameters<typeof undiciFetch>[1]),
|
|
55
|
+
redirect: "manual",
|
|
56
|
+
dispatcher,
|
|
57
|
+
}) as unknown as Response;
|
|
58
|
+
}) as FetchFunction;
|
|
59
|
+
proxyFetchCache.set(normalized, providerFetch);
|
|
60
|
+
return providerFetch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TransportOptions {
|
|
64
|
+
fetch?: FetchFunction;
|
|
65
|
+
apiKey?: string;
|
|
66
|
+
headers?: ProviderHeaders;
|
|
67
|
+
env?: ProviderEnv;
|
|
68
|
+
transport?: Transport;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyProviderTransport<T extends TransportOptions>(
|
|
72
|
+
options: T | undefined,
|
|
73
|
+
config: { proxyUrl?: string; anonymous: boolean; api: string },
|
|
74
|
+
): T {
|
|
75
|
+
const next = { ...(options ?? {}) } as T;
|
|
76
|
+
if (config.proxyUrl) {
|
|
77
|
+
if (config.api === "bedrock-converse-stream") {
|
|
78
|
+
next.env = { ...next.env, HTTP_PROXY: config.proxyUrl, HTTPS_PROXY: config.proxyUrl };
|
|
79
|
+
} else {
|
|
80
|
+
next.fetch = createProviderFetch(config.proxyUrl);
|
|
81
|
+
}
|
|
82
|
+
if (config.api === "openai-codex-responses") next.transport = "sse";
|
|
83
|
+
}
|
|
84
|
+
if (config.anonymous) {
|
|
85
|
+
next.apiKey = ANONYMOUS_SENTINEL;
|
|
86
|
+
next.headers = {
|
|
87
|
+
...next.headers,
|
|
88
|
+
authorization: null,
|
|
89
|
+
"x-api-key": null,
|
|
90
|
+
"cf-aig-authorization": null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return next;
|
|
94
|
+
}
|