@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 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
- for (const [index, model] of provider.models.entries()) {
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 apiKey = await context.getApiKey();
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: Record<string, string> = {
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.assign(headers, config.authHeaders(apiKey, credential));
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[config.keyHeader] = apiKey;
59
- } else {
60
- headers.Authorization = `Bearer ${apiKey}`;
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 response = await context.fetch(config.modelsUrl, { headers, signal: context.signal });
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, onBackgroundRefresh)
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
- onBackgroundRefresh?: (snapshot: Record<string, OfficialModelMeta>) => void,
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
- background: runtime.officialPricingUrl === OPENROUTER_MODELS_URL,
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 stream = provider.streamSimple(ctx.model, context, options);
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") {
@@ -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(async (signal) => {
585
- const response = await fetchFn(pricingUrl, { signal });
586
- if (!response.ok) return { ok: false as const };
587
- const payload = await response.json();
588
- return { ok: true as const, parsed: parseOpenRouterModels(payload) };
589
- }, timeoutMs);
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
- const request = startPricingRequest(fetchFn, pricingUrl, timeoutMs, cacheTtlMs, maxStaleMs, now, options.cachePath);
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: Pick<ModelRegistry, "getApiKeyForProvider">;
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
- adapter.fetch({
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: () => ctx.modelRegistry.getApiKeyForProvider(adapter.providerId),
200
+ getApiKey: async () => auth.apiKey,
201
+ getAuth: async () => auth,
189
202
  now: this.now,
190
203
  signal,
191
- model: ctx.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++;