@bytetrue/pi-vendor 0.1.0
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/README.md +11 -0
- package/package.json +44 -0
- package/src/command.ts +595 -0
- package/src/custom-select.ts +231 -0
- package/src/enrich.ts +64 -0
- package/src/fuzzy.ts +139 -0
- package/src/index.ts +45 -0
- package/src/models-json.ts +123 -0
- package/src/official-catalog.ts +194 -0
- package/src/openai-models.ts +91 -0
- package/src/templates.ts +120 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type CustomInputOptions = {
|
|
4
|
+
title: string;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
defaultValue?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CustomInputResult = string | null;
|
|
10
|
+
|
|
11
|
+
const MIN_WIDTH = 42;
|
|
12
|
+
|
|
13
|
+
type Frame = {
|
|
14
|
+
top: () => string;
|
|
15
|
+
row: (content?: string) => string;
|
|
16
|
+
empty: () => string;
|
|
17
|
+
divider: () => string;
|
|
18
|
+
bottom: () => string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function padAnsi(text: string, width: number): string {
|
|
22
|
+
const truncated = truncateToWidth(text, width, "…");
|
|
23
|
+
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createFrame(width: number): Frame {
|
|
27
|
+
const innerWidth = Math.max(MIN_WIDTH, width - 2);
|
|
28
|
+
const rowWidth = innerWidth - 2;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
top: () => `┌${"─".repeat(innerWidth)}┐`,
|
|
32
|
+
row: (content = "") => `│ ${padAnsi(content, rowWidth)} │`,
|
|
33
|
+
empty: () => `│${" ".repeat(innerWidth)}│`,
|
|
34
|
+
divider: () => `├${"─".repeat(innerWidth)}┤`,
|
|
35
|
+
bottom: () => `└${"─".repeat(innerWidth)}┘`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Custom input component with border.
|
|
41
|
+
*/
|
|
42
|
+
export function createCustomInput(
|
|
43
|
+
options: CustomInputOptions,
|
|
44
|
+
): (tui: any, theme: any, keybindings: any, done: (result: CustomInputResult) => void) => any {
|
|
45
|
+
return (tui, theme, keybindings, done) => {
|
|
46
|
+
const input = new Input();
|
|
47
|
+
if (options.defaultValue) {
|
|
48
|
+
input.setValue(options.defaultValue);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
input.onSubmit = (value) => {
|
|
52
|
+
done(value.trim());
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
input.onEscape = () => {
|
|
56
|
+
done(null);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const component = {
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
const frame = createFrame(width);
|
|
62
|
+
const inputLines = input.render(Math.max(8, width - 8));
|
|
63
|
+
const inputText = inputLines[0] || "";
|
|
64
|
+
const displayText = inputText.trim() ? inputText : "";
|
|
65
|
+
|
|
66
|
+
return [
|
|
67
|
+
frame.top(),
|
|
68
|
+
frame.row(theme.fg("accent", theme.bold(options.title))),
|
|
69
|
+
frame.row(theme.fg("muted", options.placeholder || "Type a value, then press Enter.")),
|
|
70
|
+
frame.divider(),
|
|
71
|
+
frame.empty(),
|
|
72
|
+
frame.row(`› ${displayText}`),
|
|
73
|
+
frame.empty(),
|
|
74
|
+
frame.divider(),
|
|
75
|
+
frame.row(theme.fg("muted", "Enter submits, Esc goes back.")),
|
|
76
|
+
frame.bottom(),
|
|
77
|
+
];
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
handleInput(keyData: string): void {
|
|
81
|
+
input.handleInput(keyData);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
focus(): void {
|
|
85
|
+
input.focused = true;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
blur(): void {
|
|
89
|
+
input.focused = false;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return component;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type CustomSelectOptions = {
|
|
98
|
+
title: string;
|
|
99
|
+
items: string[];
|
|
100
|
+
defaultValue?: string;
|
|
101
|
+
maxVisible?: number;
|
|
102
|
+
escapeLabel?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type CustomSelectResult<T extends string> = {
|
|
106
|
+
type: "select";
|
|
107
|
+
value: T;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Custom select component with wrap-around navigation and pagination.
|
|
112
|
+
* - Up/down arrows: navigate within current page (with wrap-around)
|
|
113
|
+
* - Left/right arrows: change page
|
|
114
|
+
* - Enter: select current item
|
|
115
|
+
* - Escape: go back
|
|
116
|
+
*/
|
|
117
|
+
export function createCustomSelect<T extends string>(
|
|
118
|
+
options: CustomSelectOptions,
|
|
119
|
+
): (tui: any, theme: any, keybindings: any, done: (result: CustomSelectResult<T> | null) => void) => any {
|
|
120
|
+
return (tui, theme, keybindings, done) => {
|
|
121
|
+
const items = options.items;
|
|
122
|
+
const totalItems = items.length;
|
|
123
|
+
const pageSize = options.maxVisible ?? 10;
|
|
124
|
+
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
|
125
|
+
|
|
126
|
+
let currentPage = 0;
|
|
127
|
+
let selectedIndex = 0;
|
|
128
|
+
|
|
129
|
+
// Set default value if provided
|
|
130
|
+
if (options.defaultValue) {
|
|
131
|
+
const defaultIndex = items.indexOf(options.defaultValue);
|
|
132
|
+
if (defaultIndex >= 0) {
|
|
133
|
+
currentPage = Math.floor(defaultIndex / pageSize);
|
|
134
|
+
selectedIndex = defaultIndex % pageSize;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const component = {
|
|
139
|
+
render(width: number): string[] {
|
|
140
|
+
const frame = createFrame(width);
|
|
141
|
+
const startIndex = currentPage * pageSize;
|
|
142
|
+
const endIndex = Math.min(startIndex + pageSize, totalItems);
|
|
143
|
+
const pageItems = items.slice(startIndex, endIndex);
|
|
144
|
+
const pageInfo = totalPages > 1
|
|
145
|
+
? `${totalItems} options · Page ${currentPage + 1}/${totalPages}`
|
|
146
|
+
: `${totalItems} option${totalItems === 1 ? "" : "s"}`;
|
|
147
|
+
const escapeText = options.escapeLabel ?? "goes back";
|
|
148
|
+
const helpText = totalPages > 1
|
|
149
|
+
? `↑↓ navigate, ←→ page, Enter selects, Esc ${escapeText}.`
|
|
150
|
+
: `↑↓ navigate, Enter selects, Esc ${escapeText}.`;
|
|
151
|
+
const lines = [
|
|
152
|
+
frame.top(),
|
|
153
|
+
frame.row(theme.fg("accent", theme.bold(options.title))),
|
|
154
|
+
frame.row(theme.fg("muted", pageInfo)),
|
|
155
|
+
frame.divider(),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
for (let i = 0; i < pageItems.length; i++) {
|
|
159
|
+
const isSelected = i === selectedIndex;
|
|
160
|
+
const itemText = pageItems[i] ?? "";
|
|
161
|
+
const rendered = isSelected
|
|
162
|
+
? theme.fg("accent", "› ") + theme.fg("accent", theme.bold(itemText))
|
|
163
|
+
: ` ${theme.fg("text", itemText)}`;
|
|
164
|
+
lines.push(frame.row(rendered));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (let i = pageItems.length; i < pageSize; i++) {
|
|
168
|
+
lines.push(frame.empty());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push(frame.divider());
|
|
172
|
+
lines.push(frame.row(theme.fg("muted", helpText)));
|
|
173
|
+
lines.push(frame.bottom());
|
|
174
|
+
return lines;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
handleInput(keyData: string): void {
|
|
178
|
+
const kb = keybindings;
|
|
179
|
+
const pageStart = currentPage * pageSize;
|
|
180
|
+
const pageEnd = Math.min(pageStart + pageSize, totalItems);
|
|
181
|
+
const pageLength = pageEnd - pageStart;
|
|
182
|
+
|
|
183
|
+
// Up arrow - wrap within page
|
|
184
|
+
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
|
|
185
|
+
selectedIndex = selectedIndex === 0 ? pageLength - 1 : selectedIndex - 1;
|
|
186
|
+
}
|
|
187
|
+
// Down arrow - wrap within page
|
|
188
|
+
else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
|
189
|
+
selectedIndex = selectedIndex === pageLength - 1 ? 0 : selectedIndex + 1;
|
|
190
|
+
}
|
|
191
|
+
// Left arrow - previous page
|
|
192
|
+
else if (kb.matches(keyData, "tui.editor.cursorLeft") || keyData === "h") {
|
|
193
|
+
if (currentPage > 0) {
|
|
194
|
+
currentPage--;
|
|
195
|
+
selectedIndex = 0;
|
|
196
|
+
} else {
|
|
197
|
+
// Wrap to last page
|
|
198
|
+
currentPage = totalPages - 1;
|
|
199
|
+
const lastPageLength = totalItems - (currentPage * pageSize);
|
|
200
|
+
selectedIndex = lastPageLength - 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Right arrow - next page
|
|
204
|
+
else if (kb.matches(keyData, "tui.editor.cursorRight") || keyData === "l") {
|
|
205
|
+
if (currentPage < totalPages - 1) {
|
|
206
|
+
currentPage++;
|
|
207
|
+
selectedIndex = 0;
|
|
208
|
+
} else {
|
|
209
|
+
// Wrap to first page
|
|
210
|
+
currentPage = 0;
|
|
211
|
+
selectedIndex = 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Enter - select
|
|
215
|
+
else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
|
|
216
|
+
const globalIndex = pageStart + selectedIndex;
|
|
217
|
+
const selected = items[globalIndex];
|
|
218
|
+
if (selected) {
|
|
219
|
+
done({ type: "select", value: selected as T });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Escape - go back
|
|
223
|
+
else if (kb.matches(keyData, "tui.select.cancel")) {
|
|
224
|
+
done(null);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return component;
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/enrich.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ProviderModelConfig } from "./models-json.js";
|
|
2
|
+
import {
|
|
3
|
+
collectOfficialCandidates,
|
|
4
|
+
type OfficialModelCandidate,
|
|
5
|
+
type OfficialModelsCatalog,
|
|
6
|
+
loadOfficialCatalog,
|
|
7
|
+
stripOfficialRoutingFields,
|
|
8
|
+
} from "./official-catalog.js";
|
|
9
|
+
import {
|
|
10
|
+
createDefaultModelConfig,
|
|
11
|
+
createTemplateModelConfig,
|
|
12
|
+
matchTemplate,
|
|
13
|
+
type ModelTemplate,
|
|
14
|
+
} from "./templates.js";
|
|
15
|
+
|
|
16
|
+
export type ModelEnrichmentReady = {
|
|
17
|
+
kind: "ready";
|
|
18
|
+
source: "official" | "template" | "default";
|
|
19
|
+
model: ProviderModelConfig;
|
|
20
|
+
warning?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ModelEnrichmentAmbiguous = {
|
|
24
|
+
kind: "official-ambiguous";
|
|
25
|
+
modelId: string;
|
|
26
|
+
candidates: OfficialModelCandidate[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ModelEnrichmentResult = ModelEnrichmentReady | ModelEnrichmentAmbiguous;
|
|
30
|
+
|
|
31
|
+
export type EnrichOptions = {
|
|
32
|
+
catalog?: OfficialModelsCatalog | null;
|
|
33
|
+
templates?: readonly ModelTemplate[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function enrichModelId(modelId: string, options: EnrichOptions = {}): Promise<ModelEnrichmentResult> {
|
|
37
|
+
const catalog = options.catalog ?? (await loadOfficialCatalog());
|
|
38
|
+
const officialCandidates = collectOfficialCandidates(catalog, modelId);
|
|
39
|
+
|
|
40
|
+
// Always require user confirmation when we have official candidates (even just 1)
|
|
41
|
+
if (officialCandidates.length >= 1) {
|
|
42
|
+
return {
|
|
43
|
+
kind: "official-ambiguous",
|
|
44
|
+
modelId,
|
|
45
|
+
candidates: officialCandidates,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const template = matchTemplate(modelId, options.templates);
|
|
50
|
+
if (template) {
|
|
51
|
+
return {
|
|
52
|
+
kind: "ready",
|
|
53
|
+
source: "template",
|
|
54
|
+
model: createTemplateModelConfig(modelId, template),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
kind: "ready",
|
|
60
|
+
source: "default",
|
|
61
|
+
model: createDefaultModelConfig(modelId),
|
|
62
|
+
warning: `No official catalog or template match for ${modelId}; using safe defaults.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/fuzzy.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy matching utilities for model ID search.
|
|
3
|
+
* Matches if all query characters appear in order (not necessarily consecutive).
|
|
4
|
+
* Lower score = better match.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type FuzzyMatch = {
|
|
8
|
+
matches: boolean;
|
|
9
|
+
score: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
13
|
+
const queryLower = query.toLowerCase();
|
|
14
|
+
const textLower = text.toLowerCase();
|
|
15
|
+
|
|
16
|
+
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
|
|
17
|
+
if (normalizedQuery.length === 0) {
|
|
18
|
+
return { matches: true, score: 0 };
|
|
19
|
+
}
|
|
20
|
+
if (normalizedQuery.length > textLower.length) {
|
|
21
|
+
return { matches: false, score: 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let queryIndex = 0;
|
|
25
|
+
let score = 0;
|
|
26
|
+
let lastMatchIndex = -1;
|
|
27
|
+
let consecutiveMatches = 0;
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) {
|
|
30
|
+
if (textLower[i] === normalizedQuery[queryIndex]) {
|
|
31
|
+
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
|
32
|
+
|
|
33
|
+
// Reward consecutive matches
|
|
34
|
+
if (lastMatchIndex === i - 1) {
|
|
35
|
+
consecutiveMatches++;
|
|
36
|
+
score -= consecutiveMatches * 5;
|
|
37
|
+
} else {
|
|
38
|
+
consecutiveMatches = 0;
|
|
39
|
+
// Penalize gaps
|
|
40
|
+
if (lastMatchIndex >= 0) {
|
|
41
|
+
score += (i - lastMatchIndex - 1) * 2;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reward word boundary matches
|
|
46
|
+
if (isWordBoundary) {
|
|
47
|
+
score -= 10;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Slight penalty for later matches
|
|
51
|
+
score += i * 0.1;
|
|
52
|
+
lastMatchIndex = i;
|
|
53
|
+
queryIndex++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (queryIndex < normalizedQuery.length) {
|
|
58
|
+
return { matches: false, score: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Bonus for exact match
|
|
62
|
+
if (normalizedQuery === textLower) {
|
|
63
|
+
score -= 100;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { matches: true, score };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const primaryMatch = matchQuery(queryLower);
|
|
70
|
+
if (primaryMatch.matches) {
|
|
71
|
+
return primaryMatch;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle swapped alphanumeric patterns (e.g., "gpt4" vs "4gpt")
|
|
75
|
+
const alphaNumericMatch = queryLower.match(/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/);
|
|
76
|
+
const numericAlphaMatch = queryLower.match(/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/);
|
|
77
|
+
|
|
78
|
+
const swappedQuery = alphaNumericMatch
|
|
79
|
+
? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
|
|
80
|
+
: numericAlphaMatch
|
|
81
|
+
? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
|
|
82
|
+
: "";
|
|
83
|
+
|
|
84
|
+
if (!swappedQuery) {
|
|
85
|
+
return primaryMatch;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const swappedMatch = matchQuery(swappedQuery);
|
|
89
|
+
if (!swappedMatch.matches) {
|
|
90
|
+
return primaryMatch;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { matches: true, score: swappedMatch.score + 5 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type FuzzyResult<T> = {
|
|
97
|
+
item: T;
|
|
98
|
+
score: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
102
|
+
if (!query.trim()) {
|
|
103
|
+
return items;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tokens = query
|
|
107
|
+
.trim()
|
|
108
|
+
.split(/[\s/]+/)
|
|
109
|
+
.filter((t) => t.length > 0);
|
|
110
|
+
|
|
111
|
+
if (tokens.length === 0) {
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const results: FuzzyResult<T>[] = [];
|
|
116
|
+
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const text = getText(item);
|
|
119
|
+
let totalScore = 0;
|
|
120
|
+
let allMatch = true;
|
|
121
|
+
|
|
122
|
+
for (const token of tokens) {
|
|
123
|
+
const match = fuzzyMatch(token, text);
|
|
124
|
+
if (match.matches) {
|
|
125
|
+
totalScore += match.score;
|
|
126
|
+
} else {
|
|
127
|
+
allMatch = false;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (allMatch) {
|
|
133
|
+
results.push({ item, score: totalScore });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
results.sort((a, b) => a.score - b.score);
|
|
138
|
+
return results.map((r) => r.item);
|
|
139
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerVendorCommand } from "./command.js";
|
|
4
|
+
|
|
5
|
+
export { registerVendorCommand } from "./command.js";
|
|
6
|
+
export type { ModelEnrichmentAmbiguous, ModelEnrichmentReady, ModelEnrichmentResult } from "./enrich.js";
|
|
7
|
+
export { enrichModelId } from "./enrich.js";
|
|
8
|
+
export type { ModelsJson, ProviderConfig, ProviderDraft, ProviderModelConfig } from "./models-json.js";
|
|
9
|
+
export {
|
|
10
|
+
createMinimalProviderConfig,
|
|
11
|
+
createNewProviderDraft,
|
|
12
|
+
createProviderDraft,
|
|
13
|
+
getModelsJsonPath,
|
|
14
|
+
readModelsJson,
|
|
15
|
+
upsertProvider,
|
|
16
|
+
writeModelsJson,
|
|
17
|
+
} from "./models-json.js";
|
|
18
|
+
export type { OfficialModelCandidate, OfficialModelsCatalog, OfficialModelConfig } from "./official-catalog.js";
|
|
19
|
+
export {
|
|
20
|
+
collectOfficialCandidates,
|
|
21
|
+
findOfficialCatalogPath,
|
|
22
|
+
formatOfficialCandidate,
|
|
23
|
+
loadOfficialCatalog,
|
|
24
|
+
stripOfficialRoutingFields,
|
|
25
|
+
} from "./official-catalog.js";
|
|
26
|
+
export type { FetchLike, OpenAIModelsProviderDraft } from "./openai-models.js";
|
|
27
|
+
export {
|
|
28
|
+
buildOpenAIModelsUrl,
|
|
29
|
+
fetchOpenAIModelIds,
|
|
30
|
+
parseOpenAIModelsResponse,
|
|
31
|
+
resolveApiKeyValue,
|
|
32
|
+
} from "./openai-models.js";
|
|
33
|
+
export type { ModelTemplate } from "./templates.js";
|
|
34
|
+
export {
|
|
35
|
+
MODEL_TEMPLATES,
|
|
36
|
+
createDefaultModelConfig,
|
|
37
|
+
createTemplateModelConfig,
|
|
38
|
+
listModelTemplates,
|
|
39
|
+
matchTemplate,
|
|
40
|
+
templateLabel,
|
|
41
|
+
} from "./templates.js";
|
|
42
|
+
|
|
43
|
+
export default async function registerVendor(pi: ExtensionAPI): Promise<void> {
|
|
44
|
+
registerVendorCommand(pi);
|
|
45
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type ProviderModelConfig = {
|
|
6
|
+
id: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
api?: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
reasoning?: boolean;
|
|
11
|
+
thinkingLevelMap?: Record<string, string | null>;
|
|
12
|
+
input?: Array<"text" | "image">;
|
|
13
|
+
cost?: Record<string, number>;
|
|
14
|
+
contextWindow?: number;
|
|
15
|
+
maxTokens?: number;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
compat?: Record<string, unknown>;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ProviderConfig = {
|
|
22
|
+
name?: string;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
api?: string;
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
authHeader?: boolean;
|
|
28
|
+
compat?: Record<string, unknown>;
|
|
29
|
+
models?: ProviderModelConfig[];
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ModelsJson = {
|
|
34
|
+
providers?: Record<string, ProviderConfig>;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ProviderDraft = {
|
|
39
|
+
key: string;
|
|
40
|
+
originalKey: string;
|
|
41
|
+
config: ProviderConfig;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ProviderUpsertOptions = {
|
|
45
|
+
previousKey?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function cloneJson<T>(value: T): T {
|
|
49
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getModelsJsonPath(): string {
|
|
53
|
+
const baseDir = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
|
|
54
|
+
return join(baseDir, "models.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createMinimalProviderConfig(): ProviderConfig {
|
|
58
|
+
return {
|
|
59
|
+
baseUrl: "",
|
|
60
|
+
api: "openai-completions",
|
|
61
|
+
apiKey: "$ENV_VAR",
|
|
62
|
+
models: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeProviderConfig(config: ProviderConfig): ProviderConfig {
|
|
67
|
+
const next = cloneJson(config);
|
|
68
|
+
next.models = Array.isArray(next.models) ? next.models.map((model) => cloneJson(model)) : [];
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createProviderDraft(key: string, config: ProviderConfig): ProviderDraft {
|
|
73
|
+
return {
|
|
74
|
+
key,
|
|
75
|
+
originalKey: key,
|
|
76
|
+
config: normalizeProviderConfig(config),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createNewProviderDraft(key: string): ProviderDraft {
|
|
81
|
+
return createProviderDraft(key, createMinimalProviderConfig());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readModelsJson(path = getModelsJsonPath()): ModelsJson {
|
|
85
|
+
let raw: unknown;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "ENOENT") {
|
|
90
|
+
return { providers: {} };
|
|
91
|
+
}
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
throw new Error(`Failed to read models.json at ${path}: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
97
|
+
throw new Error(`Invalid models.json at ${path}: expected a JSON object`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const providers = (raw as ModelsJson).providers;
|
|
101
|
+
if (providers !== undefined && (providers === null || typeof providers !== "object" || Array.isArray(providers))) {
|
|
102
|
+
throw new Error(`Invalid models.json at ${path}: providers must be an object`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return raw as ModelsJson;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function writeModelsJson(models: ModelsJson, path = getModelsJsonPath()): void {
|
|
109
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
110
|
+
writeFileSync(path, `${JSON.stringify(models, null, 2)}\n`, "utf8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function upsertProvider(modelsJson: ModelsJson, draft: ProviderDraft, options: ProviderUpsertOptions = {}): ModelsJson {
|
|
114
|
+
const providers = { ...(modelsJson.providers ?? {}) };
|
|
115
|
+
if (options.previousKey && options.previousKey !== draft.key) {
|
|
116
|
+
delete providers[options.previousKey];
|
|
117
|
+
}
|
|
118
|
+
providers[draft.key] = normalizeProviderConfig(draft.config);
|
|
119
|
+
return {
|
|
120
|
+
...modelsJson,
|
|
121
|
+
providers,
|
|
122
|
+
};
|
|
123
|
+
}
|