@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.
@@ -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
+ }