@hyav/pi-provider 0.1.4 → 0.1.5
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/CHANGELOG.md +10 -0
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/core/adapter-validation.ts +23 -6
- package/core/catalog-preflight.ts +20 -8
- package/core/diagnostic-auth.ts +103 -0
- package/core/host.ts +26 -4
- package/core/live-check-manager.ts +2 -1
- package/core/official-pricing.ts +39 -17
- package/core/preflight-manager.ts +27 -8
- package/core/provider-registration.ts +105 -6
- package/core/public-adapters.ts +9 -0
- package/core/runtime-config.ts +3 -0
- package/core/runtime.ts +43 -29
- package/core/status-manager.ts +27 -8
- package/core/types.ts +20 -2
- package/index.ts +10 -1
- package/package.json +1 -1
- package/preflight/charm-hyper.ts +2 -1
- package/preflight/deepseek.ts +2 -1
- package/preflight/github-copilot.ts +23 -10
- package/preflight/google.ts +2 -1
- package/preflight/groq.ts +2 -1
- package/preflight/openai-codex.ts +2 -1
- package/preflight/openrouter.ts +2 -1
- package/preflight/vercel-ai-gateway.ts +1 -6
- package/preflight/xai.ts +2 -1
- package/providers/charm-hyper.ts +4 -1
- package/status/anthropic.ts +5 -1
- package/status/charm-hyper.ts +2 -1
- package/status/deepseek.ts +2 -1
- package/status/github-copilot.ts +23 -10
- package/status/groq.ts +2 -1
- package/status/huggingface.ts +2 -1
- package/status/moonshotai.ts +2 -1
- package/status/openai-codex.ts +2 -1
- package/status/opencode-go.ts +2 -1
- package/status/openrouter.ts +2 -1
- package/status/vercel-ai-gateway.ts +2 -1
- package/status/xai.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
This file is the authoritative user-facing release history for `@hyav/pi-provider`.
|
|
4
4
|
|
|
5
|
+
## 0.1.5 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- Skip network model-catalog refreshes for Providers whose environment-backed API key is not configured and which have no resolved stored credential, retaining cached models without Pi refresh warnings.
|
|
8
|
+
- Apply bounded validation to initial, cached, and refreshed model catalogs, including model-count, field-length, and control-character checks.
|
|
9
|
+
- Route Status, Preflight, and Live Check requests through each model credential's effective headers and base URL, and skip account endpoints that cannot be mapped without risking credential disclosure.
|
|
10
|
+
- Defer official metadata network refreshes until session startup, cancel them during shutdown, and preserve refreshed dynamic catalogs when pricing updates race with model discovery.
|
|
11
|
+
- Derive metadata cache paths after resolving the agent directory and disable persistence when no agent directory is configured.
|
|
12
|
+
- Report the Vercel AI Gateway public catalog check without claiming it verifies authentication.
|
|
13
|
+
- Export diagnostic authentication helpers through the public Adapter API so installed user Adapters remain independently loadable.
|
|
14
|
+
|
|
5
15
|
## 0.1.4 - 2026-08-21
|
|
6
16
|
|
|
7
17
|
- Add Status and Preflight Adapters for the Vercel AI Gateway (auth, model catalog, and credits).
|
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@ pi install npm:@hyav/pi-provider
|
|
|
45
45
|
|
|
46
46
|
Use `/status refresh` for free endpoint, authentication, catalog, and account checks. Use `/status check` only when you explicitly accept a real model request and possible usage charges.
|
|
47
47
|
|
|
48
|
+
A dynamic Provider whose API key references environment variables keeps its cached or fallback model catalog and skips network catalog refreshes until those variables or a stored credential are available. This prevents unconfigured Providers from surfacing model-refresh warnings.
|
|
49
|
+
|
|
48
50
|
## Common configuration
|
|
49
51
|
|
|
50
52
|
| Name | Required | Default | Effect |
|
|
@@ -68,7 +70,7 @@ Built-in Adapters ship inside the package and are always discovered. User Adapte
|
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
|
|
71
|
-
Add, remove, or modify files there, then run `/reload` to rediscover them without touching the package; edits to existing files are re-read from disk. User Adapters load after built-ins, so a same-ID file overrides the built-in Adapter (the Host keeps the latest registration and warns). `createPiProviderExtension({ adapterRoot })` replaces the default user directory with a custom root; built-ins are always scanned. The built-in Adapters under the package's `providers/`, `status/`, and `preflight/` are reference templates with this exact shape — copy one and customize it (Charm Hyper and `preflight/openai-codex.ts` also use package-private helpers).
|
|
73
|
+
Add, remove, or modify files there, then run `/reload` to rediscover them without touching the package; edits to existing files are re-read from disk. User Adapters load after built-ins, so a same-ID file overrides the built-in Adapter (the Host keeps the latest registration and warns). `createPiProviderExtension({ adapterRoot })` replaces the default user directory with a custom root; built-ins are always scanned. The built-in Adapters under the package's `providers/`, `status/`, and `preflight/` are reference templates with this exact shape — copy one and customize it (Charm Hyper and `preflight/openai-codex.ts` also use package-private helpers). A complete non-built-in Command Code reference is available in [`examples/command-code`](examples/command-code/).
|
|
72
74
|
|
|
73
75
|
Adapter files import helpers and types from `@hyav/pi-provider` (aliased inside the loader):
|
|
74
76
|
|
package/README.zh-CN.md
CHANGED
|
@@ -45,6 +45,8 @@ pi install npm:@hyav/pi-provider
|
|
|
45
45
|
|
|
46
46
|
使用 `/status refresh` 执行免费的端点、鉴权、目录和账户检查。只有明确接受一次真实模型请求及其可能产生的用量费用时,才使用 `/status check`。
|
|
47
47
|
|
|
48
|
+
动态 Provider 的 API Key 引用环境变量时,如果这些变量和已存储凭据均未配置,将保留缓存或回退模型目录并跳过网络刷新,避免未配置的 Provider 产生模型目录刷新警告。
|
|
49
|
+
|
|
48
50
|
## 常用配置
|
|
49
51
|
|
|
50
52
|
| 名称 | 必需 | 默认值 | 作用 |
|
|
@@ -68,7 +70,7 @@ pi install npm:@hyav/pi-provider
|
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
|
|
71
|
-
在目录中增删或修改文件后执行 `/reload` 即可重新发现,无需改动包;对现有文件的修改会重新从磁盘读取。用户 Adapter 在内置之后加载,因此同 ID 的用户文件会覆盖内置 Adapter(Host 保留最新注册并发出警告)。`createPiProviderExtension({ adapterRoot })` 用自定义根替换默认用户目录;内置目录始终被扫描。包内 `providers/`、`status/`、`preflight/` 下的内置 Adapter 就是采用这种写法的参考模板——复制一份改改即可(Charm Hyper 与 `preflight/openai-codex.ts`
|
|
73
|
+
在目录中增删或修改文件后执行 `/reload` 即可重新发现,无需改动包;对现有文件的修改会重新从磁盘读取。用户 Adapter 在内置之后加载,因此同 ID 的用户文件会覆盖内置 Adapter(Host 保留最新注册并发出警告)。`createPiProviderExtension({ adapterRoot })` 用自定义根替换默认用户目录;内置目录始终被扫描。包内 `providers/`、`status/`、`preflight/` 下的内置 Adapter 就是采用这种写法的参考模板——复制一份改改即可(Charm Hyper 与 `preflight/openai-codex.ts` 还依赖包内私有辅助文件)。完整且不会被默认加载的 Command Code 参考实现见 [`examples/command-code`](examples/command-code/)。
|
|
72
74
|
|
|
73
75
|
Adapter 文件从 `@hyav/pi-provider` 导入 helper 和类型(加载器内部做了别名映射):
|
|
74
76
|
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { isValidTimeoutMs } from "./deadline.ts";
|
|
2
2
|
import type { PreflightAdapter } from "./preflight-manager.ts";
|
|
3
3
|
import { validatePricingAdjustment, validatePricingPolicy } from "./pricing-adjustments.ts";
|
|
4
|
-
import type { ProviderAdapter, StatusAdapter, TunerAdapter } from "./types.ts";
|
|
4
|
+
import type { ProviderAdapter, ProviderModelDraft, StatusAdapter, TunerAdapter } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
export type AdapterValue = ProviderAdapter | StatusAdapter | PreflightAdapter | TunerAdapter;
|
|
7
7
|
|
|
8
8
|
const MAX_STABLE_ID_LENGTH = 128;
|
|
9
9
|
const MAX_TEXT_LENGTH = 1_024;
|
|
10
10
|
const MAX_MODEL_ID_LENGTH = 512;
|
|
11
|
+
/** Maximum accepted models in one initial, cached, or refreshed Provider catalog. */
|
|
12
|
+
export const MAX_PROVIDER_MODEL_COUNT = 4_096;
|
|
11
13
|
|
|
12
14
|
export function isStableAdapterId(value: unknown): value is string {
|
|
13
15
|
return (
|
|
@@ -16,7 +18,7 @@ export function isStableAdapterId(value: unknown): value is string {
|
|
|
16
18
|
value.length <= MAX_STABLE_ID_LENGTH &&
|
|
17
19
|
value.trim() === value &&
|
|
18
20
|
!/\s/.test(value) &&
|
|
19
|
-
!/[\u0000-\u001f\u007f]/.test(value)
|
|
21
|
+
!/[\u0000-\u001f\u007f-\u009f]/.test(value)
|
|
20
22
|
);
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -29,7 +31,7 @@ function isSafeText(value: unknown, maxLength = MAX_TEXT_LENGTH): value is strin
|
|
|
29
31
|
typeof value === "string" &&
|
|
30
32
|
value.trim() !== "" &&
|
|
31
33
|
value.length <= maxLength &&
|
|
32
|
-
!/[\u0000-\u001f\u007f]/.test(value)
|
|
34
|
+
!/[\u0000-\u001f\u007f-\u009f]/.test(value)
|
|
33
35
|
);
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -95,6 +97,17 @@ function validateProviderModelDraft(value: unknown, label: string): void {
|
|
|
95
97
|
if (value.thinkingLevelMap !== undefined) assertAdapterObject(value.thinkingLevelMap, `${label}.thinkingLevelMap`);
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
export function validateProviderModelDrafts(
|
|
101
|
+
value: unknown,
|
|
102
|
+
label = "Provider model catalog",
|
|
103
|
+
): asserts value is ProviderModelDraft[] {
|
|
104
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
105
|
+
if (value.length > MAX_PROVIDER_MODEL_COUNT) {
|
|
106
|
+
throw new Error(`${label} has too many models (maximum ${MAX_PROVIDER_MODEL_COUNT})`);
|
|
107
|
+
}
|
|
108
|
+
for (const [index, model] of value.entries()) validateProviderModelDraft(model, `${label} model ${index}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
export function validateProviderAdapter(adapter: unknown): asserts adapter is ProviderAdapter {
|
|
99
112
|
assertAdapterObject(adapter, "Provider adapter");
|
|
100
113
|
assertStableId(adapter.id, "Provider adapter ID");
|
|
@@ -125,9 +138,7 @@ export function validateProviderAdapter(adapter: unknown): asserts adapter is Pr
|
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
if (!Array.isArray(provider.models)) throw new Error(`Provider ${adapter.id} must define a model list`);
|
|
128
|
-
|
|
129
|
-
validateProviderModelDraft(model, `Provider ${adapter.id} model ${index}`);
|
|
130
|
-
}
|
|
141
|
+
validateProviderModelDrafts(provider.models, `Provider ${adapter.id}`);
|
|
131
142
|
if (provider.refreshModels !== undefined && typeof provider.refreshModels !== "function") {
|
|
132
143
|
throw new Error(`Provider ${adapter.id} has invalid refreshModels`);
|
|
133
144
|
}
|
|
@@ -165,6 +176,9 @@ export function validateStatusAdapter(adapter: unknown): asserts adapter is Stat
|
|
|
165
176
|
throw new Error(`Status ${adapter.id} has invalid cache TTL`);
|
|
166
177
|
}
|
|
167
178
|
if (!isValidTimeoutMs(adapter.requestTimeoutMs)) throw new Error(`Status ${adapter.id} has invalid timing settings`);
|
|
179
|
+
if (adapter.supportsModel !== undefined && typeof adapter.supportsModel !== "function") {
|
|
180
|
+
throw new Error(`Status ${adapter.id} has invalid model support policy`);
|
|
181
|
+
}
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
export function validatePreflightAdapter(adapter: unknown): asserts adapter is PreflightAdapter {
|
|
@@ -179,6 +193,9 @@ export function validatePreflightAdapter(adapter: unknown): asserts adapter is P
|
|
|
179
193
|
if (!isValidTimeoutMs(adapter.requestTimeoutMs)) {
|
|
180
194
|
throw new Error(`Preflight ${adapter.id} has invalid timing settings`);
|
|
181
195
|
}
|
|
196
|
+
if (adapter.supportsModel !== undefined && typeof adapter.supportsModel !== "function") {
|
|
197
|
+
throw new Error(`Preflight ${adapter.id} has invalid model support policy`);
|
|
198
|
+
}
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
export function validateTunerAdapter(adapter: unknown): asserts adapter is TunerAdapter {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Shared helpers for Provider-agnostic, OpenAI-style model-catalog checks. */
|
|
2
2
|
|
|
3
|
+
import { authDefinesHeader, getContextAuth, hasBaseUrlOrigin, mergeDiagnosticHeaders } from "./diagnostic-auth.ts";
|
|
3
4
|
import { ProviderDataError } from "./errors.ts";
|
|
4
5
|
import type { PreflightAdapter } from "./preflight-manager.ts";
|
|
5
6
|
import { parseRetryAfter } from "./retry-after.ts";
|
|
@@ -42,25 +43,36 @@ export function createCatalogPreflightAdapter(
|
|
|
42
43
|
cacheTtlMs: 30_000,
|
|
43
44
|
requestTimeoutMs,
|
|
44
45
|
async fetch(context) {
|
|
45
|
-
const
|
|
46
|
+
const auth = await getContextAuth(context);
|
|
47
|
+
const apiKey = auth.apiKey;
|
|
46
48
|
const credential = context.getCredentialType
|
|
47
49
|
? await context.getCredentialType().catch(() => undefined)
|
|
48
50
|
: undefined;
|
|
49
|
-
const headers
|
|
51
|
+
const headers = mergeDiagnosticHeaders(auth, {
|
|
50
52
|
Accept: "application/json",
|
|
51
53
|
"Accept-Encoding": "identity",
|
|
52
54
|
...(config.headers ?? {}),
|
|
53
|
-
};
|
|
55
|
+
});
|
|
54
56
|
if (apiKey && apiKey !== "proxy-managed") {
|
|
55
57
|
if (config.authHeaders) {
|
|
56
|
-
Object.
|
|
58
|
+
for (const [name, value] of Object.entries(config.authHeaders(apiKey, credential))) {
|
|
59
|
+
if (!authDefinesHeader(auth, name)) headers.set(name, value);
|
|
60
|
+
}
|
|
57
61
|
} else if (config.keyHeader) {
|
|
58
|
-
headers
|
|
59
|
-
} else {
|
|
60
|
-
headers.Authorization
|
|
62
|
+
if (!authDefinesHeader(auth, config.keyHeader)) headers.set(config.keyHeader, apiKey);
|
|
63
|
+
} else if (!authDefinesHeader(auth, "Authorization")) {
|
|
64
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
const
|
|
67
|
+
const effectiveBaseUrl = context.model.baseUrl ?? auth.baseUrl;
|
|
68
|
+
const modelsUrl =
|
|
69
|
+
effectiveBaseUrl === undefined || hasBaseUrlOrigin(effectiveBaseUrl, config.modelsUrl)
|
|
70
|
+
? config.modelsUrl
|
|
71
|
+
: workspaceModelsUrl(effectiveBaseUrl);
|
|
72
|
+
if (!modelsUrl) {
|
|
73
|
+
throw new ProviderDataError(`${config.name} model catalog is unavailable for this endpoint`, "unsupported");
|
|
74
|
+
}
|
|
75
|
+
const response = await context.fetch(modelsUrl, { headers, signal: context.signal });
|
|
64
76
|
if (!response.ok) {
|
|
65
77
|
throw new ProviderDataError(
|
|
66
78
|
`${config.name} preflight failed: HTTP ${response.status}`,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ProviderDataError } from "./errors.ts";
|
|
3
|
+
import type { ProviderRequestAuth } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
type DiagnosticModel = NonNullable<ExtensionContext["model"]>;
|
|
6
|
+
type ModelRegistry = ExtensionContext["modelRegistry"];
|
|
7
|
+
|
|
8
|
+
export interface DiagnosticModelRegistry {
|
|
9
|
+
getApiKeyAndHeaders?: ModelRegistry["getApiKeyAndHeaders"];
|
|
10
|
+
getApiKeyForProvider?: ModelRegistry["getApiKeyForProvider"];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveDiagnosticAuth(
|
|
14
|
+
model: DiagnosticModel,
|
|
15
|
+
modelRegistry: DiagnosticModelRegistry,
|
|
16
|
+
): Promise<ProviderRequestAuth> {
|
|
17
|
+
if (typeof modelRegistry.getApiKeyAndHeaders === "function") {
|
|
18
|
+
const resolved = await modelRegistry.getApiKeyAndHeaders(model);
|
|
19
|
+
if (!resolved.ok) throw new ProviderDataError(resolved.error, "auth");
|
|
20
|
+
return {
|
|
21
|
+
...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
|
|
22
|
+
...(resolved.headers !== undefined ? { headers: { ...resolved.headers } } : {}),
|
|
23
|
+
baseUrl: resolved.baseUrl ?? model.baseUrl,
|
|
24
|
+
...(resolved.env !== undefined ? { env: { ...resolved.env } } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const apiKey =
|
|
29
|
+
typeof modelRegistry.getApiKeyForProvider === "function"
|
|
30
|
+
? await modelRegistry.getApiKeyForProvider(model.provider)
|
|
31
|
+
: undefined;
|
|
32
|
+
return {
|
|
33
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
34
|
+
baseUrl: model.baseUrl,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function applyDiagnosticBaseUrl<T extends DiagnosticModel>(model: T, auth: ProviderRequestAuth): T {
|
|
39
|
+
return auth.baseUrl !== undefined && auth.baseUrl !== model.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read full diagnostic auth, falling back to the legacy API-key accessor for direct adapter use. */
|
|
43
|
+
export async function getContextAuth(context: {
|
|
44
|
+
getAuth?: () => Promise<ProviderRequestAuth>;
|
|
45
|
+
getApiKey: () => Promise<string | undefined>;
|
|
46
|
+
model?: { baseUrl?: string };
|
|
47
|
+
}): Promise<ProviderRequestAuth> {
|
|
48
|
+
if (context.getAuth) return await context.getAuth();
|
|
49
|
+
const apiKey = await context.getApiKey();
|
|
50
|
+
return {
|
|
51
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
52
|
+
...(context.model?.baseUrl !== undefined ? { baseUrl: context.model.baseUrl } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Test resolved headers case-insensitively, including explicit null removals. */
|
|
57
|
+
export function authDefinesHeader(auth: ProviderRequestAuth, name: string): boolean {
|
|
58
|
+
const expected = name.toLowerCase();
|
|
59
|
+
return Object.keys(auth.headers ?? {}).some((candidate) => candidate.toLowerCase() === expected);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Apply Pi-resolved request headers over adapter defaults. */
|
|
63
|
+
export function mergeDiagnosticHeaders(auth: ProviderRequestAuth, defaults: Record<string, string> = {}): Headers {
|
|
64
|
+
const headers = new Headers(defaults);
|
|
65
|
+
for (const [name, value] of Object.entries(auth.headers ?? {})) {
|
|
66
|
+
if (value === null) headers.delete(name);
|
|
67
|
+
else headers.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
return headers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function httpUrl(value: string | undefined): URL | undefined {
|
|
73
|
+
if (!value) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = new URL(value);
|
|
76
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Match an effective model endpoint to a trusted origin; absent model context preserves direct adapter use. */
|
|
83
|
+
export function hasBaseUrlOrigin(baseUrl: string | undefined, expectedUrl: string): boolean {
|
|
84
|
+
if (baseUrl === undefined) return true;
|
|
85
|
+
const actual = httpUrl(baseUrl);
|
|
86
|
+
const expected = httpUrl(expectedUrl);
|
|
87
|
+
return actual !== undefined && expected !== undefined && actual.origin === expected.origin;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve a provider-relative diagnostic path without changing the effective endpoint origin. */
|
|
91
|
+
export function appendBaseUrlPath(
|
|
92
|
+
baseUrl: string | undefined,
|
|
93
|
+
path: string,
|
|
94
|
+
fallbackBaseUrl?: string,
|
|
95
|
+
): string | undefined {
|
|
96
|
+
const parsed = httpUrl(baseUrl) ?? httpUrl(fallbackBaseUrl);
|
|
97
|
+
if (!parsed) return undefined;
|
|
98
|
+
const suffix = path.replace(/^\/+/, "");
|
|
99
|
+
parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/${suffix}`;
|
|
100
|
+
parsed.search = "";
|
|
101
|
+
parsed.hash = "";
|
|
102
|
+
return parsed.toString();
|
|
103
|
+
}
|
package/core/host.ts
CHANGED
|
@@ -66,6 +66,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
66
66
|
let readyPromise: Promise<PiProviderRuntimeController | undefined> | undefined;
|
|
67
67
|
let disposed = false;
|
|
68
68
|
let lifecycleGeneration = 0;
|
|
69
|
+
let pricingRefreshController: AbortController | undefined;
|
|
69
70
|
let latestBackgroundPricing: Record<string, OfficialModelMeta> | undefined;
|
|
70
71
|
let installedDefinition:
|
|
71
72
|
| {
|
|
@@ -85,10 +86,29 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
85
86
|
refreshProviderRegistrations(pi, installedDefinition.definition.providers, runtime, snapshot);
|
|
86
87
|
};
|
|
87
88
|
const officialPricing = runtime.enableOfficialPricingFallback
|
|
88
|
-
? fetchOfficialPricingForHost(runtime,
|
|
89
|
+
? fetchOfficialPricingForHost(runtime, { allowNetwork: false })
|
|
89
90
|
: Promise.resolve({});
|
|
90
91
|
const bridge: StartupBridge = { dependencies: runtime, officialPricing };
|
|
91
92
|
|
|
93
|
+
const startOfficialPricingRefresh = (): void => {
|
|
94
|
+
pricingRefreshController?.abort();
|
|
95
|
+
pricingRefreshController = undefined;
|
|
96
|
+
if (!runtime.enableOfficialPricingFallback || disposed) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
pricingRefreshController = controller;
|
|
101
|
+
void fetchOfficialPricingForHost(runtime, { signal: controller.signal })
|
|
102
|
+
.then((snapshot) => {
|
|
103
|
+
if (controller.signal.aborted || disposed) return;
|
|
104
|
+
onBackgroundRefresh(snapshot);
|
|
105
|
+
})
|
|
106
|
+
.catch(() => undefined)
|
|
107
|
+
.finally(() => {
|
|
108
|
+
if (pricingRefreshController === controller) pricingRefreshController = undefined;
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
92
112
|
const invalidateRuntime = (): void => {
|
|
93
113
|
lifecycleGeneration++;
|
|
94
114
|
installedDefinition = undefined;
|
|
@@ -412,6 +432,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
412
432
|
});
|
|
413
433
|
pi.on("session_start", (event, ctx) => {
|
|
414
434
|
invalidateRuntime();
|
|
435
|
+
startOfficialPricingRefresh();
|
|
415
436
|
scheduleModelCatalogRefresh(ctx, event.reason);
|
|
416
437
|
});
|
|
417
438
|
pi.on("before_provider_request", async (event, ctx) => {
|
|
@@ -426,6 +447,8 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
426
447
|
});
|
|
427
448
|
pi.on("session_shutdown", () => {
|
|
428
449
|
disposed = true;
|
|
450
|
+
pricingRefreshController?.abort();
|
|
451
|
+
pricingRefreshController = undefined;
|
|
429
452
|
invalidateRuntime();
|
|
430
453
|
unsubscribeHostClaim();
|
|
431
454
|
unsubscribeBridge();
|
|
@@ -444,7 +467,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
|
|
|
444
467
|
|
|
445
468
|
function fetchOfficialPricingForHost(
|
|
446
469
|
runtime: PiProviderDependencies,
|
|
447
|
-
|
|
470
|
+
options: { allowNetwork?: boolean; signal?: AbortSignal } = {},
|
|
448
471
|
) {
|
|
449
472
|
return fetchOfficialPricing(
|
|
450
473
|
runtime.fetch,
|
|
@@ -456,8 +479,7 @@ function fetchOfficialPricingForHost(
|
|
|
456
479
|
{
|
|
457
480
|
cachePath:
|
|
458
481
|
runtime.officialPricingUrl === OPENROUTER_MODELS_URL ? runtime.openRouterMetadataCachePath : undefined,
|
|
459
|
-
|
|
460
|
-
onBackgroundRefresh,
|
|
482
|
+
...options,
|
|
461
483
|
},
|
|
462
484
|
);
|
|
463
485
|
}
|
|
@@ -228,7 +228,8 @@ export class LiveCheckManager {
|
|
|
228
228
|
},
|
|
229
229
|
};
|
|
230
230
|
throwIfAborted(signal);
|
|
231
|
-
const
|
|
231
|
+
const requestModel = auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model;
|
|
232
|
+
const stream = provider.streamSimple(requestModel, context, options);
|
|
232
233
|
let completed = false;
|
|
233
234
|
for await (const event of stream) {
|
|
234
235
|
if (event.type === "error") {
|
package/core/official-pricing.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface OfficialPricingFetchOptions {
|
|
|
48
48
|
background?: boolean;
|
|
49
49
|
/** Observe the completed background snapshot without delaying the initial caller. */
|
|
50
50
|
onBackgroundRefresh?: (snapshot: Record<string, OfficialModelMeta>) => void;
|
|
51
|
+
/** Read process/disk cache only and never start network access. */
|
|
52
|
+
allowNetwork?: boolean;
|
|
53
|
+
/** Cancel network access owned by the caller's lifecycle. */
|
|
54
|
+
signal?: AbortSignal;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
interface PricingCacheEntry {
|
|
@@ -68,7 +72,7 @@ const pricingRequests = new Map<string, Promise<Record<string, OfficialModelMeta
|
|
|
68
72
|
|
|
69
73
|
/** Default cache for OpenRouter metadata, not Pi's native model catalog. */
|
|
70
74
|
export function getDefaultOpenRouterMetadataCachePath(agentDir: string): string {
|
|
71
|
-
return join(agentDir, "extensions", "pi-provider", "openrouter-model-metadata.json");
|
|
75
|
+
return agentDir.trim() === "" ? "" : join(agentDir, "extensions", "pi-provider", "openrouter-model-metadata.json");
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
function cloneCost(cost: ProviderCost): ProviderCost {
|
|
@@ -568,6 +572,7 @@ async function fetchOfficialPricingUncoalesced(
|
|
|
568
572
|
maxStaleMs: number,
|
|
569
573
|
now: () => number,
|
|
570
574
|
cachePath?: string,
|
|
575
|
+
externalSignal?: AbortSignal,
|
|
571
576
|
): Promise<Record<string, OfficialModelMeta>> {
|
|
572
577
|
const persisted = await readPersistedPricingCache(cachePath, pricingUrl);
|
|
573
578
|
const allowPersistedStale = persisted !== undefined;
|
|
@@ -581,12 +586,16 @@ async function fetchOfficialPricingUncoalesced(
|
|
|
581
586
|
}
|
|
582
587
|
|
|
583
588
|
try {
|
|
584
|
-
const result = await withDeadline(
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
589
|
+
const result = await withDeadline(
|
|
590
|
+
async (signal) => {
|
|
591
|
+
const response = await fetchFn(pricingUrl, { signal });
|
|
592
|
+
if (!response.ok) return { ok: false as const };
|
|
593
|
+
const payload = await response.json();
|
|
594
|
+
return { ok: true as const, parsed: parseOpenRouterModels(payload) };
|
|
595
|
+
},
|
|
596
|
+
timeoutMs,
|
|
597
|
+
externalSignal,
|
|
598
|
+
);
|
|
590
599
|
if (!result.ok) return staleCache(pricingUrl, now(), maxStaleMs, allowPersistedStale);
|
|
591
600
|
if (Object.keys(result.parsed).length > 0) {
|
|
592
601
|
const updatedAt = now();
|
|
@@ -663,15 +672,6 @@ export async function fetchOfficialPricing(
|
|
|
663
672
|
const cachedAge = getPricingCacheAge(pricingUrl, currentTime);
|
|
664
673
|
if (cachedAge !== undefined && cachedAge <= cacheTtlMs) return getPricingCache(pricingUrl);
|
|
665
674
|
|
|
666
|
-
const existing = pricingRequests.get(pricingUrl);
|
|
667
|
-
if (existing) {
|
|
668
|
-
if (options.background === true) {
|
|
669
|
-
observeBackgroundRefresh(existing, options.onBackgroundRefresh);
|
|
670
|
-
return getPricingCache(pricingUrl);
|
|
671
|
-
}
|
|
672
|
-
return existing;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
675
|
if (options.cachePath) {
|
|
676
676
|
const persisted = await readPersistedPricingCache(options.cachePath, pricingUrl);
|
|
677
677
|
if (persisted !== undefined) {
|
|
@@ -684,7 +684,29 @@ export async function fetchOfficialPricing(
|
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
-
|
|
687
|
+
if (options.allowNetwork === false) return getPricingCache(pricingUrl);
|
|
688
|
+
|
|
689
|
+
const existing = options.signal === undefined ? pricingRequests.get(pricingUrl) : undefined;
|
|
690
|
+
if (existing) {
|
|
691
|
+
if (options.background === true) {
|
|
692
|
+
observeBackgroundRefresh(existing, options.onBackgroundRefresh);
|
|
693
|
+
return getPricingCache(pricingUrl);
|
|
694
|
+
}
|
|
695
|
+
return existing;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const request = options.signal
|
|
699
|
+
? fetchOfficialPricingUncoalesced(
|
|
700
|
+
fetchFn,
|
|
701
|
+
pricingUrl,
|
|
702
|
+
timeoutMs,
|
|
703
|
+
cacheTtlMs,
|
|
704
|
+
maxStaleMs,
|
|
705
|
+
now,
|
|
706
|
+
options.cachePath,
|
|
707
|
+
options.signal,
|
|
708
|
+
)
|
|
709
|
+
: startPricingRequest(fetchFn, pricingUrl, timeoutMs, cacheTtlMs, maxStaleMs, now, options.cachePath);
|
|
688
710
|
if (options.background === true) {
|
|
689
711
|
observeBackgroundRefresh(request, options.onBackgroundRefresh);
|
|
690
712
|
void request.catch(() => undefined);
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { deriveCredentialType } from "./credential-type.ts";
|
|
3
3
|
import { isValidTimeoutMs, withDeadline } from "./deadline.ts";
|
|
4
|
+
import { applyDiagnosticBaseUrl, type DiagnosticModelRegistry, resolveDiagnosticAuth } from "./diagnostic-auth.ts";
|
|
4
5
|
import { isProviderDataError, ProviderDataError } from "./errors.ts";
|
|
6
|
+
import type { ProviderRequestAuth } from "./types.ts";
|
|
5
7
|
|
|
6
8
|
export type PreflightModel = NonNullable<ExtensionContext["model"]>;
|
|
7
9
|
|
|
8
|
-
type ModelRegistry = ExtensionContext["modelRegistry"];
|
|
9
|
-
|
|
10
10
|
export interface PreflightContext {
|
|
11
11
|
fetch: typeof globalThis.fetch;
|
|
12
12
|
getApiKey: () => Promise<string | undefined>;
|
|
13
|
+
/** Complete model-scoped request authentication resolved by Pi. */
|
|
14
|
+
getAuth?: () => Promise<ProviderRequestAuth>;
|
|
13
15
|
signal?: AbortSignal;
|
|
14
16
|
now: () => number;
|
|
15
17
|
model: PreflightModel;
|
|
@@ -32,12 +34,14 @@ export interface PreflightAdapter {
|
|
|
32
34
|
name: string;
|
|
33
35
|
cacheTtlMs: number;
|
|
34
36
|
requestTimeoutMs: number;
|
|
37
|
+
/** Return false when this endpoint cannot safely serve the effective model URL. */
|
|
38
|
+
supportsModel?: (model: PreflightModel) => boolean;
|
|
35
39
|
fetch(context: PreflightContext): Promise<PreflightSnapshot>;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export interface PreflightContextLike {
|
|
39
43
|
model: PreflightModel;
|
|
40
|
-
modelRegistry:
|
|
44
|
+
modelRegistry: DiagnosticModelRegistry;
|
|
41
45
|
/** Optional non-secret credential metadata for provider-specific account labels. */
|
|
42
46
|
getCredentialMetadata?: () => unknown;
|
|
43
47
|
}
|
|
@@ -182,20 +186,30 @@ export class PreflightManager {
|
|
|
182
186
|
const cancellation = new AbortController();
|
|
183
187
|
const generation = ++state.generation;
|
|
184
188
|
const promise = withDeadline(
|
|
185
|
-
(signal) =>
|
|
186
|
-
|
|
189
|
+
async (signal) => {
|
|
190
|
+
const auth = await resolveDiagnosticAuth(ctx.model, ctx.modelRegistry);
|
|
191
|
+
const model = applyDiagnosticBaseUrl(ctx.model, auth);
|
|
192
|
+
if (adapter.supportsModel && !adapter.supportsModel(model)) {
|
|
193
|
+
throw new ProviderDataError(
|
|
194
|
+
"Preflight endpoint is unavailable for the effective model URL",
|
|
195
|
+
"unsupported",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return await adapter.fetch({
|
|
187
199
|
fetch: this.fetchFn,
|
|
188
|
-
getApiKey: () =>
|
|
200
|
+
getApiKey: async () => auth.apiKey,
|
|
201
|
+
getAuth: async () => auth,
|
|
189
202
|
now: this.now,
|
|
190
203
|
signal,
|
|
191
|
-
model
|
|
204
|
+
model,
|
|
192
205
|
...(ctx.getCredentialMetadata === undefined
|
|
193
206
|
? {}
|
|
194
207
|
: {
|
|
195
208
|
getCredentialMetadata: ctx.getCredentialMetadata,
|
|
196
209
|
getCredentialType: async () => deriveCredentialType(ctx.getCredentialMetadata?.()),
|
|
197
210
|
}),
|
|
198
|
-
})
|
|
211
|
+
});
|
|
212
|
+
},
|
|
199
213
|
adapter.requestTimeoutMs,
|
|
200
214
|
cancellation.signal,
|
|
201
215
|
);
|
|
@@ -217,6 +231,11 @@ export class PreflightManager {
|
|
|
217
231
|
return "refreshed";
|
|
218
232
|
} catch (error) {
|
|
219
233
|
if (state.generation !== generation || isErrorNamed(error, "AbortError")) return "skipped";
|
|
234
|
+
if (isProviderDataError(error) && error.code === "unsupported") {
|
|
235
|
+
state.snapshot = undefined;
|
|
236
|
+
state.lastError = undefined;
|
|
237
|
+
return "skipped";
|
|
238
|
+
}
|
|
220
239
|
state.lastError = errorState(error);
|
|
221
240
|
if (state.lastError.code === "timeout") {
|
|
222
241
|
state.generation++;
|