@hyav/pi-provider 0.1.3 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +8 -1
  3. package/README.zh-CN.md +8 -1
  4. package/core/adapter-validation.ts +23 -6
  5. package/core/catalog-preflight.ts +142 -0
  6. package/core/credential-type.ts +13 -0
  7. package/core/diagnostic-auth.ts +103 -0
  8. package/core/host.ts +26 -4
  9. package/core/live-check-manager.ts +2 -1
  10. package/core/official-pricing.ts +39 -17
  11. package/core/preflight-manager.ts +40 -8
  12. package/core/provider-registration.ts +105 -6
  13. package/core/public-adapters.ts +10 -0
  14. package/core/ratelimit-headers.ts +72 -0
  15. package/core/runtime-config.ts +3 -0
  16. package/core/runtime.ts +55 -34
  17. package/core/status-manager.ts +34 -9
  18. package/core/types.ts +22 -2
  19. package/index.ts +12 -1
  20. package/package.json +1 -1
  21. package/preflight/anthropic.ts +42 -0
  22. package/preflight/cerebras.ts +27 -0
  23. package/preflight/charm-hyper.ts +2 -1
  24. package/preflight/deepseek.ts +2 -1
  25. package/preflight/github-copilot.ts +87 -0
  26. package/preflight/google.ts +2 -1
  27. package/preflight/groq.ts +73 -0
  28. package/preflight/huggingface.ts +27 -0
  29. package/preflight/mistral.ts +27 -0
  30. package/preflight/moonshotai-cn.ts +27 -0
  31. package/preflight/moonshotai.ts +37 -0
  32. package/preflight/nvidia.ts +27 -0
  33. package/preflight/openai-codex.ts +2 -1
  34. package/preflight/openai.ts +27 -0
  35. package/preflight/openrouter.ts +112 -0
  36. package/preflight/vercel-ai-gateway.ts +81 -0
  37. package/preflight/xai.ts +71 -0
  38. package/providers/charm-hyper.ts +4 -1
  39. package/status/anthropic.ts +262 -0
  40. package/status/charm-hyper.ts +3 -2
  41. package/status/deepseek.ts +2 -1
  42. package/status/github-copilot.ts +189 -0
  43. package/status/groq.ts +89 -0
  44. package/status/huggingface.ts +95 -0
  45. package/status/moonshotai-cn.ts +26 -0
  46. package/status/moonshotai.ts +151 -0
  47. package/status/openai-codex.ts +2 -1
  48. package/status/opencode-go.ts +2 -1
  49. package/status/openrouter.ts +173 -0
  50. package/status/vercel-ai-gateway/constants.ts +3 -0
  51. package/status/vercel-ai-gateway.ts +95 -0
  52. package/status/xai.ts +74 -0
@@ -1,17 +1,24 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { deriveCredentialType } from "./credential-type.ts";
2
3
  import { isValidTimeoutMs, withDeadline } from "./deadline.ts";
4
+ import { applyDiagnosticBaseUrl, type DiagnosticModelRegistry, resolveDiagnosticAuth } from "./diagnostic-auth.ts";
3
5
  import { isProviderDataError, ProviderDataError } from "./errors.ts";
6
+ import type { ProviderRequestAuth } from "./types.ts";
4
7
 
5
8
  export type PreflightModel = NonNullable<ExtensionContext["model"]>;
6
9
 
7
- type ModelRegistry = ExtensionContext["modelRegistry"];
8
-
9
10
  export interface PreflightContext {
10
11
  fetch: typeof globalThis.fetch;
11
12
  getApiKey: () => Promise<string | undefined>;
13
+ /** Complete model-scoped request authentication resolved by Pi. */
14
+ getAuth?: () => Promise<ProviderRequestAuth>;
12
15
  signal?: AbortSignal;
13
16
  now: () => number;
14
17
  model: PreflightModel;
18
+ /** Optional non-secret credential metadata for provider-specific account labels. */
19
+ getCredentialMetadata?: () => unknown;
20
+ /** Optional non-secret credential type ("oauth" vs "api_key") for providers with dual auth modes. */
21
+ getCredentialType?: () => Promise<string | undefined>;
15
22
  }
16
23
 
