@wuyaos/pi-model-config 0.0.0-stage → 0.1.2
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/runtime.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProvider,
|
|
3
|
+
type Api,
|
|
4
|
+
type ApiKeyAuth,
|
|
5
|
+
type ClassifierApi,
|
|
6
|
+
type ImageApi,
|
|
7
|
+
type Provider,
|
|
8
|
+
type ProviderClassifier,
|
|
9
|
+
type ProviderImages,
|
|
10
|
+
type ProviderStreams,
|
|
11
|
+
} from "@earendil-works/pi-ai";
|
|
12
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { ModelConfigFile, ModelsJsonFile, ModelsJsonProvider, ProviderConnectionConfig } from "./types.ts";
|
|
15
|
+
import { validateHttpUrl } from "./config.ts";
|
|
16
|
+
import { mcError, t, type UiLanguage } from "./i18n.ts";
|
|
17
|
+
import { ANONYMOUS_SENTINEL, applyProviderTransport, CHAT_APIS, CLASSIFIER_APIS, IMAGE_APIS, PROXY_CHAT_APIS, type TransportOptions } from "./transport.ts";
|
|
18
|
+
|
|
19
|
+
interface HostApiImplementations {
|
|
20
|
+
chat: Map<string, ProviderStreams>;
|
|
21
|
+
classifier: Map<string, ProviderClassifier>;
|
|
22
|
+
image: Map<string, ProviderImages>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The extension loader exposes @earendil-works/pi-ai/providers/all as a host-owned
|
|
27
|
+
* virtual/aliased module in every runtime. Derive API implementations from those
|
|
28
|
+
* native Provider objects instead of importing unsupported api/*.lazy subpaths,
|
|
29
|
+
* which are not part of the loader's virtual module table.
|
|
30
|
+
*/
|
|
31
|
+
const HOST_BUILTIN_PROVIDERS = builtinProviders();
|
|
32
|
+
|
|
33
|
+
function collectHostApiImplementations(providers: readonly Provider[]): HostApiImplementations {
|
|
34
|
+
const chat = new Map<string, ProviderStreams>();
|
|
35
|
+
const classifier = new Map<string, ProviderClassifier>();
|
|
36
|
+
const image = new Map<string, ProviderImages>();
|
|
37
|
+
for (const provider of providers) {
|
|
38
|
+
const models = provider.getAllModels?.() ?? provider.getModels();
|
|
39
|
+
for (const model of models) {
|
|
40
|
+
if (model.type === "classifier") {
|
|
41
|
+
if (provider.classify && !classifier.has(model.api)) {
|
|
42
|
+
classifier.set(model.api, {
|
|
43
|
+
classify: (requestModel, context, options) => provider.classify!(requestModel, context, options),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (model.type === "image") {
|
|
49
|
+
if (provider.generateImages && !image.has(model.api)) {
|
|
50
|
+
image.set(model.api, {
|
|
51
|
+
generateImages: (requestModel, context, options) => provider.generateImages!(requestModel, context, options),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!chat.has(model.api)) {
|
|
57
|
+
chat.set(model.api, {
|
|
58
|
+
stream: (requestModel, context, options) => provider.stream(requestModel, context, options),
|
|
59
|
+
streamSimple: (requestModel, context, options) => provider.streamSimple(requestModel, context, options),
|
|
60
|
+
...(provider.fetchDeferred ? {
|
|
61
|
+
fetchDeferred: (requestModel, handle, options) => provider.fetchDeferred!(requestModel, handle, options),
|
|
62
|
+
} : {}),
|
|
63
|
+
...(provider.cancelDeferred ? {
|
|
64
|
+
cancelDeferred: (requestModel, handle, options) => provider.cancelDeferred!(requestModel, handle, options),
|
|
65
|
+
} : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { chat, classifier, image };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const HOST_APIS = collectHostApiImplementations(HOST_BUILTIN_PROVIDERS);
|
|
74
|
+
|
|
75
|
+
function requiredImplementation<T>(implementations: ReadonlyMap<string, T>, api: string, operation: string): T {
|
|
76
|
+
const implementation = implementations.get(api);
|
|
77
|
+
if (!implementation) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: operation, api });
|
|
78
|
+
return implementation;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function wrapStreams(api: string, config: ProviderConnectionConfig): ProviderStreams {
|
|
82
|
+
const base = requiredImplementation(HOST_APIS.chat, api, "chat");
|
|
83
|
+
const transport = <T extends TransportOptions>(options: T | undefined): T => applyProviderTransport(options, {
|
|
84
|
+
proxyUrl: config.proxyUrl,
|
|
85
|
+
anonymous: config.authMode === "anonymous",
|
|
86
|
+
api,
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
stream: (model, context, options) => base.stream(model, context, transport(options)),
|
|
90
|
+
streamSimple: (model, context, options) => base.streamSimple(model, context, transport(options)),
|
|
91
|
+
...(base.fetchDeferred ? { fetchDeferred: (model, handle, options) => base.fetchDeferred!(model, handle, transport(options)) } : {}),
|
|
92
|
+
...(base.cancelDeferred ? { cancelDeferred: (model, handle, options) => base.cancelDeferred!(model, handle, transport(options)) } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function wrapClassifier(api: string, config: ProviderConnectionConfig): ProviderClassifier {
|
|
97
|
+
const base = requiredImplementation(HOST_APIS.classifier, api, "classifier");
|
|
98
|
+
return {
|
|
99
|
+
classify: (model, context, options) => base.classify(model, context, applyProviderTransport(options, {
|
|
100
|
+
proxyUrl: config.proxyUrl,
|
|
101
|
+
anonymous: config.authMode === "anonymous",
|
|
102
|
+
api,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wrapImages(api: string, config: ProviderConnectionConfig): ProviderImages {
|
|
108
|
+
const base = requiredImplementation(HOST_APIS.image, api, "image");
|
|
109
|
+
return {
|
|
110
|
+
generateImages: (model, context, options) => base.generateImages(model, context, applyProviderTransport(options, {
|
|
111
|
+
proxyUrl: config.proxyUrl,
|
|
112
|
+
anonymous: config.authMode === "anonymous",
|
|
113
|
+
api,
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function authFor(config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider | undefined, language: UiLanguage): ApiKeyAuth {
|
|
119
|
+
return {
|
|
120
|
+
name: config.authMode === "anonymous" ? t(language, "authAnonymous") : config.authMode === "reuse-pi" ? t(language, "authReuse") : t(language, "authModelsJson"),
|
|
121
|
+
resolve: async ({ ctx, credential, signal }) => {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
if (config.authMode === "anonymous") return { auth: { apiKey: ANONYMOUS_SENTINEL }, source: t(language, "authSourceAnonymous") };
|
|
124
|
+
if (credential?.type === "api_key" && credential.key) {
|
|
125
|
+
return { auth: { apiKey: credential.key }, env: credential.env, source: t(language, "authSourceStored") };
|
|
126
|
+
}
|
|
127
|
+
if (config.authMode === "reuse-pi") return undefined;
|
|
128
|
+
const raw = modelsProvider?.apiKey;
|
|
129
|
+
if (!raw) return undefined;
|
|
130
|
+
const match = raw.match(/^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/u);
|
|
131
|
+
const escapedLiteral = raw.startsWith("$$") || raw.startsWith("$!");
|
|
132
|
+
const key = match ? await ctx.env(match[1] ?? match[2]!) : escapedLiteral ? raw.slice(1) : raw;
|
|
133
|
+
signal.throwIfAborted();
|
|
134
|
+
return key ? { auth: { apiKey: key }, source: match ? (match[1] ?? match[2]) : "models.json" } : undefined;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeChatModels(providerId: string, provider: ModelsJsonProvider, config: ProviderConnectionConfig) {
|
|
140
|
+
const baseUrl = provider.baseUrl;
|
|
141
|
+
return (provider.models ?? []).map((raw) => ({
|
|
142
|
+
type: "chat" as const,
|
|
143
|
+
id: String(raw.id),
|
|
144
|
+
name: typeof raw.name === "string" ? raw.name : String(raw.id),
|
|
145
|
+
api: typeof raw.api === "string" ? raw.api : (provider.api ?? config.chatApi ?? "openai-completions"),
|
|
146
|
+
baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : (baseUrl ?? ""),
|
|
147
|
+
reasoning: raw.reasoning === true,
|
|
148
|
+
...(typeof raw.thinkingLevelMap === "object" && raw.thinkingLevelMap ? { thinkingLevelMap: raw.thinkingLevelMap as any } : {}),
|
|
149
|
+
input: (Array.isArray(raw.input) ? raw.input.filter((v): v is "text" | "image" => v === "text" || v === "image") : ["text"]) as ("text" | "image")[],
|
|
150
|
+
cost: typeof raw.cost === "object" && raw.cost ? raw.cost as { input: number; output: number; cacheRead: number; cacheWrite: number } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
151
|
+
contextWindow: typeof raw.contextWindow === "number" ? raw.contextWindow : 128_000,
|
|
152
|
+
maxTokens: typeof raw.maxTokens === "number" ? raw.maxTokens : 16_384,
|
|
153
|
+
...(typeof raw.headers === "object" && raw.headers ? { headers: raw.headers as Record<string, string> } : {}),
|
|
154
|
+
provider: providerId,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateRuntimeConfig(providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider | undefined): void {
|
|
159
|
+
if (config.discoveryUrl) validateHttpUrl(config.discoveryUrl, `${providerId} Discovery URL`);
|
|
160
|
+
if (config.proxyUrl) validateHttpUrl(config.proxyUrl, `${providerId}.proxyUrl`);
|
|
161
|
+
const chatApis = new Set<string>();
|
|
162
|
+
for (const model of modelsProvider?.models ?? []) {
|
|
163
|
+
if (typeof model.id !== "string" || !model.id) throw mcError("MCFG_E_MODEL_SHAPE", { file: "models.json", provider: providerId });
|
|
164
|
+
if (model.type === "classifier" || model.type === "image") {
|
|
165
|
+
throw mcError("MCFG_E_TYPE_API", { identity: `${providerId}/chat/${model.id}`, api: String(model.api ?? model.type) });
|
|
166
|
+
}
|
|
167
|
+
const api = typeof model.api === "string" ? model.api : (modelsProvider?.api ?? config.chatApi ?? "openai-completions");
|
|
168
|
+
if (!CHAT_APIS.has(api)) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${providerId}/chat/${model.id}`, api });
|
|
169
|
+
chatApis.add(api);
|
|
170
|
+
if (config.owned) validateHttpUrl(String(model.baseUrl ?? modelsProvider?.baseUrl ?? ""), `${providerId}/${model.id} Base URL`);
|
|
171
|
+
if (config.owned && (typeof model.contextWindow !== "number" || typeof model.maxTokens !== "number")) throw mcError("MCFG_E_MODEL_SHAPE", { file: "models.json", provider: providerId });
|
|
172
|
+
}
|
|
173
|
+
for (const model of config.models ?? []) {
|
|
174
|
+
const supported = model.type === "classifier" ? CLASSIFIER_APIS : model.type === "image" ? IMAGE_APIS : undefined;
|
|
175
|
+
if (!supported) throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
|
|
176
|
+
if (!supported.has(model.api)) throw mcError("MCFG_E_TYPE_API", { identity: `${providerId}/${model.type}/${model.id}`, api: model.api });
|
|
177
|
+
if (config.owned) validateHttpUrl(model.baseUrl ?? modelsProvider?.baseUrl ?? "", `${providerId}/${model.id} Base URL`);
|
|
178
|
+
if (config.owned && model.type === "classifier" && typeof model.contextWindow !== "number") throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
|
|
179
|
+
}
|
|
180
|
+
if (!config.owned && (config.models?.length ?? 0) > 0) {
|
|
181
|
+
throw mcError("MCFG_E_NOT_OWNED", { provider: providerId });
|
|
182
|
+
}
|
|
183
|
+
for (const api of chatApis) {
|
|
184
|
+
if (config.proxyUrl && !PROXY_CHAT_APIS.has(api)) {
|
|
185
|
+
throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: `${providerId}/chat/${api}` });
|
|
186
|
+
}
|
|
187
|
+
if (config.authMode === "anonymous" && ["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(api)) {
|
|
188
|
+
throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${providerId}/chat/${api}` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function registerOwnedProvider(pi: ExtensionAPI, providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider, language: UiLanguage): void {
|
|
194
|
+
const chatModels = normalizeChatModels(providerId, modelsProvider, config);
|
|
195
|
+
const extraModels = (config.models ?? []).map((model) => ({ ...model, provider: providerId, baseUrl: model.baseUrl ?? modelsProvider.baseUrl ?? "" }));
|
|
196
|
+
const allModels = [...chatModels, ...extraModels];
|
|
197
|
+
if (allModels.length === 0) return;
|
|
198
|
+
const chatApis = new Set(chatModels.map((model) => model.api));
|
|
199
|
+
const classifierApis = new Set(extraModels.filter((model) => model.type === "classifier").map((model) => model.api));
|
|
200
|
+
const imageApis = new Set(extraModels.filter((model) => model.type === "image").map((model) => model.api));
|
|
201
|
+
const api = Object.fromEntries([...chatApis].map((id) => [id, wrapStreams(id, config)])) as Partial<Record<Api, ProviderStreams>>;
|
|
202
|
+
const classifiers = Object.fromEntries([...classifierApis].map((id) => [id, wrapClassifier(id, config)])) as Partial<Record<ClassifierApi, ProviderClassifier>>;
|
|
203
|
+
const images = Object.fromEntries([...imageApis].map((id) => [id, wrapImages(id, config)])) as Partial<Record<ImageApi, ProviderImages>>;
|
|
204
|
+
pi.registerProvider(createProvider({
|
|
205
|
+
id: providerId,
|
|
206
|
+
name: config.name ?? modelsProvider.name ?? providerId,
|
|
207
|
+
baseUrl: modelsProvider.baseUrl,
|
|
208
|
+
headers: modelsProvider.headers,
|
|
209
|
+
auth: { apiKey: authFor(config, modelsProvider, language) },
|
|
210
|
+
models: allModels,
|
|
211
|
+
...(Object.keys(api).length ? { api } : {}),
|
|
212
|
+
...(Object.keys(classifiers).length ? { classifiers } : {}),
|
|
213
|
+
...(Object.keys(images).length ? { images } : {}),
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function wrapBuiltinProvider(base: Provider, config: ProviderConnectionConfig, language: UiLanguage): Provider {
|
|
218
|
+
const transport = <T extends TransportOptions>(options: T | undefined, api: string): T => applyProviderTransport(options, {
|
|
219
|
+
proxyUrl: config.proxyUrl,
|
|
220
|
+
anonymous: config.authMode === "anonymous",
|
|
221
|
+
api,
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
id: base.id,
|
|
225
|
+
name: base.name,
|
|
226
|
+
baseUrl: base.baseUrl,
|
|
227
|
+
headers: base.headers,
|
|
228
|
+
auth: config.authMode === "anonymous" ? { apiKey: authFor(config, undefined, language) } : base.auth,
|
|
229
|
+
getModels: () => base.getModels(),
|
|
230
|
+
...(base.getAllModels ? { getAllModels: () => base.getAllModels!() } : {}),
|
|
231
|
+
...(base.refreshModels ? { refreshModels: (context) => base.refreshModels!(context) } : {}),
|
|
232
|
+
...(base.filterModels ? { filterModels: (models, credential) => base.filterModels!(models, credential) } : {}),
|
|
233
|
+
...(base.filterAllModels ? { filterAllModels: (models, credential) => base.filterAllModels!(models, credential) } : {}),
|
|
234
|
+
stream: (model, context, options) => base.stream(model, context, transport(options, model.api)),
|
|
235
|
+
streamSimple: (model, context, options) => base.streamSimple(model, context, transport(options, model.api)),
|
|
236
|
+
...(base.fetchDeferred ? { fetchDeferred: (model, handle, options) => base.fetchDeferred!(model, handle, transport(options, model.api)) } : {}),
|
|
237
|
+
...(base.cancelDeferred ? { cancelDeferred: (model, handle, options) => base.cancelDeferred!(model, handle, transport(options, model.api)) } : {}),
|
|
238
|
+
...(base.generateImages ? { generateImages: (model, context, options) => base.generateImages!(model, context, transport(options, model.api)) } : {}),
|
|
239
|
+
...(base.classify ? { classify: (model, context, options) => base.classify!(model, context, transport(options, model.api)) } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function registerBuiltinOverlay(pi: ExtensionAPI, providerId: string, config: ProviderConnectionConfig, language: UiLanguage): void {
|
|
244
|
+
const base = HOST_BUILTIN_PROVIDERS.find((provider) => provider.id === providerId);
|
|
245
|
+
if (!base) throw mcError("MCFG_E_NOT_BUILTIN", { provider: providerId });
|
|
246
|
+
const allModels = base.getAllModels?.() ?? base.getModels();
|
|
247
|
+
const unsupportedProxy = config.proxyUrl
|
|
248
|
+
? allModels.filter((model) => model.type !== "classifier" && model.type !== "image" && !PROXY_CHAT_APIS.has(model.api))
|
|
249
|
+
: [];
|
|
250
|
+
if (unsupportedProxy.length) throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: providerId });
|
|
251
|
+
const unsupportedAnonymous = config.authMode === "anonymous"
|
|
252
|
+
? allModels.filter((model) => ["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(model.api))
|
|
253
|
+
: [];
|
|
254
|
+
if (unsupportedAnonymous.length) throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: providerId });
|
|
255
|
+
pi.registerProvider(wrapBuiltinProvider(base, config, language));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function registerDisabledProvider(pi: ExtensionAPI, providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider | undefined): void {
|
|
259
|
+
const builtin = HOST_BUILTIN_PROVIDERS.some((provider) => provider.id === providerId);
|
|
260
|
+
if (!builtin && !config.owned && !modelsProvider) throw mcError("MCFG_E_DISABLE_THIRD_PARTY", { provider: providerId });
|
|
261
|
+
pi.registerProvider(providerId, { models: [] });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ProviderRegistrationIssue {
|
|
265
|
+
providerId: string;
|
|
266
|
+
error: unknown;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function registerConfiguredProviders(
|
|
270
|
+
pi: ExtensionAPI,
|
|
271
|
+
configFile: ModelConfigFile,
|
|
272
|
+
modelsFile: ModelsJsonFile,
|
|
273
|
+
language: UiLanguage,
|
|
274
|
+
): ProviderRegistrationIssue[] {
|
|
275
|
+
const issues: ProviderRegistrationIssue[] = [];
|
|
276
|
+
for (const [providerId, config] of Object.entries(configFile.providers)) {
|
|
277
|
+
try {
|
|
278
|
+
const modelsProvider = modelsFile.providers[providerId];
|
|
279
|
+
if (config.enabled === false) {
|
|
280
|
+
registerDisabledProvider(pi, providerId, config, modelsProvider);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
validateRuntimeConfig(providerId, config, modelsProvider);
|
|
284
|
+
if (config.owned) {
|
|
285
|
+
if (!modelsProvider) throw mcError("MCFG_E_OWNED_PROVIDER_MISSING", { provider: providerId });
|
|
286
|
+
registerOwnedProvider(pi, providerId, config, modelsProvider, language);
|
|
287
|
+
} else if (config.proxyUrl || config.authMode === "anonymous") {
|
|
288
|
+
registerBuiltinOverlay(pi, providerId, config, language);
|
|
289
|
+
} else {
|
|
290
|
+
const safeOverlayOwner = HOST_BUILTIN_PROVIDERS.some((provider) => provider.id === providerId) || !!modelsProvider;
|
|
291
|
+
if (safeOverlayOwner) pi.unregisterProvider(providerId);
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
issues.push({ providerId, error });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return issues;
|
|
298
|
+
}
|