17
24
  export interface PreflightSnapshot {
@@ -27,12 +34,16 @@ export interface PreflightAdapter {
27
34
  name: string;
28
35
  cacheTtlMs: number;
29
36
  requestTimeoutMs: number;
37
+ /** Return false when this endpoint cannot safely serve the effective model URL. */
38
+ supportsModel?: (model: PreflightModel) => boolean;
30
39
  fetch(context: PreflightContext): Promise<PreflightSnapshot>;
31
40
  }
32
41
 
33
42
  export interface PreflightContextLike {
34
43
  model: PreflightModel;
35
- modelRegistry: Pick<ModelRegistry, "getApiKeyForProvider">;
44
+ modelRegistry: DiagnosticModelRegistry;
45
+ /** Optional non-secret credential metadata for provider-specific account labels. */
46
+ getCredentialMetadata?: () => unknown;
36
47
  }
37
48
 
38
49
  export interface PreflightErrorState {
@@ -175,14 +186,30 @@ export class PreflightManager {
175
186
  const cancellation = new AbortController();
176
187
  const generation = ++state.generation;
177
188
  const promise = withDeadline(
178
- (signal) =>
179
- 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({
180
199
  fetch: this.fetchFn,
181
- getApiKey: () => ctx.modelRegistry.getApiKeyForProvider(adapter.providerId),
200
+ getApiKey: async () => auth.apiKey,
201
+ getAuth: async () => auth,
182
202
  now: this.now,
183
203
  signal,
184
- model: ctx.model,
185
- }),
204
+ model,
205
+ ...(ctx.getCredentialMetadata === undefined
206
+ ? {}
207
+ : {
208
+ getCredentialMetadata: ctx.getCredentialMetadata,
209
+ getCredentialType: async () => deriveCredentialType(ctx.getCredentialMetadata?.()),
210
+ }),
211
+ });
212
+ },
186
213
  adapter.requestTimeoutMs,
187
214
  cancellation.signal,
188
215
  );
@@ -204,6 +231,11 @@ export class PreflightManager {
204
231
  return "refreshed";
205
232
  } catch (error) {
206
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
+ }
207
239
  state.lastError = errorState(error);
208
240
  if (state.lastError.code === "timeout") {
209
241
  state.generation++;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
2
+ import { validateProviderModelDrafts } from "./adapter-validation.ts";
2
3
  import { applyOfficialModelCosts, findOfficialMeta, type OfficialModelMeta } from "./official-pricing.ts";
3
4
  import { resolvePricingDetails } from "./pricing-adjustments.ts";
4
5
  import type { PiProviderDependencies } from "./runtime-config.ts";
@@ -83,6 +84,7 @@ export function normalizeProviderModel(model: ProviderModelDraft): ProviderModel
83
84
  }
84
85
 
85
86
  export function normalizeProviderModels(models: ProviderModelDraft[]): ProviderModel[] {
87
+ validateProviderModelDrafts(models);
86
88
  const seen = new Set<string>();
87
89
  return models.map((model) => {
88
90
  const normalized = normalizeProviderModel(model);
@@ -113,6 +115,7 @@ function resolveModelRegistration(
113
115
  modelDrafts: ProviderModelDraft[],
114
116
  officialPricing: Record<string, OfficialModelMeta>,
115
117
  ): { models: ProviderModel[]; modelMetadata: Record<string, ProviderModelMetadata> } {
118
+ validateProviderModelDrafts(modelDrafts, `Provider ${adapter.id}`);
116
119
  const enrichedDrafts = applyOfficialModelCosts(modelDrafts, officialPricing);
117
120
  const pricingPolicy = runtime.pricingPolicies?.[adapter.id] ?? adapter.pricing;
118
121
  const metadata: Record<string, ProviderModelMetadata> = {};
@@ -177,6 +180,65 @@ function getErrorCode(error: unknown): string {
177
180
  return "fetch";
178
181
  }
179
182
 
183
+ const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
184
+ const ENVIRONMENT_NAME_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*/;
185
+
186
+ /** Match Pi's `$NAME` / `${NAME}` interpolation without executing API-key commands. */
187
+ function getEnvironmentReferences(value: string): string[] {
188
+ if (value.startsWith("!")) return [];
189
+ const names = new Set<string>();
190
+ let index = 0;
191
+ while (index < value.length) {
192
+ const dollarIndex = value.indexOf("$", index);
193
+ if (dollarIndex < 0) break;
194
+ const next = value[dollarIndex + 1];
195
+ if (next === "$" || next === "!") {
196
+ index = dollarIndex + 2;
197
+ continue;
198
+ }
199
+ if (next === "{") {
200
+ const endIndex = value.indexOf("}", dollarIndex + 2);
201
+ if (endIndex < 0) {
202
+ index = dollarIndex + 1;
203
+ continue;
204
+ }
205
+ const name = value.slice(dollarIndex + 2, endIndex);
206
+ if (ENVIRONMENT_NAME.test(name)) names.add(name);
207
+ index = endIndex + 1;
208
+ continue;
209
+ }
210
+ const match = value.slice(dollarIndex + 1).match(ENVIRONMENT_NAME_PREFIX);
211
+ if (match) {
212
+ names.add(match[0]);
213
+ index = dollarIndex + 1 + match[0].length;
214
+ } else {
215
+ index = dollarIndex + 1;
216
+ }
217
+ }
218
+ return [...names];
219
+ }
220
+
221
+ function getRegistrationState(adapter: ProviderAdapter): NonNullable<ProviderAdapter["registration"]> | undefined {
222
+ const registration = adapter.registration;
223
+ if (!registration) return undefined;
224
+ registration.normalizedModels ??= [];
225
+ registration.modelMetadata ??= {};
226
+ registration.officialPricing ??= {};
227
+ registration.activeRefreshes ??= 0;
228
+ return registration;
229
+ }
230
+
231
+ function replaceModels(target: ProviderModel[], source: ProviderModel[]): ProviderModel[] {
232
+ target.splice(0, target.length, ...source);
233
+ return target;
234
+ }
235
+
236
+ function lacksCatalogRefreshCredential(apiKey: string, context: ProviderRefreshContext): boolean {
237
+ if (context.allowNetwork !== true || context.credential !== undefined) return false;
238
+ const environmentNames = getEnvironmentReferences(apiKey);
239
+ return environmentNames.length > 0 && environmentNames.some((name) => !process.env[name]);
240
+ }
241
+
180
242
  /**
181
243
  * Register a normalized Provider before the Host has assembled its final
182
244
  * registry. The original drafts remain attached to the adapter so a Host in a
@@ -194,9 +256,19 @@ export function prepareProviderRegistration(
194
256
  ? adapter.registration.modelDrafts
195
257
  : adapter.provider.models);
196
258
  const resolved = resolveModelRegistration(adapter, runtime, drafts, officialPricing);
197
- const models = resolved.models;
259
+ const existingRegistration = getRegistrationState(adapter);
260
+ const registration: NonNullable<ProviderAdapter["registration"]> = existingRegistration ?? {
261
+ modelDrafts: drafts,
262
+ normalizedModels: [],
263
+ modelMetadata: {},
264
+ officialPricing,
265
+ activeRefreshes: 0,
266
+ };
267
+ registration.modelDrafts = drafts;
268
+ registration.modelMetadata = resolved.modelMetadata;
269
+ registration.officialPricing = officialPricing;
270
+ const models = replaceModels(registration.normalizedModels, resolved.models);
198
271
  const adapterOwnsCatalog = adapter.catalog !== undefined;
199
- const registration = { modelDrafts: drafts, normalizedModels: models, modelMetadata: resolved.modelMetadata };
200
272
  adapter.registration = registration;
201
273
  adapter.provider.models = models;
202
274
  adapter.catalog ??= { source: "static", modelCount: models.length };
@@ -206,12 +278,18 @@ export function prepareProviderRegistration(
206
278
  const registeredProvider: ProviderConfig = { ...providerMetadata, models };
207
279
  if (originalRefresh) {
208
280
  registeredProvider.refreshModels = async (options: ProviderRefreshContext) => {
281
+ // Pi may ask every dynamic Provider to refresh. Keep the current catalog
282
+ // when this Provider's environment-backed key is absent instead of
283
+ // attempting an unauthenticated request that becomes a global refresh error.
284
+ if (lacksCatalogRefreshCredential(adapter.provider.apiKey, options)) {
285
+ return [...registration.normalizedModels];
286
+ }
287
+ registration.activeRefreshes++;
209
288
  try {
210
289
  const refreshedModels = await originalRefresh(options);
211
- const resolved = resolveModelRegistration(adapter, runtime, refreshedModels, officialPricing);
212
- const normalizedModels = resolved.models;
290
+ const resolved = resolveModelRegistration(adapter, runtime, refreshedModels, registration.officialPricing);
291
+ const normalizedModels = replaceModels(registration.normalizedModels, resolved.models);
213
292
  registration.modelDrafts = refreshedModels;
214
- registration.normalizedModels = normalizedModels;
215
293
  registration.modelMetadata = resolved.modelMetadata;
216
294
  adapter.provider.models = normalizedModels;
217
295
  registeredProvider.models = normalizedModels;
@@ -226,16 +304,30 @@ export function prepareProviderRegistration(
226
304
  lastError: undefined,
227
305
  };
228
306
  }
229
- return normalizedModels;
307
+ return [...normalizedModels];
230
308
  } catch (error) {
231
309
  if (adapter.catalog && !isAbortError(error)) adapter.catalog.lastError = getErrorCode(error);
232
310
  throw error;
311
+ } finally {
312
+ registration.activeRefreshes = Math.max(0, registration.activeRefreshes - 1);
313
+ if (registration.activeRefreshes === 0 && registration.deferredRegistration) {
314
+ const deferred = registration.deferredRegistration;
315
+ registration.deferredRegistration = undefined;
316
+ queueMicrotask(deferred);
317
+ }
233
318
  }
234
319
  };
235
320
  }
236
321
  return registeredProvider;
237
322
  }
238
323
 
324
+ export function cancelDeferredProviderRegistrations(providers: readonly ProviderAdapter[]): void {
325
+ for (const adapter of providers) {
326
+ const registration = getRegistrationState(adapter);
327
+ if (registration) registration.deferredRegistration = undefined;
328
+ }
329
+ }
330
+
239
331
  export function refreshProviderRegistrations(
240
332
  pi: Pick<ExtensionAPI, "registerProvider">,
241
333
  providers: readonly ProviderAdapter[],
@@ -244,6 +336,13 @@ export function refreshProviderRegistrations(
244
336
  providerDrafts?: ReadonlyMap<ProviderAdapter, ProviderModelDraft[]>,
245
337
  ): void {
246
338
  for (const adapter of providers) {
339
+ const registration = getRegistrationState(adapter);
340
+ if (registration) registration.officialPricing = officialPricing;
341
+ if (registration && registration.activeRefreshes > 0) {
342
+ registration.deferredRegistration = () =>
343
+ registerProviderAdapter(pi, adapter, runtime, officialPricing, providerDrafts?.get(adapter));
344
+ continue;
345
+ }
247
346
  registerProviderAdapter(pi, adapter, runtime, officialPricing, providerDrafts?.get(adapter));
248
347
  }
249
348
  }
@@ -18,7 +18,16 @@ export {
18
18
  defineStatusExtension,
19
19
  defineTunerExtension,
20
20
  } from "./adapter-extensions.ts";
21
+ export { MAX_PROVIDER_MODEL_COUNT } from "./adapter-validation.ts";
22
+ export { createCatalogPreflightAdapter } from "./catalog-preflight.ts";
21
23
  export { withDeadline } from "./deadline.ts";
24
+ export {
25
+ appendBaseUrlPath,
26
+ authDefinesHeader,
27
+ getContextAuth,
28
+ hasBaseUrlOrigin,
29
+ mergeDiagnosticHeaders,
30
+ } from "./diagnostic-auth.ts";
22
31
  export { isProviderDataError, ProviderDataError } from "./errors.ts";
23
32
  export { createOpenCodeCatalogPreflightAdapter } from "./opencode-preflight.ts";
24
33
  export type {
@@ -37,6 +46,7 @@ export type {
37
46
  ProviderModel,
38
47
  ProviderModelDraft,
39
48
  ProviderRefreshContext,
49
+ ProviderRequestAuth,
40
50
  StatusAdapter,
41
51
  StatusContext,
42
52
  StatusEntry,
@@ -0,0 +1,72 @@
1
+ /** Shared parsing for OpenAI-style `x-ratelimit-*` response headers. */
2
+
3
+ export interface RateLimitWindow {
4
+ limit: number;
5
+ remaining: number;
6
+ resetAt?: number;
7
+ }
8
+
9
+ function numberValue(value: unknown): number | undefined {
10
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
11
+ if (typeof value === "string" && value.trim() !== "") {
12
+ const parsed = Number(value);
13
+ return Number.isFinite(parsed) ? parsed : undefined;
14
+ }
15
+ return undefined;
16
+ }
17
+
18
+ const DURATION_PATTERN = /^((?:\d+(?:\.\d+)?(?:ms|[smhd]))+)$/i;
19
+ const DURATION_PARTS = /(\d+(?:\.\d+)?)(ms|[smhd])/gi;
20
+ const UNIT_SECONDS: Record<string, number> = { ms: 0.001, s: 1, m: 60, h: 3_600, d: 86_400 };
21
+
22
+ export function durationSeconds(value: string): number | undefined {
23
+ const matched = DURATION_PATTERN.exec(value.trim());
24
+ if (!matched?.[1]) return undefined;
25
+ let total = 0;
26
+ for (const match of value.matchAll(DURATION_PARTS)) {
27
+ const amount = Number(match[1]);
28
+ if (!Number.isFinite(amount)) return undefined;
29
+ total += amount * (UNIT_SECONDS[match[2]!.toLowerCase()] ?? Number.NaN);
30
+ if (!Number.isFinite(total)) return undefined;
31
+ }
32
+ return total;
33
+ }
34
+
35
+ /**
36
+ * Reset hint in seconds. Providers use bare numbers, `7.66s`, and compound
37
+ * durations like Groq's `2m59.56s`; reset windows like `1d` also appear.
38
+ */
39
+ export function resetSecondsFromHeader(value: string | null): number | undefined {
40
+ if (value === null || value.trim() === "") return undefined;
41
+ const trimmed = value.trim();
42
+ const duration = durationSeconds(trimmed);
43
+ if (duration === undefined) {
44
+ const bare = numberValue(trimmed);
45
+ if (bare === undefined || bare <= 0) return undefined;
46
+ return bare;
47
+ }
48
+ if (duration <= 0) return undefined;
49
+ return duration;
50
+ }
51
+
52
+ function parseWindow(headers: Headers, kind: "requests" | "tokens", now: number): RateLimitWindow | undefined {
53
+ const limit = numberValue(headers.get(`x-ratelimit-limit-${kind}`));
54
+ const remaining = numberValue(headers.get(`x-ratelimit-remaining-${kind}`));
55
+ if (limit === undefined || remaining === undefined || limit <= 0) return undefined;
56
+ const reset = resetSecondsFromHeader(headers.get(`x-ratelimit-reset-${kind}`) ?? headers.get("x-ratelimit-reset"));
57
+ return {
58
+ limit,
59
+ remaining,
60
+ ...(reset !== undefined ? { resetAt: now + Math.round(reset * 1_000) } : {}),
61
+ };
62
+ }
63
+
64
+ export function parseRateLimitWindows(
65
+ headers: Headers,
66
+ now: number,
67
+ ): { requests?: RateLimitWindow; tokens?: RateLimitWindow } {
68
+ return {
69
+ requests: parseWindow(headers, "requests", now),
70
+ tokens: parseWindow(headers, "tokens", now),
71
+ };
72
+ }
@@ -173,6 +173,9 @@ export function resolvePiProviderDependencies(
173
173
  dependencies: Partial<PiProviderDependencies> = {},
174
174
  ): PiProviderDependencies {
175
175
  const runtime = { ...getDefaultPiProviderDependencies(), ...dependencies };
176
+ if (!Object.hasOwn(dependencies, "openRouterMetadataCachePath")) {
177
+ runtime.openRouterMetadataCachePath = getDefaultOpenRouterMetadataCachePath(runtime.agentDir);
178
+ }
176
179
  if (runtime.pricingPolicies === undefined) runtime.pricingPolicies = {};
177
180
  validatePiProviderDependencies(runtime);
178
181
  return runtime;
package/core/runtime.ts CHANGED
@@ -11,7 +11,11 @@ import {
11
11
  } from "./official-pricing.ts";
12
12
  import type { PreflightContextLike } from "./preflight-manager.ts";
13
13
  import { PreflightManager } from "./preflight-manager.ts";
14
- import { refreshProviderRegistrations, registerProviderAdapter } from "./provider-registration.ts";
14
+ import {
15
+ cancelDeferredProviderRegistrations,
16
+ refreshProviderRegistrations,
17
+ registerProviderAdapter,
18
+ } from "./provider-registration.ts";
15
19
  import type { PiProviderDependencies, PiProviderLoader } from "./runtime-config.ts";
16
20
  import { resolvePiProviderDependencies } from "./runtime-config.ts";
17
21
  import type { StatusContextLike } from "./status-manager.ts";
@@ -46,10 +50,11 @@ function readProviderCredentialMetadata(
46
50
  ): unknown {
47
51
  try {
48
52
  const credential = readStoredCredential(provider);
49
- if (credential?.type !== "oauth") return undefined;
53
+ const type = credential?.type;
54
+ if (type !== "oauth" && type !== "api_key") return undefined;
50
55
  return {
51
- type: "oauth",
52
- ...(typeof credential.teamName === "string" ? { teamName: credential.teamName } : {}),
56
+ type,
57
+ ...(type === "oauth" && typeof credential?.teamName === "string" ? { teamName: credential.teamName } : {}),
53
58
  };
54
59
  } catch {
55
60
  return undefined;
@@ -337,13 +342,19 @@ export function installPiProviderRuntime(
337
342
  const { status, preflight, auth } = getStatusDetails(model, ctx);
338
343
  let liveCheckRequested = false;
339
344
  if ((mode === "refresh" || mode === "check") && auth.configured) {
345
+ const getCredentialMetadata = () =>
346
+ readProviderCredentialMetadata(model.provider, runtime.readStoredCredential);
340
347
  const statusContext: StatusContextLike = {
341
348
  model,
342
349
  modelRegistry: ctx.modelRegistry,
343
350
  getCredentialKey: () => ctx.modelRegistry.getApiKeyForProvider(model.provider),
344
- getCredentialMetadata: () => readProviderCredentialMetadata(model.provider, runtime.readStoredCredential),
351
+ getCredentialMetadata,
352
+ };
353
+ const preflightContext: PreflightContextLike = {
354
+ model,
355
+ modelRegistry: ctx.modelRegistry,
356
+ getCredentialMetadata,
345
357
  };
346
- const preflightContext: PreflightContextLike = { model, modelRegistry: ctx.modelRegistry };
347
358
  const refreshChecks: Array<Promise<unknown>> = [];
348
359
  if (status) refreshChecks.push(statusManager.update(statusContext, { force: true }));
349
360
  if (preflight) refreshChecks.push(preflightManager.update(preflightContext, { force: true }));
@@ -385,6 +396,7 @@ export function installPiProviderRuntime(
385
396
  lifecycleGeneration++;
386
397
  statusPresentationGeneration++;
387
398
  statusPresentationVisible = false;
399
+ cancelDeferredProviderRegistrations(providers);
388
400
  statusManager.cancelAll();
389
401
  statusManager.clear();
390
402
  preflightManager.cancelAll();
@@ -437,43 +449,52 @@ export function createPiProviderRuntime(
437
449
  ): (pi: ExtensionAPI) => Promise<void> {
438
450
  const runtime = resolvePiProviderDependencies(dependencies);
439
451
  return async (pi) => {
440
- let latestBackgroundPricing: Record<string, OfficialModelMeta> | undefined;
441
- let installedDefinition: PiProviderDefinition | undefined;
442
452
  let installedController: PiProviderRuntimeController | undefined;
443
453
  let disposed = false;
444
- const onBackgroundRefresh = (snapshot: Record<string, OfficialModelMeta>): void => {
445
- latestBackgroundPricing = snapshot;
446
- if (disposed) return;
447
- installedController?.updateOfficialPricing?.(snapshot);
448
- if (installedDefinition === undefined) return;
449
- refreshProviderRegistrations(pi, installedDefinition.providers, runtime, snapshot);
450
- };
454
+ let pricingRefreshController: AbortController | undefined;
455
+ const cachePath =
456
+ runtime.officialPricingUrl === OPENROUTER_MODELS_URL ? runtime.openRouterMetadataCachePath : undefined;
457
+ const fetchPricing = (options: { allowNetwork?: boolean; signal?: AbortSignal } = {}) =>
458
+ fetchOfficialPricing(
459
+ runtime.fetch,
460
+ runtime.officialPricingUrl,
461
+ runtime.officialPricingTimeoutMs,
462
+ runtime.officialPricingCacheTtlMs,
463
+ runtime.officialPricingMaxStaleMs,
464
+ runtime.now,
465
+ { cachePath, ...options },
466
+ );
451
467
  const officialPricingPromise = runtime.enableOfficialPricingFallback
452
- ? fetchOfficialPricing(
453
- runtime.fetch,
454
- runtime.officialPricingUrl,
455
- runtime.officialPricingTimeoutMs,
456
- runtime.officialPricingCacheTtlMs,
457
- runtime.officialPricingMaxStaleMs,
458
- runtime.now,
459
- {
460
- cachePath:
461
- runtime.officialPricingUrl === OPENROUTER_MODELS_URL
462
- ? runtime.openRouterMetadataCachePath
463
- : undefined,
464
- background: runtime.officialPricingUrl === OPENROUTER_MODELS_URL,
465
- onBackgroundRefresh: onBackgroundRefresh,
466
- },
467
- )
468
+ ? fetchPricing({ allowNetwork: false })
468
469
  : Promise.resolve({});
469
470
  const definitionPromise = loadDefinition(runtime);
470
471
  const [officialPricing, definition] = await Promise.all([officialPricingPromise, definitionPromise]);
471
472
  validatePiProviderDefinition(definition);
472
- installedController = installPiProviderRuntime(pi, runtime, definition, officialPricing);
473
- installedDefinition = definition;
474
- if (latestBackgroundPricing !== undefined) onBackgroundRefresh(latestBackgroundPricing);
473
+
474
+ pi.on("session_start", () => {
475
+ pricingRefreshController?.abort();
476
+ pricingRefreshController = undefined;
477
+ if (!runtime.enableOfficialPricingFallback || disposed) {
478
+ return;
479
+ }
480
+ const controller = new AbortController();
481
+ pricingRefreshController = controller;
482
+ void fetchPricing({ signal: controller.signal })
483
+ .then((snapshot) => {
484
+ if (disposed || controller.signal.aborted) return;
485
+ installedController?.updateOfficialPricing?.(snapshot);
486
+ refreshProviderRegistrations(pi, definition.providers, runtime, snapshot);
487
+ })
488
+ .catch(() => undefined)
489
+ .finally(() => {
490
+ if (pricingRefreshController === controller) pricingRefreshController = undefined;
491
+ });
492
+ });
475
493
  pi.on("session_shutdown", () => {
476
494
  disposed = true;
495
+ pricingRefreshController?.abort();
496
+ pricingRefreshController = undefined;
477
497
  });
498
+ installedController = installPiProviderRuntime(pi, runtime, definition, officialPricing);
478
499
  };
479
500
  }
@@ -1,4 +1,7 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { deriveCredentialType } from "./credential-type.ts";
1
3
  import { isValidTimeoutMs, withDeadline } from "./deadline.ts";
4
+ import { applyDiagnosticBaseUrl, type DiagnosticModelRegistry, resolveDiagnosticAuth } from "./diagnostic-auth.ts";
2
5
  import { isProviderDataError, ProviderDataError } from "./errors.ts";
3
6
  import type {
4
7
  StatusAdapter,
@@ -9,11 +12,11 @@ import type {
9
12
  StatusWindowEntry,
10
13
  } from "./types.ts";
11
14
 
15
+ type StatusModel = NonNullable<ExtensionContext["model"]>;
16
+
12
17
  export interface StatusContextLike {
13
- model?: { provider?: string };
14
- modelRegistry: {
15
- getApiKeyForProvider(provider: string): Promise<string | undefined>;
16
- };
18
+ model?: { provider?: string; id?: string; baseUrl?: string };
19
+ modelRegistry: DiagnosticModelRegistry;
17
20
  /** Optional credential identity used to isolate cached account data. */
18
21
  getCredentialKey?: () => Promise<string | undefined>;
19
22
  /** Optional non-secret credential metadata for provider-specific account labels. */
@@ -222,14 +225,31 @@ export class StatusManager {
222
225
  const cancellation = new AbortController();
223
226
  const generation = ++state.generation;
224
227
  const promise = withDeadline(
225
- (signal) =>
226
- adapter.fetch({
228
+ async (signal) => {
229
+ const sourceModel = ctx.model as StatusModel;
230
+ const auth = await resolveDiagnosticAuth(sourceModel, ctx.modelRegistry);
231
+ const model = applyDiagnosticBaseUrl(sourceModel, auth);
232
+ if (adapter.supportsModel && !adapter.supportsModel(model)) {
233
+ throw new ProviderDataError(
234
+ "Status endpoint is unavailable for the effective model URL",
235
+ "unsupported",
236
+ );
237
+ }
238
+ return await adapter.fetch({
227
239
  fetch: this.fetchFn,
228
- getApiKey: () => ctx.modelRegistry.getApiKeyForProvider(adapter.providerId),
229
- ...(ctx.getCredentialMetadata ? { getCredentialMetadata: ctx.getCredentialMetadata } : {}),
240
+ getApiKey: async () => auth.apiKey,
241
+ getAuth: async () => auth,
242
+ model,
243
+ ...(ctx.getCredentialMetadata === undefined
244
+ ? {}
245
+ : {
246
+ getCredentialMetadata: ctx.getCredentialMetadata,
247
+ getCredentialType: async () => deriveCredentialType(ctx.getCredentialMetadata?.()),
248
+ }),
230
249
  now: this.now,
231
250
  signal,
232
- }),
251
+ });
252
+ },
233
253
  adapter.requestTimeoutMs,
234
254
  cancellation.signal,
235
255
  );
@@ -256,6 +276,11 @@ export class StatusManager {
256
276
  } catch (error) {
257
277
  if (state.generation !== generation || isErrorNamed(error, "AbortError")) return "skipped";
258
278
  const dataError = isProviderDataError(error) ? error : undefined;
279
+ if (dataError?.code === "unsupported") {
280
+ state.snapshot = undefined;
281
+ state.lastError = undefined;
282
+ return "skipped";
283
+ }
259
284
  const code = dataError?.code ?? (isErrorNamed(error, "TimeoutError") ? "timeout" : "fetch");
260
285
  const retryAt =
261
286
  dataError?.retryAt !== undefined && Number.isFinite(dataError.retryAt) ? dataError.retryAt : undefined;
package/core/types.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  ProviderConfig,
5
5
  ProviderModelConfig,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import type { OfficialModelMeta } from "./official-pricing.ts";
7
8
 
8
9
  export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
9
10
 
@@ -32,6 +33,14 @@ export interface StoredCredentialLike {
32
33
  readonly teamName?: string;
33
34
  }
34
35
 
36
+ /** Model-scoped authentication resolved by Pi for one diagnostic request. */
37
+ export interface ProviderRequestAuth {
38
+ apiKey?: string;
39
+ headers?: Record<string, string | null>;
40
+ baseUrl?: string;
41
+ env?: Record<string, string>;
42
+ }
43
+
35
44
  export interface ModelFieldSources {
36
45
  contextWindow?: ModelFieldSource;
37
46
  maxTokens?: ModelFieldSource;
@@ -145,8 +154,14 @@ export interface StatusSnapshot {
145
154
  export interface StatusContext {
146
155
  fetch: typeof globalThis.fetch;
147
156
  getApiKey: () => Promise<string | undefined>;
157
+ /** Complete model-scoped request authentication resolved by Pi. */
158
+ getAuth?: () => Promise<ProviderRequestAuth>;
159
+ /** Effective model, including any credential-specific base URL. */
160
+ model?: ActiveModel;
148
161
  /** Optional non-secret credential metadata for provider-specific account labels. */
149
162
  getCredentialMetadata?: () => unknown;
163
+ /** Optional non-secret credential type ("oauth" vs "api_key") for providers with dual auth modes. */
164
+ getCredentialType?: () => Promise<string | undefined>;
150
165
  signal?: AbortSignal;
151
166
  now: () => number;
152
167
  }
@@ -157,6 +172,8 @@ export interface StatusAdapter {
157
172
  name: string;
158
173
  cacheTtlMs: number;
159
174
  requestTimeoutMs: number;
175
+ /** Return false when this account endpoint cannot safely serve the effective model URL. */
176
+ supportsModel?: (model: ActiveModel) => boolean;
160
177
  fetch(context: StatusContext): Promise<StatusSnapshot>;
161
178
  }
162
179
 
@@ -177,8 +194,11 @@ export interface ProviderAdapter {
177
194
  /** @internal Draft state shared across isolated Adapter and Host contexts. */
178
195
  registration?: {
179
196
  modelDrafts: ProviderModelDraft[];
180
- normalizedModels?: ProviderModel[];
181
- modelMetadata?: Record<string, ProviderModelMetadata>;
197
+ normalizedModels: ProviderModel[];
198
+ modelMetadata: Record<string, ProviderModelMetadata>;
199
+ officialPricing: Record<string, OfficialModelMeta>;
200
+ activeRefreshes: number;
201
+ deferredRegistration?: () => void;
182
202
  };
183
203
  }
184
204