@hyav/pi-provider 0.1.6 → 0.1.8

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,22 @@
2
2
 
3
3
  This file is the authoritative user-facing release history for `@hyav/pi-provider`.
4
4
 
5
+ ## 0.1.8 - 2026-09-01
6
+
7
+ - Make Charm Hyper model catalogs online-only: use the last successful online snapshot when refresh is unavailable and an empty catalog when no snapshot exists; remove package-maintained static model, pricing, and model-specific capability fallbacks.
8
+ - Skip malformed individual Charm Hyper models while retaining valid entries, and use Provider metadata before OpenRouter metadata when filling model fields.
9
+ - Add a public model-catalog lifecycle helper for cached snapshot restoration, TTL checks, generation-guarded publication, persistence fallback, complete live replacement, and failure retention; migrate Charm Hyper to it.
10
+ - Share one model-catalog discovery request across concurrent callers with different cancellation signals, while keeping caller cancellation and generation-guarded publication independent.
11
+ - Separate successful-catalog TTL from exponential failure backoff, expose attempt, success, failure-count, and retry diagnostics, and show retry timing in `/status`.
12
+ - Report bounded invalid and duplicate model counts for accepted online catalogs without retaining remote model IDs or payload content.
13
+ - Add accurately named OpenRouter metadata APIs (`fetchOfficialModelMetadata()` and `applyOfficialModelMetadata()`), migrate internal callers, and retain the pricing-named APIs as deprecated compatibility wrappers.
14
+ - Extend field-level provenance to normalized cost and thinking-level maps while keeping `pricing.source` authoritative for known and effective pricing.
15
+ - Normalize partial model costs before applying pricing adjustments so registered model costs and pricing sidecars remain consistent; report fields rewritten at the registration boundary as `normalized`.
16
+
17
+ ## 0.1.7 - 2026-08-24
18
+
19
+ - Refresh the active model catalog dynamically on `/status refresh` and `/status check` by delegating to Pi's model registry with forced network revalidation, keeping the displayed model catalog and model counts up to date without requiring `/reload`.
20
+
5
21
  ## 0.1.6 - 2026-08-24
6
22
 
7
23
  - Prevent Charm Hyper model-refresh warnings when no credentials are configured by registering OAuth-capable Providers without unresolved optional environment API keys and clearing stale API-key configuration after `/reload`; configured environment and stored API keys remain supported.
package/README.md CHANGED
@@ -10,7 +10,7 @@ A provider extension toolkit for [Pi](https://pi.dev). It registers LLM provider
10
10
 
11
11
  - One Pi Provider Host for registration, status, preflight checks, live checks, and request tuners
12
12
  - Provider, status, preflight, and tuner Adapter files discovered by one Pi entrypoint on `/reload`
13
- - Resilient model catalogs with cached fallback, bounded background refresh, and failure retention
13
+ - Resilient model catalogs with cached online snapshots, bounded background refresh, and failure retention
14
14
  - Provider-first pricing metadata with optional OpenRouter completion and quality indicators
15
15
  - Explicit diagnostics: cached `/status`, free `/status refresh`, and potentially billable `/status check`
16
16
  - Built-in integrations for Charm Hyper, DeepSeek, Google Gemini, OpenAI Codex, OpenCode Zen, and OpenCode Go
@@ -45,7 +45,7 @@ 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.
48
+ A dynamic Provider whose API key references environment variables keeps its last successful online catalog snapshot and skips network catalog refreshes until those variables or a stored credential are available. Providers without a successful snapshot expose an empty catalog rather than inventing models. This prevents unconfigured Providers from surfacing model-refresh warnings.
49
49
 
50
50
  ## Common configuration
51
51
 
package/README.zh-CN.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  - 由一个 Pi Provider Host 统一负责注册、Status、Preflight、实时检查和请求 Tuner
12
12
  - 由单一 Pi 入口在 `/reload` 时发现 Provider、Status、Preflight 和 Tuner Adapter 文件
13
- - 通过缓存回退、有界后台刷新和失败保留提供可靠的模型目录
13
+ - 通过缓存在线快照、有界后台刷新和失败保留提供可靠的模型目录
14
14
  - 优先采用 Provider 价格元数据,并可由 OpenRouter 补全价格和质量指标
15
15
  - 显式诊断:缓存 `/status`、免费 `/status refresh` 和可能计费的 `/status check`
16
16
  - 内置 Charm Hyper、DeepSeek、Google Gemini、OpenAI Codex、OpenCode Zen 和 OpenCode Go 集成
@@ -45,7 +45,7 @@ pi install npm:@hyav/pi-provider
45
45
 
46
46
  使用 `/status refresh` 执行免费的端点、鉴权、目录和账户检查。只有明确接受一次真实模型请求及其可能产生的用量费用时,才使用 `/status check`。
47
47
 
48
- 动态 Provider 的 API Key 引用环境变量时,如果这些变量和已存储凭据均未配置,将保留缓存或回退模型目录并跳过网络刷新,避免未配置的 Provider 产生模型目录刷新警告。
48
+ 动态 Provider 的 API Key 引用环境变量时,如果这些变量和已存储凭据均未配置,将保留最近一次成功获取的在线目录快照;如果从未成功获取过在线目录,则使用空目录,并跳过网络刷新,避免未配置的 Provider 产生模型目录刷新警告。
49
49
 
50
50
  ## 常用配置
51
51
 
@@ -147,7 +147,9 @@ export function validateProviderAdapter(adapter: unknown): asserts adapter is Pr
147
147
  if (
148
148
  adapter.catalog.source !== "static" &&
149
149
  adapter.catalog.source !== "live" &&
150
- adapter.catalog.source !== "fallback"
150
+ adapter.catalog.source !== "cached" &&
151
+ adapter.catalog.source !== "fallback" &&
152
+ adapter.catalog.source !== "empty"
151
153
  ) {
152
154
  throw new Error(`Provider ${adapter.id} has invalid catalog source`);
153
155
  }
@@ -160,6 +162,17 @@ export function validateProviderAdapter(adapter: unknown): asserts adapter is Pr
160
162
  }
161
163
  if (adapter.catalog.updatedAt !== undefined)
162
164
  assertFiniteNonNegative(adapter.catalog.updatedAt, "Catalog updatedAt");
165
+ for (const field of ["lastSuccessfulRefreshAt", "lastAttemptAt", "nextRetryAt"] as const) {
166
+ if (adapter.catalog[field] !== undefined) {
167
+ assertFiniteNonNegative(adapter.catalog[field], `Catalog ${field}`);
168
+ }
169
+ }
170
+ for (const field of ["consecutiveFailures", "rejectedCount", "duplicateCount"] as const) {
171
+ const count = adapter.catalog[field];
172
+ if (count !== undefined && (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)) {
173
+ throw new Error(`Provider ${adapter.id} has invalid catalog ${field}`);
174
+ }
175
+ }
163
176
  if (adapter.catalog.lastError !== undefined && !isSafeText(adapter.catalog.lastError)) {
164
177
  throw new Error(`Provider ${adapter.id} has invalid catalog error`);
165
178
  }
package/core/host.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  type PiProviderRuntimeController,
18
18
  prepareProviderRegistration,
19
19
  } from "./extension.ts";
20
- import { fetchOfficialPricing, type OfficialModelMeta, OPENROUTER_MODELS_URL } from "./official-pricing.ts";
20
+ import { fetchOfficialModelMetadata, type OfficialModelMeta, OPENROUTER_MODELS_URL } from "./official-pricing.ts";
21
21
  import type { PreflightAdapter } from "./preflight-manager.ts";
22
22
  import { refreshProviderRegistrations } from "./provider-registration.ts";
23
23
  import { scheduleModelCatalogRefresh } from "./runtime.ts";
@@ -86,7 +86,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
86
86
  refreshProviderRegistrations(pi, installedDefinition.definition.providers, runtime, snapshot);
87
87
  };
88
88
  const officialPricing = runtime.enableOfficialPricingFallback
89
- ? fetchOfficialPricingForHost(runtime, { allowNetwork: false })
89
+ ? fetchOfficialModelMetadataForHost(runtime, { allowNetwork: false })
90
90
  : Promise.resolve({});
91
91
  const bridge: StartupBridge = { dependencies: runtime, officialPricing };
92
92
 
@@ -98,7 +98,7 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
98
98
  }
99
99
  const controller = new AbortController();
100
100
  pricingRefreshController = controller;
101
- void fetchOfficialPricingForHost(runtime, { signal: controller.signal })
101
+ void fetchOfficialModelMetadataForHost(runtime, { signal: controller.signal })
102
102
  .then((snapshot) => {
103
103
  if (controller.signal.aborted || disposed) return;
104
104
  onBackgroundRefresh(snapshot);
@@ -465,11 +465,11 @@ export function createPiProviderHost(dependencies: Partial<PiProviderDependencie
465
465
  };
466
466
  }
467
467
 
468
- function fetchOfficialPricingForHost(
468
+ function fetchOfficialModelMetadataForHost(
469
469
  runtime: PiProviderDependencies,
470
470
  options: { allowNetwork?: boolean; signal?: AbortSignal } = {},
471
471
  ) {
472
- return fetchOfficialPricing(
472
+ return fetchOfficialModelMetadata(
473
473
  runtime.fetch,
474
474
  runtime.officialPricingUrl,
475
475
  runtime.officialPricingTimeoutMs,
@@ -0,0 +1,277 @@
1
+ import type { ModelCatalogSource, ModelCatalogStatus, ProviderModelDraft, ProviderRefreshContext } from "./types.ts";
2
+
3
+ export interface ModelCatalogDiagnostics {
4
+ rejectedCount?: number;
5
+ duplicateCount?: number;
6
+ }
7
+
8
+ export interface ModelCatalogDiscoveryResult {
9
+ models: ProviderModelDraft[];
10
+ diagnostics?: ModelCatalogDiagnostics;
11
+ }
12
+
13
+ export interface ModelCatalogLifecycleOptions {
14
+ initialModels?: ProviderModelDraft[];
15
+ initialSource?: ModelCatalogSource;
16
+ ttlMs: number;
17
+ failureBackoffMs?: number;
18
+ maxFailureBackoffMs?: number;
19
+ now?: () => number;
20
+ discover(
21
+ context: ProviderRefreshContext,
22
+ ): Promise<ProviderModelDraft[] | ModelCatalogDiscoveryResult> | ProviderModelDraft[] | ModelCatalogDiscoveryResult;
23
+ restore(stored: ProviderRefreshContext["stored"]): ProviderModelDraft[] | undefined;
24
+ persist(models: ProviderModelDraft[], checkedAt: number): NonNullable<ProviderRefreshContext["stored"]>;
25
+ onUpdate(models: ProviderModelDraft[]): void;
26
+ errorCode(error: unknown): string;
27
+ }
28
+
29
+ export interface ModelCatalogLifecycle {
30
+ catalog: ModelCatalogStatus;
31
+ getModels(): ProviderModelDraft[];
32
+ refreshModels(context: ProviderRefreshContext): Promise<ProviderModelDraft[]>;
33
+ }
34
+
35
+ function isAbortError(error: unknown): boolean {
36
+ return error !== null && typeof error === "object" && "name" in error && error.name === "AbortError";
37
+ }
38
+
39
+ function isValidTimestamp(value: unknown): value is number {
40
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
41
+ }
42
+
43
+ async function publish(
44
+ context: ProviderRefreshContext,
45
+ update: () => void,
46
+ persist?: NonNullable<ProviderRefreshContext["stored"]>,
47
+ ): Promise<boolean> {
48
+ try {
49
+ return await context.publish({ ...(persist ? { persist } : {}), update });
50
+ } catch {
51
+ if (!persist) return false;
52
+ // Persistence is an optimization. Retry the generation-checked update without it.
53
+ try {
54
+ return await context.publish({ update });
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ }
60
+
61
+ function abortReason(signal: AbortSignal): unknown {
62
+ return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
63
+ }
64
+
65
+ function waitForCaller<T>(request: Promise<T>, signal: AbortSignal): Promise<T> {
66
+ if (signal.aborted) return Promise.reject(abortReason(signal));
67
+ return new Promise<T>((resolve, reject) => {
68
+ const onAbort = () => reject(abortReason(signal));
69
+ signal.addEventListener("abort", onAbort, { once: true });
70
+ void request.then(
71
+ (value) => {
72
+ signal.removeEventListener("abort", onAbort);
73
+ resolve(value);
74
+ },
75
+ (error) => {
76
+ signal.removeEventListener("abort", onAbort);
77
+ reject(error);
78
+ },
79
+ );
80
+ });
81
+ }
82
+
83
+ const DEFAULT_FAILURE_BACKOFF_MS = 30_000;
84
+ const DEFAULT_MAX_FAILURE_BACKOFF_MS = 15 * 60_000;
85
+
86
+ function finiteNonNegative(value: number | undefined, fallback: number, label: string): number {
87
+ const resolved = value ?? fallback;
88
+ if (!Number.isFinite(resolved) || resolved < 0)
89
+ throw new RangeError(`${label} must be a finite non-negative number`);
90
+ return resolved;
91
+ }
92
+
93
+ interface NormalizedCatalogDiscoveryResult {
94
+ models: ProviderModelDraft[];
95
+ diagnostics: Required<ModelCatalogDiagnostics>;
96
+ }
97
+
98
+ function diagnosticCount(value: number | undefined, label: string): number {
99
+ const count = value ?? 0;
100
+ if (!Number.isSafeInteger(count) || count < 0) {
101
+ throw new RangeError(`Model catalog ${label} must be a non-negative safe integer`);
102
+ }
103
+ return count;
104
+ }
105
+
106
+ function normalizeDiscoveryResult(
107
+ result: ProviderModelDraft[] | ModelCatalogDiscoveryResult,
108
+ ): NormalizedCatalogDiscoveryResult {
109
+ const models = Array.isArray(result) ? result : result.models;
110
+ const diagnostics = Array.isArray(result) ? undefined : result.diagnostics;
111
+ return {
112
+ models,
113
+ diagnostics: {
114
+ rejectedCount: diagnosticCount(diagnostics?.rejectedCount, "rejected count"),
115
+ duplicateCount: diagnosticCount(diagnostics?.duplicateCount, "duplicate count"),
116
+ },
117
+ };
118
+ }
119
+
120
+ interface ActiveCatalogRefresh {
121
+ request: Promise<NormalizedCatalogDiscoveryResult>;
122
+ publication: Promise<void>;
123
+ waiters: number;
124
+ settled: boolean;
125
+ applied: boolean;
126
+ failureRecorded: boolean;
127
+ }
128
+
129
+ export function createModelCatalogLifecycle(options: ModelCatalogLifecycleOptions): ModelCatalogLifecycle {
130
+ const now = options.now ?? Date.now;
131
+ const failureBackoffMs = finiteNonNegative(
132
+ options.failureBackoffMs,
133
+ DEFAULT_FAILURE_BACKOFF_MS,
134
+ "Model catalog failure backoff",
135
+ );
136
+ const maxFailureBackoffMs = finiteNonNegative(
137
+ options.maxFailureBackoffMs,
138
+ DEFAULT_MAX_FAILURE_BACKOFF_MS,
139
+ "Model catalog maximum failure backoff",
140
+ );
141
+ if (maxFailureBackoffMs < failureBackoffMs) {
142
+ throw new RangeError("Model catalog maximum failure backoff must not be less than its initial backoff");
143
+ }
144
+ let models = [...(options.initialModels ?? [])];
145
+ let lastCatalogUpdatedAt: number | undefined;
146
+ let inFlight: ActiveCatalogRefresh | undefined;
147
+ const catalog: ModelCatalogStatus = {
148
+ source: options.initialSource ?? (models.length > 0 ? "static" : "empty"),
149
+ modelCount: models.length,
150
+ consecutiveFailures: 0,
151
+ };
152
+
153
+ const applyModels = (
154
+ nextModels: ProviderModelDraft[],
155
+ source: ModelCatalogSource,
156
+ updatedAt?: number,
157
+ diagnostics: Required<ModelCatalogDiagnostics> = { rejectedCount: 0, duplicateCount: 0 },
158
+ ) => {
159
+ models = [...nextModels];
160
+ options.onUpdate(models);
161
+ catalog.source = source;
162
+ catalog.modelCount = models.length;
163
+ catalog.rejectedCount = diagnostics.rejectedCount;
164
+ catalog.duplicateCount = diagnostics.duplicateCount;
165
+ catalog.lastError = undefined;
166
+ catalog.consecutiveFailures = 0;
167
+ catalog.nextRetryAt = undefined;
168
+ if (updatedAt !== undefined) {
169
+ catalog.updatedAt = updatedAt;
170
+ catalog.lastSuccessfulRefreshAt = updatedAt;
171
+ lastCatalogUpdatedAt = updatedAt;
172
+ }
173
+ };
174
+
175
+ const restoreStored = async (context: ProviderRefreshContext): Promise<void> => {
176
+ const restored = options.restore(context.stored);
177
+ if (!restored) return;
178
+ const checkedAt = isValidTimestamp(context.stored?.checkedAt) ? context.stored.checkedAt : undefined;
179
+ if (lastCatalogUpdatedAt !== undefined && (checkedAt === undefined || checkedAt <= lastCatalogUpdatedAt)) return;
180
+ await publish(context, () => {
181
+ applyModels(restored, "cached", checkedAt);
182
+ });
183
+ };
184
+
185
+ const clearSettledRefresh = (active: ActiveCatalogRefresh) => {
186
+ if (inFlight === active && active.settled && active.waiters === 0) inFlight = undefined;
187
+ };
188
+
189
+ const recordFailure = (active: ActiveCatalogRefresh, error: unknown) => {
190
+ if (active.failureRecorded || isAbortError(error)) return;
191
+ active.failureRecorded = true;
192
+ const failedAt = now();
193
+ const consecutiveFailures = (catalog.consecutiveFailures ?? 0) + 1;
194
+ const multiplier = 2 ** Math.min(30, consecutiveFailures - 1);
195
+ catalog.consecutiveFailures = consecutiveFailures;
196
+ catalog.nextRetryAt = failedAt + Math.min(maxFailureBackoffMs, failureBackoffMs * multiplier);
197
+ catalog.lastError = options.errorCode(error);
198
+ };
199
+
200
+ const startRefresh = (context: ProviderRefreshContext): ActiveCatalogRefresh => {
201
+ const active: ActiveCatalogRefresh = {
202
+ request: Promise.resolve({
203
+ models: [],
204
+ diagnostics: { rejectedCount: 0, duplicateCount: 0 },
205
+ }),
206
+ publication: Promise.resolve(),
207
+ waiters: 0,
208
+ settled: false,
209
+ applied: false,
210
+ failureRecorded: false,
211
+ };
212
+ const sharedContext = { ...context, signal: new AbortController().signal };
213
+ catalog.lastAttemptAt = now();
214
+ active.request = Promise.resolve()
215
+ .then(() => options.discover(sharedContext))
216
+ .then(normalizeDiscoveryResult)
217
+ .catch((error: unknown) => {
218
+ recordFailure(active, error);
219
+ throw error;
220
+ })
221
+ .finally(() => {
222
+ active.settled = true;
223
+ clearSettledRefresh(active);
224
+ });
225
+ inFlight = active;
226
+ return active;
227
+ };
228
+
229
+ const refreshModels = async (context: ProviderRefreshContext): Promise<ProviderModelDraft[]> => {
230
+ await restoreStored(context);
231
+ if (context.allowNetwork !== true || context.signal.aborted) return [...models];
232
+
233
+ const currentTime = now();
234
+ const isFresh =
235
+ catalog.lastSuccessfulRefreshAt !== undefined &&
236
+ Math.max(0, currentTime - catalog.lastSuccessfulRefreshAt) <= Math.max(0, options.ttlMs);
237
+ if (!context.force && catalog.lastError === undefined && isFresh) return [...models];
238
+ if (
239
+ !context.force &&
240
+ inFlight === undefined &&
241
+ catalog.nextRetryAt !== undefined &&
242
+ currentTime < catalog.nextRetryAt
243
+ ) {
244
+ return [...models];
245
+ }
246
+
247
+ const active = inFlight ?? startRefresh(context);
248
+ active.waiters++;
249
+ try {
250
+ const discovered = await waitForCaller(active.request, context.signal);
251
+ const attempt = active.publication.then(async () => {
252
+ if (active.applied || context.signal.aborted) return;
253
+ const updatedAt = now();
254
+ await publish(
255
+ context,
256
+ () => {
257
+ applyModels(discovered.models, "live", updatedAt, discovered.diagnostics);
258
+ active.applied = true;
259
+ },
260
+ options.persist(discovered.models, updatedAt),
261
+ );
262
+ });
263
+ active.publication = attempt.catch(() => undefined);
264
+ await waitForCaller(attempt, context.signal);
265
+ return [...models];
266
+ } catch (error) {
267
+ if (!context.signal.aborted) recordFailure(active, error);
268
+ throw error;
269
+ } finally {
270
+ active.waiters = Math.max(0, active.waiters - 1);
271
+ clearSettledRefresh(active);
272
+ }
273
+ };
274
+
275
+ options.onUpdate(models);
276
+ return { catalog, getModels: () => [...models], refreshModels };
277
+ }
@@ -41,7 +41,7 @@ export interface OfficialModelMeta {
41
41
  };
42
42
  }
43
43
 
44
- export interface OfficialPricingFetchOptions {
44
+ export interface OfficialModelMetadataFetchOptions {
45
45
  /** Optional persistent cache file. Omit for process-only caching (for example, in unit tests). */
46
46
  cachePath?: string;
47
47
  /** Return the current snapshot immediately and refresh an expired/missing cache in the background. */
@@ -54,6 +54,9 @@ export interface OfficialPricingFetchOptions {
54
54
  signal?: AbortSignal;
55
55
  }
56
56
 
57
+ /** @deprecated Use OfficialModelMetadataFetchOptions. */
58
+ export type OfficialPricingFetchOptions = OfficialModelMetadataFetchOptions;
59
+
57
60
  interface PricingCacheEntry {
58
61
  snapshot: Record<string, OfficialModelMeta>;
59
62
  updatedAt: number;
@@ -659,14 +662,14 @@ function observeBackgroundRefresh(
659
662
  );
660
663
  }
661
664
 
662
- export async function fetchOfficialPricing(
665
+ export async function fetchOfficialModelMetadata(
663
666
  fetchFn: typeof globalThis.fetch,
664
667
  pricingUrl = OPENROUTER_MODELS_URL,
665
668
  timeoutMs = 3_000,
666
669
  cacheTtlMs = DEFAULT_PRICING_CACHE_TTL_MS,
667
670
  maxStaleMs = DEFAULT_PRICING_MAX_STALE_MS,
668
671
  now: () => number = Date.now,
669
- options: OfficialPricingFetchOptions = {},
672
+ options: OfficialModelMetadataFetchOptions = {},
670
673
  ): Promise<Record<string, OfficialModelMeta>> {
671
674
  const currentTime = now();
672
675
  const cachedAge = getPricingCacheAge(pricingUrl, currentTime);
@@ -715,6 +718,19 @@ export async function fetchOfficialPricing(
715
718
  return request;
716
719
  }
717
720
 
721
+ /** @deprecated Use fetchOfficialModelMetadata. */
722
+ export function fetchOfficialPricing(
723
+ fetchFn: typeof globalThis.fetch,
724
+ pricingUrl = OPENROUTER_MODELS_URL,
725
+ timeoutMs = 3_000,
726
+ cacheTtlMs = DEFAULT_PRICING_CACHE_TTL_MS,
727
+ maxStaleMs = DEFAULT_PRICING_MAX_STALE_MS,
728
+ now: () => number = Date.now,
729
+ options: OfficialPricingFetchOptions = {},
730
+ ): Promise<Record<string, OfficialModelMeta>> {
731
+ return fetchOfficialModelMetadata(fetchFn, pricingUrl, timeoutMs, cacheTtlMs, maxStaleMs, now, options);
732
+ }
733
+
718
734
  export function findOfficialCost(
719
735
  modelId: string,
720
736
  dynamicPricing: Record<string, OfficialModelMeta | ProviderCost> = {},
@@ -868,12 +884,12 @@ export function findOfficialMeta(
868
884
  return result;
869
885
  }
870
886
 
871
- export function applyOfficialModelCosts(
887
+ export function applyOfficialModelMetadata(
872
888
  models: ProviderModelDraft[],
873
- dynamicPricing: Record<string, OfficialModelMeta | ProviderCost> = {},
889
+ dynamicMetadata: Record<string, OfficialModelMeta | ProviderCost> = {},
874
890
  ): ProviderModelDraft[] {
875
891
  return models.map((model) => {
876
- const meta = findOfficialMeta(model.id, dynamicPricing);
892
+ const meta = findOfficialMeta(model.id, dynamicMetadata);
877
893
  if (!meta) return model;
878
894
 
879
895
  const useOfficialCost =
@@ -897,3 +913,11 @@ export function applyOfficialModelCosts(
897
913
  return merged;
898
914
  });
899
915
  }
916
+
917
+ /** @deprecated Use applyOfficialModelMetadata. */
918
+ export function applyOfficialModelCosts(
919
+ models: ProviderModelDraft[],
920
+ dynamicPricing: Record<string, OfficialModelMeta | ProviderCost> = {},
921
+ ): ProviderModelDraft[] {
922
+ return applyOfficialModelMetadata(models, dynamicPricing);
923
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
2
2
  import { validateProviderModelDrafts } from "./adapter-validation.ts";
3
- import { applyOfficialModelCosts, findOfficialMeta, type OfficialModelMeta } from "./official-pricing.ts";
3
+ import { applyOfficialModelMetadata, findOfficialMeta, type OfficialModelMeta } from "./official-pricing.ts";
4
4
  import { resolvePricingDetails } from "./pricing-adjustments.ts";
5
5
  import type { PiProviderDependencies } from "./runtime-config.ts";
6
6
  import type {
@@ -114,6 +114,60 @@ function selectPricingAdjustment(
114
114
  return model.pricingAdjustment ?? policy?.models?.[model.id.trim()] ?? policy?.defaultAdjustment;
115
115
  }
116
116
 
117
+ function costsEqual(left: ProviderModelDraft["cost"], right: ProviderCost): boolean {
118
+ if (left === undefined) return false;
119
+ const candidate = left as Partial<ProviderCost>;
120
+ if (
121
+ candidate.input !== right.input ||
122
+ candidate.output !== right.output ||
123
+ candidate.cacheRead !== right.cacheRead ||
124
+ candidate.cacheWrite !== right.cacheWrite
125
+ ) {
126
+ return false;
127
+ }
128
+ const leftTiers = candidate.tiers ?? [];
129
+ const rightTiers = right.tiers ?? [];
130
+ return (
131
+ leftTiers.length === rightTiers.length &&
132
+ leftTiers.every((tier, index) => {
133
+ const normalized = rightTiers[index];
134
+ return (
135
+ normalized !== undefined &&
136
+ tier.inputTokensAbove === normalized.inputTokensAbove &&
137
+ tier.input === normalized.input &&
138
+ tier.output === normalized.output &&
139
+ tier.cacheRead === normalized.cacheRead &&
140
+ tier.cacheWrite === normalized.cacheWrite
141
+ );
142
+ })
143
+ );
144
+ }
145
+
146
+ function inputsEqual(left: ProviderModelDraft["input"], right: ProviderModel["input"]): boolean {
147
+ return Array.isArray(left) && left.length === right.length && left.every((value, index) => value === right[index]);
148
+ }
149
+
150
+ function getDraftCostSource(
151
+ draft: ProviderModelDraft | undefined,
152
+ officialMeta: OfficialModelMeta | undefined,
153
+ normalizedCost: ProviderCost,
154
+ ): ProviderPricingSource | "default" | "normalized" {
155
+ const candidate = draft?.cost ?? (officialMeta?.costKnown !== false ? officialMeta?.cost : undefined);
156
+ if (candidate === undefined) return "default";
157
+ if (!costsEqual(candidate, normalizedCost)) return "normalized";
158
+ return draft?.cost !== undefined ? (draft.pricingSource ?? "provider") : "official";
159
+ }
160
+
161
+ function getFieldSource(
162
+ providerValue: unknown,
163
+ officialValue: unknown,
164
+ wasNormalized: boolean,
165
+ ): "provider" | "official" | "default" | "normalized" {
166
+ if (providerValue === undefined && officialValue === undefined) return "default";
167
+ if (wasNormalized) return "normalized";
168
+ return providerValue !== undefined ? "provider" : "official";
169
+ }
170
+
117
171
  function resolveModelRegistration(
118
172
  adapter: ProviderAdapter,
119
173
  runtime: PiProviderDependencies,
@@ -121,42 +175,46 @@ function resolveModelRegistration(
121
175
  officialPricing: Record<string, OfficialModelMeta>,
122
176
  ): { models: ProviderModel[]; modelMetadata: Record<string, ProviderModelMetadata> } {
123
177
  validateProviderModelDrafts(modelDrafts, `Provider ${adapter.id}`);
124
- const enrichedDrafts = applyOfficialModelCosts(modelDrafts, officialPricing);
178
+ const enrichedDrafts = applyOfficialModelMetadata(modelDrafts, officialPricing);
125
179
  const pricingPolicy = runtime.pricingPolicies?.[adapter.id] ?? adapter.pricing;
126
180
  const metadata: Record<string, ProviderModelMetadata> = {};
127
181
  const adjustedDrafts = enrichedDrafts.map((model, index) => {
128
182
  const modelId = model.id.trim();
129
183
  const originalDraft = modelDrafts[index];
130
184
  const officialMeta = findOfficialMeta(modelId, officialPricing);
185
+ const normalizedModel = normalizeProviderModel(model);
186
+ const normalizedCost = normalizeCost(model.cost);
131
187
  const fieldSources = {
132
- contextWindow:
133
- originalDraft?.contextWindow !== undefined
134
- ? ("provider" as const)
135
- : officialMeta?.contextWindow !== undefined
136
- ? ("official" as const)
137
- : ("default" as const),
138
- maxTokens:
139
- originalDraft?.maxTokens !== undefined
140
- ? ("provider" as const)
141
- : officialMeta?.maxTokens !== undefined
142
- ? ("official" as const)
143
- : ("default" as const),
144
- input:
145
- originalDraft?.input !== undefined
146
- ? ("provider" as const)
147
- : officialMeta?.input !== undefined
148
- ? ("official" as const)
149
- : ("default" as const),
150
- reasoning:
151
- originalDraft?.reasoning !== undefined
152
- ? ("provider" as const)
153
- : officialMeta?.reasoning !== undefined
154
- ? ("official" as const)
155
- : ("default" as const),
188
+ cost: getDraftCostSource(originalDraft, officialMeta, normalizedCost),
189
+ contextWindow: getFieldSource(
190
+ originalDraft?.contextWindow,
191
+ officialMeta?.contextWindow,
192
+ model.contextWindow !== undefined && model.contextWindow !== normalizedModel.contextWindow,
193
+ ),
194
+ maxTokens: getFieldSource(
195
+ originalDraft?.maxTokens,
196
+ officialMeta?.maxTokens,
197
+ model.maxTokens !== undefined && model.maxTokens !== normalizedModel.maxTokens,
198
+ ),
199
+ input: getFieldSource(
200
+ originalDraft?.input,
201
+ officialMeta?.input,
202
+ model.input !== undefined && !inputsEqual(model.input, normalizedModel.input),
203
+ ),
204
+ reasoning: getFieldSource(
205
+ originalDraft?.reasoning,
206
+ officialMeta?.reasoning,
207
+ model.reasoning !== undefined && model.reasoning !== normalizedModel.reasoning,
208
+ ),
209
+ thinkingLevelMap: getFieldSource(originalDraft?.thinkingLevelMap, officialMeta?.thinkingLevelMap, false),
156
210
  };
157
211
  const source: ProviderPricingSource | "none" =
158
212
  model.cost === undefined ? "none" : (model.pricingSource ?? "provider");
159
- const pricing = resolvePricingDetails(model.cost, source, selectPricingAdjustment(adapter, model, pricingPolicy));
213
+ const pricing = resolvePricingDetails(
214
+ model.cost === undefined ? undefined : normalizedCost,
215
+ source,
216
+ selectPricingAdjustment(adapter, model, pricingPolicy),
217
+ );
160
218
  metadata[modelId] = {
161
219
  pricing,
162
220
  fieldSources,
@@ -29,6 +29,13 @@ export {
29
29
  mergeDiagnosticHeaders,
30
30
  } from "./diagnostic-auth.ts";
31
31
  export { isProviderDataError, ProviderDataError } from "./errors.ts";
32
+ export type {
33
+ ModelCatalogDiagnostics,
34
+ ModelCatalogDiscoveryResult,
35
+ ModelCatalogLifecycle,
36
+ ModelCatalogLifecycleOptions,
37
+ } from "./model-catalog.ts";
38
+ export { createModelCatalogLifecycle } from "./model-catalog.ts";
32
39
  export { createOpenCodeCatalogPreflightAdapter } from "./opencode-preflight.ts";
33
40
  export type {
34
41
  PreflightAdapter,
package/core/runtime.ts CHANGED
@@ -3,7 +3,7 @@ import type { PiProviderDefinition } from "./definition.ts";
3
3
  import { validatePiProviderDefinition } from "./definition.ts";
4
4
  import { LiveCheckManager, type LiveCheckResult } from "./live-check-manager.ts";
5
5
  import {
6
- fetchOfficialPricing,
6
+ fetchOfficialModelMetadata,
7
7
  findOfficialMeta,
8
8
  getPricingCacheAge,
9
9
  type OfficialModelMeta,
@@ -180,10 +180,12 @@ function getNativeModelMetadata(
180
180
  : {}),
181
181
  },
182
182
  fieldSources: {
183
+ cost: "native",
183
184
  contextWindow: "native",
184
185
  maxTokens: "native",
185
186
  input: "native",
186
187
  reasoning: "native",
188
+ thinkingLevelMap: model.thinkingLevelMap === undefined ? "default" : "native",
187
189
  },
188
190
  ...(officialMeta?.quality
189
191
  ? {
@@ -358,6 +360,19 @@ export function installPiProviderRuntime(
358
360
  const refreshChecks: Array<Promise<unknown>> = [];
359
361
  if (status) refreshChecks.push(statusManager.update(statusContext, { force: true }));
360
362
  if (preflight) refreshChecks.push(preflightManager.update(preflightContext, { force: true }));
363
+ if (typeof ctx.modelRegistry?.refresh === "function") {
364
+ refreshChecks.push(
365
+ Promise.resolve()
366
+ .then(() =>
367
+ ctx.modelRegistry.refresh({
368
+ force: true,
369
+ allowNetwork: true,
370
+ providers: [model.provider],
371
+ } as any),
372
+ )
373
+ .catch(() => undefined),
374
+ );
375
+ }
361
376
 
362
377
  let liveCheck: Promise<LiveCheckResult | undefined> = Promise.resolve(undefined);
363
378
  if (mode === "check") {
@@ -454,8 +469,8 @@ export function createPiProviderRuntime(
454
469
  let pricingRefreshController: AbortController | undefined;
455
470
  const cachePath =
456
471
  runtime.officialPricingUrl === OPENROUTER_MODELS_URL ? runtime.openRouterMetadataCachePath : undefined;
457
- const fetchPricing = (options: { allowNetwork?: boolean; signal?: AbortSignal } = {}) =>
458
- fetchOfficialPricing(
472
+ const fetchMetadata = (options: { allowNetwork?: boolean; signal?: AbortSignal } = {}) =>
473
+ fetchOfficialModelMetadata(
459
474
  runtime.fetch,
460
475
  runtime.officialPricingUrl,
461
476
  runtime.officialPricingTimeoutMs,
@@ -465,7 +480,7 @@ export function createPiProviderRuntime(
465
480
  { cachePath, ...options },
466
481
  );
467
482
  const officialPricingPromise = runtime.enableOfficialPricingFallback
468
- ? fetchPricing({ allowNetwork: false })
483
+ ? fetchMetadata({ allowNetwork: false })
469
484
  : Promise.resolve({});
470
485
  const definitionPromise = loadDefinition(runtime);
471
486
  const [officialPricing, definition] = await Promise.all([officialPricingPromise, definitionPromise]);
@@ -479,7 +494,7 @@ export function createPiProviderRuntime(
479
494
  }
480
495
  const controller = new AbortController();
481
496
  pricingRefreshController = controller;
482
- void fetchPricing({ signal: controller.signal })
497
+ void fetchMetadata({ signal: controller.signal })
483
498
  .then((snapshot) => {
484
499
  if (disposed || controller.signal.aborted) return;
485
500
  installedController?.updateOfficialPricing?.(snapshot);
@@ -177,7 +177,9 @@ function formatModelFieldSource(source: ModelFieldSource | undefined): string {
177
177
  ? "OpenRouter"
178
178
  : source === "fallback"
179
179
  ? "Provider fallback"
180
- : "Pi default";
180
+ : source === "normalized"
181
+ ? "Normalized catalog value"
182
+ : "Pi default";
181
183
  return ` · ${label}`;
182
184
  }
183
185
 
@@ -279,7 +281,18 @@ function formatCatalog(
279
281
  const statusParts = freshness ? [freshness, source] : [source];
280
282
  if (catalog?.updatedAt !== undefined) statusParts.push(formatAge(now, catalog.updatedAt));
281
283
  const lines = [`Status: ${statusParts.join(" · ")}`, `Models: ${count}`];
284
+ const rejectedCount = catalog?.rejectedCount ?? 0;
285
+ const duplicateCount = catalog?.duplicateCount ?? 0;
286
+ if (rejectedCount > 0 || duplicateCount > 0) {
287
+ lines.push(`Skipped: ${rejectedCount} invalid · ${duplicateCount} duplicate`);
288
+ }
282
289
  if (catalog?.lastError) lines.push(`Error: ${catalog.lastError}`);
290
+ if (catalog?.lastError && catalog.nextRetryAt !== undefined) {
291
+ const failures = catalog.consecutiveFailures ?? 1;
292
+ lines.push(
293
+ `Retry: ${formatUntil(now, catalog.nextRetryAt)} · ${failures} consecutive failure${failures === 1 ? "" : "s"}`,
294
+ );
295
+ }
283
296
  const issue =
284
297
  catalog?.lastError !== undefined
285
298
  ? {
@@ -578,6 +591,11 @@ export function formatProviderStatus(
578
591
  ` Max output: ${formatTokens(model.maxTokens)}${formatModelFieldSource(fieldSources?.maxTokens)}`,
579
592
  ` Input: ${model.input?.join(", ") || "unknown"}${formatModelFieldSource(fieldSources?.input)}`,
580
593
  ` Reasoning: ${formatReasoning(model)}${formatModelFieldSource(fieldSources?.reasoning)}`,
594
+ ...(model.thinkingLevelMap
595
+ ? [
596
+ ` Thinking levels: ${getSupportedReasoningLevels(model).join(", ") || "none"}${formatModelFieldSource(fieldSources?.thinkingLevelMap)}`,
597
+ ]
598
+ : []),
581
599
  ` Pricing: ${formatPricing(model, options.modelMetadata)}${pricingSource}`,
582
600
  ...(model.cost?.tiers?.map((tier) => ` Pricing tier: ${formatPricingTier(tier)}`) ?? []),
583
601
  ...(options.modelMetadata?.pricing?.note ? [` Pricing note: ${options.modelMetadata.pricing.note}`] : []),
package/core/types.ts CHANGED
@@ -14,7 +14,7 @@ export type PricingSku = "input" | "output" | "cacheRead" | "cacheWrite";
14
14
  /** Pricing provenance used by Pi Provider sidecars; not added to Pi model objects. */
15
15
  export type ProviderPricingSource = "provider" | "fallback" | "official";
16
16
  export type ModelPricingSource = ProviderPricingSource | "native";
17
- export type ModelFieldSource = ProviderPricingSource | "native" | "default";
17
+ export type ModelFieldSource = ProviderPricingSource | "native" | "default" | "normalized";
18
18
  export type ModelMetadataState = "fresh" | "stale" | "checking" | "unavailable";
19
19
 
20
20
  export interface ModelMetadataStatus {
@@ -42,10 +42,12 @@ export interface ProviderRequestAuth {
42
42
  }
43
43
 
44
44
  export interface ModelFieldSources {
45
+ cost?: ModelFieldSource;
45
46
  contextWindow?: ModelFieldSource;
46
47
  maxTokens?: ModelFieldSource;
47
48
  input?: ModelFieldSource;
48
49
  reasoning?: ModelFieldSource;
50
+ thinkingLevelMap?: ModelFieldSource;
49
51
  }
50
52
 
51
53
  export interface ProviderPricingAdjustment {
@@ -112,12 +114,18 @@ export type ProviderDefinition = Omit<ProviderConfig, "models" | "refreshModels"
112
114
  refreshModels?: (context: ProviderRefreshContext) => Promise<ProviderModelDraft[]>;
113
115
  };
114
116
 
115
- export type ModelCatalogSource = "static" | "live" | "fallback";
117
+ export type ModelCatalogSource = "static" | "live" | "cached" | "fallback" | "empty";
116
118
 
117
119
  export interface ModelCatalogStatus {
118
120
  source: ModelCatalogSource;
119
121
  modelCount: number;
120
122
  updatedAt?: number;
123
+ lastSuccessfulRefreshAt?: number;
124
+ lastAttemptAt?: number;
125
+ consecutiveFailures?: number;
126
+ nextRetryAt?: number;
127
+ rejectedCount?: number;
128
+ duplicateCount?: number;
121
129
  lastError?: string;
122
130
  }
123
131
 
package/index.ts CHANGED
@@ -54,9 +54,18 @@ export type {
54
54
  LiveCheckSnapshot,
55
55
  } from "./core/live-check-manager.ts";
56
56
  export { getLiveCheckKey, LIVE_CHECK_SCOPE, LiveCheckManager } from "./core/live-check-manager.ts";
57
+ export type {
58
+ ModelCatalogDiagnostics,
59
+ ModelCatalogDiscoveryResult,
60
+ ModelCatalogLifecycle,
61
+ ModelCatalogLifecycleOptions,
62
+ } from "./core/model-catalog.ts";
63
+ export { createModelCatalogLifecycle } from "./core/model-catalog.ts";
57
64
  export {
58
65
  applyOfficialModelCosts,
66
+ applyOfficialModelMetadata,
59
67
  clearPricingCache,
68
+ fetchOfficialModelMetadata,
60
69
  fetchOfficialPricing,
61
70
  findOfficialCost,
62
71
  findOfficialMeta,
@@ -64,6 +73,7 @@ export {
64
73
  getPricingCache,
65
74
  getPricingCacheAge,
66
75
  type OfficialModelMeta,
76
+ type OfficialModelMetadataFetchOptions,
67
77
  type OfficialPricingFetchOptions,
68
78
  OPENROUTER_MODELS_URL,
69
79
  parseOpenRouterModels,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyav/pi-provider",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Provider extension toolkit for Pi to integrate and manage custom LLM providers with dynamic models, request tuners, and account status.",
5
5
  "author": "hyav",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import type {
2
- ModelCatalogStatus,
2
+ ModelCatalogDiscoveryResult,
3
3
  ProviderAdapter,
4
4
  ProviderModel,
5
5
  ProviderModelDraft,
@@ -7,6 +7,7 @@ import type {
7
7
  ThinkingLevel,
8
8
  } from "@hyav/pi-provider";
9
9
  import {
10
+ createModelCatalogLifecycle,
10
11
  defineProviderExtension,
11
12
  isProviderDataError,
12
13
  MAX_PROVIDER_MODEL_COUNT,
@@ -21,17 +22,6 @@ export { HYPER_BASE_URL, HYPER_USER_AGENT } from "./charm-hyper/constants.ts";
21
22
  export const HYPER_PROVIDER_URL = "https://hyper.charm.land/v1/provider";
22
23
  export const HYPER_MODELS_URL = "https://hyper.charm.land/v1/models";
23
24
  export const HYPER_MODEL_CATALOG_TTL_MS = 4 * 60 * 60 * 1_000;
24
- const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
25
-
26
- const officialCostFallbacks: Partial<Record<string, ProviderModel["cost"]>> = {
27
- "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
28
- "deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
29
- "glm-5": { input: 1, output: 3.2, cacheRead: 0, cacheWrite: 0 },
30
- "glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
31
- "kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
32
- "kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
33
- "mistral-large-instruct-2411": { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 0 },
34
- };
35
25
 
36
26
  const thinkingLevels = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
37
27
  const baseHyperCompat: NonNullable<ProviderModel["compat"]> = {
@@ -50,21 +40,6 @@ const onOffThinkingLevelMap: NonNullable<ProviderModel["thinkingLevelMap"]> = {
50
40
  max: "max",
51
41
  };
52
42
 
53
- const modelOverrides: Record<string, Partial<ProviderModelDraft>> = {
54
- "qwen3-coder-480b-a35b-instruct-int4-mixed-ar": { reasoning: false },
55
- "qwen3-next-80b-a3b-instruct": { reasoning: false },
56
- "gpt-oss-120b": {
57
- thinkingLevelMap: {
58
- minimal: null,
59
- low: "low",
60
- medium: "medium",
61
- high: "high",
62
- xhigh: "high",
63
- },
64
- compat: { supportsReasoningEffort: true },
65
- },
66
- };
67
-
68
43
  function isRecord(value: unknown): value is Record<string, unknown> {
69
44
  return value !== null && typeof value === "object" && !Array.isArray(value);
70
45
  }
@@ -77,63 +52,6 @@ function isFiniteNonNegative(value: unknown): value is number {
77
52
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
78
53
  }
79
54
 
80
- function costFallbackFor(id: string): ProviderModel["cost"] {
81
- return { ...(officialCostFallbacks[id] ?? zeroCost) };
82
- }
83
-
84
- function fallbackModel(
85
- id: string,
86
- name: string,
87
- contextWindow: number,
88
- overrides: Partial<ProviderModelDraft> = {},
89
- ): ProviderModelDraft {
90
- const { compat: overrideCompat, ...rest } = overrides;
91
- return {
92
- id,
93
- name,
94
- reasoning: true,
95
- input: ["text", "image"],
96
- contextWindow,
97
- maxTokens: Math.floor(contextWindow / 10),
98
- cost: costFallbackFor(id),
99
- pricingSource: "fallback",
100
- headers: { ...hyperModelHeaders },
101
- compat: { ...baseHyperCompat, ...overrideCompat },
102
- ...rest,
103
- };
104
- }
105
-
106
- export function getHyperFallbackModels(): ProviderModelDraft[] {
107
- return [
108
- fallbackModel("deepseek-v4-flash", "DeepSeek V4 Flash", 1_048_576),
109
- fallbackModel("deepseek-v4-pro", "DeepSeek V4 Pro", 1_048_576),
110
- fallbackModel("gemma-4-26b-a4b-it", "Gemma 4 26B A4B", 32_768, { input: ["text"] }),
111
- fallbackModel("glm-5", "GLM-5", 202_752),
112
- fallbackModel("glm-5.1", "GLM-5.1", 202_752),
113
- fallbackModel("gpt-oss-120b", "GPT-OSS-120B", 131_072, {
114
- input: ["text"],
115
- thinkingLevelMap: { minimal: null, low: "low", medium: "medium", high: "high", xhigh: "high" },
116
- compat: { supportsReasoningEffort: true },
117
- }),
118
- fallbackModel("kimi-k2.5", "Kimi K2.5", 262_144),
119
- fallbackModel("kimi-k2.6", "Kimi K2.6", 32_768),
120
- fallbackModel("llama-3.3-70b-instruct", "Llama 3.3 70B Instruct", 128_000, { input: ["text"] }),
121
- fallbackModel("llama-4-maverick-17b-128e-instruct-fp8", "Llama 4 Maverick 17B 128E", 430_000),
122
- fallbackModel("mistral-large-instruct-2411", "Mistral Large Instruct 2411", 128_000, {
123
- reasoning: false,
124
- input: ["text"],
125
- }),
126
- fallbackModel("qwen3-coder-480b-a35b-instruct-int4-mixed-ar", "Qwen3 Coder 480B INT4", 106_000, {
127
- reasoning: false,
128
- input: ["text"],
129
- }),
130
- fallbackModel("qwen3-next-80b-a3b-instruct", "Qwen3 Next 80B A3B", 262_144, {
131
- reasoning: false,
132
- input: ["text"],
133
- }),
134
- ];
135
- }
136
-
137
55
  function mapHyperPricing(value: unknown): ProviderModel["cost"] | undefined {
138
56
  if (!isRecord(value)) return undefined;
139
57
  if (!isFiniteNonNegative(value.input) || !isFiniteNonNegative(value.output)) return undefined;
@@ -163,16 +81,6 @@ function buildThinkingLevelMap(levels: readonly string[]): NonNullable<ProviderM
163
81
  };
164
82
  }
165
83
 
166
- function applyModelOverride(model: ProviderModelDraft): ProviderModelDraft {
167
- const override = modelOverrides[model.id];
168
- if (override === undefined) return model;
169
- return {
170
- ...model,
171
- ...override,
172
- ...(override.compat ? { compat: { ...(model.compat ?? {}), ...override.compat } } : {}),
173
- };
174
- }
175
-
176
84
  function readEffortLevels(model: Record<string, unknown>): ThinkingLevel[] {
177
85
  const reasoning = isRecord(model.reasoning) ? model.reasoning : undefined;
178
86
  const currentLevels = Array.isArray(reasoning?.effort_levels)
@@ -238,41 +146,81 @@ function parseCurrentHyperModel(value: unknown): ProviderModelDraft | undefined
238
146
  compat: { ...baseHyperCompat, supportsReasoningEffort: reportedReasoningLevels.length > 0 },
239
147
  ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
240
148
  };
241
- return applyModelOverride(mapped);
149
+ return mapped;
150
+ }
151
+
152
+ interface ParsedHyperCatalog {
153
+ models: ProviderModelDraft[];
154
+ diagnostics: {
155
+ rejectedCount: number;
156
+ duplicateCount: number;
157
+ };
242
158
  }
243
159
 
244
- function parseCurrentHyperModels(payload: Record<string, unknown>): ProviderModelDraft[] | undefined {
160
+ function parseCurrentHyperModels(payload: Record<string, unknown>): ParsedHyperCatalog | undefined {
245
161
  if (!Array.isArray(payload.models)) return undefined;
246
- if (payload.models.length > MAX_PROVIDER_MODEL_COUNT) return [];
162
+ if (payload.models.length > MAX_PROVIDER_MODEL_COUNT) {
163
+ return { models: [], diagnostics: { rejectedCount: payload.models.length, duplicateCount: 0 } };
164
+ }
247
165
  const models: ProviderModelDraft[] = [];
248
166
  const seenIds = new Set<string>();
167
+ let rejectedCount = 0;
168
+ let duplicateCount = 0;
249
169
  for (const value of payload.models) {
250
170
  const model = parseCurrentHyperModel(value);
251
- if (model === undefined) return [];
171
+ if (model === undefined) {
172
+ rejectedCount++;
173
+ continue;
174
+ }
252
175
  const normalizedId = model.id.toLowerCase();
253
- if (seenIds.has(normalizedId)) continue;
176
+ if (seenIds.has(normalizedId)) {
177
+ duplicateCount++;
178
+ continue;
179
+ }
254
180
  seenIds.add(normalizedId);
255
181
  models.push(model);
256
182
  }
257
- return models;
183
+ return { models, diagnostics: { rejectedCount, duplicateCount } };
258
184
  }
259
185
 
260
- function parseLegacyHyperModels(payload: Record<string, unknown>): ProviderModelDraft[] {
261
- if (!Array.isArray(payload.data) || payload.data.length > MAX_PROVIDER_MODEL_COUNT) return [];
186
+ function parseLegacyHyperModels(payload: Record<string, unknown>): ParsedHyperCatalog {
187
+ if (!Array.isArray(payload.data)) {
188
+ return { models: [], diagnostics: { rejectedCount: 0, duplicateCount: 0 } };
189
+ }
190
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
191
+ return { models: [], diagnostics: { rejectedCount: payload.data.length, duplicateCount: 0 } };
192
+ }
262
193
  const models: ProviderModelDraft[] = [];
263
194
  const seenIds = new Set<string>();
195
+ let rejectedCount = 0;
196
+ let duplicateCount = 0;
264
197
 
265
198
  for (const value of payload.data) {
266
- if (!isRecord(value) || typeof value.id !== "string" || value.id.trim() === "") continue;
199
+ if (!isRecord(value) || typeof value.id !== "string" || value.id.trim() === "") {
200
+ rejectedCount++;
201
+ continue;
202
+ }
267
203
  const id = value.id.trim();
268
204
  const normalizedId = id.toLowerCase();
269
- if (seenIds.has(normalizedId)) continue;
270
- if (value.context_window !== undefined && !isPositiveInteger(value.context_window)) continue;
271
- if (value.max_output_tokens !== undefined && !isPositiveInteger(value.max_output_tokens)) continue;
205
+ if (value.context_window !== undefined && !isPositiveInteger(value.context_window)) {
206
+ rejectedCount++;
207
+ continue;
208
+ }
209
+ if (value.max_output_tokens !== undefined && !isPositiveInteger(value.max_output_tokens)) {
210
+ rejectedCount++;
211
+ continue;
212
+ }
272
213
 
273
214
  const contextWindow = isPositiveInteger(value.context_window) ? value.context_window : undefined;
274
215
  const maxTokens = isPositiveInteger(value.max_output_tokens) ? value.max_output_tokens : undefined;
275
- if (contextWindow !== undefined && maxTokens !== undefined && maxTokens > contextWindow) continue;
216
+ if (contextWindow !== undefined && maxTokens !== undefined && maxTokens > contextWindow) {
217
+ rejectedCount++;
218
+ continue;
219
+ }
220
+ if (seenIds.has(normalizedId)) {
221
+ duplicateCount++;
222
+ continue;
223
+ }
276
224
  const reasoning = isRecord(value.reasoning) ? value.reasoning : undefined;
277
225
  const capabilities = isRecord(value.capabilities) ? value.capabilities : undefined;
278
226
  const reasoningEffortLevels = readEffortLevels(value);
@@ -310,27 +258,29 @@ function parseLegacyHyperModels(payload: Record<string, unknown>): ProviderModel
310
258
  headers: { ...hyperModelHeaders },
311
259
  ...(contextWindow !== undefined ? { contextWindow } : {}),
312
260
  ...(maxTokens !== undefined ? { maxTokens } : {}),
313
- cost: cost ?? costFallbackFor(id),
314
- pricingSource: cost ? "provider" : "fallback",
261
+ ...(cost ? { cost, pricingSource: "provider" as const } : {}),
315
262
  compat: { ...baseHyperCompat, supportsReasoningEffort },
316
263
  };
317
- models.push(applyModelOverride(mapped));
264
+ models.push(mapped);
318
265
  seenIds.add(normalizedId);
319
266
  }
320
- return models;
267
+ return { models, diagnostics: { rejectedCount, duplicateCount } };
268
+ }
269
+
270
+ function parseHyperCatalog(payload: unknown): ParsedHyperCatalog {
271
+ if (!isRecord(payload)) return { models: [], diagnostics: { rejectedCount: 0, duplicateCount: 0 } };
272
+ return parseCurrentHyperModels(payload) ?? parseLegacyHyperModels(payload);
321
273
  }
322
274
 
323
275
  export function parseHyperModels(payload: unknown): ProviderModelDraft[] {
324
- if (!isRecord(payload)) return [];
325
- const currentModels = parseCurrentHyperModels(payload);
326
- return currentModels ?? parseLegacyHyperModels(payload);
276
+ return parseHyperCatalog(payload).models;
327
277
  }
328
278
 
329
279
  async function discoverHyperModels(
330
280
  fetchFn: typeof globalThis.fetch,
331
281
  timeoutMs: number,
332
282
  externalSignal?: AbortSignal,
333
- ): Promise<ProviderModelDraft[]> {
283
+ ): Promise<ModelCatalogDiscoveryResult> {
334
284
  return withDeadline(
335
285
  async (signal) => {
336
286
  let endpoint = HYPER_PROVIDER_URL;
@@ -354,12 +304,12 @@ async function discoverHyperModels(
354
304
  "badjson",
355
305
  );
356
306
  }
357
- const models = parseHyperModels(payload);
358
- if (models.length === 0) {
307
+ const parsed = parseHyperCatalog(payload);
308
+ if (parsed.models.length === 0) {
359
309
  throw new ProviderDataError("Charm Hyper model discovery returned no valid models", "badjson");
360
310
  }
361
- normalizeProviderModels(models);
362
- return models;
311
+ normalizeProviderModels(parsed.models);
312
+ return parsed;
363
313
  },
364
314
  timeoutMs,
365
315
  externalSignal,
@@ -384,10 +334,6 @@ type HyperStoredModel = NonNullable<HyperModelsStoreEntry>["models"][number] & {
384
334
  pricingSource?: ProviderModelDraft["pricingSource"];
385
335
  };
386
336
 
387
- function isValidTimestamp(value: unknown): value is number {
388
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
389
- }
390
-
391
337
  function draftsFromStoredModels(entry: HyperModelsStoreEntry): ProviderModelDraft[] | undefined {
392
338
  if (!entry || !Array.isArray(entry.models) || entry.models.length === 0) return undefined;
393
339
  try {
@@ -412,113 +358,23 @@ function storedModelsFromDrafts(models: ProviderModelDraft[]): HyperStoredModel[
412
358
  });
413
359
  }
414
360
 
415
- async function publishCatalog(
416
- context: ProviderRefreshContext,
417
- models: ProviderModelDraft[],
418
- checkedAt: number,
419
- update: () => void,
420
- ): Promise<boolean> {
421
- try {
422
- return await context.publish({ persist: { models: storedModelsFromDrafts(models), checkedAt }, update });
423
- } catch {
424
- // Persistence is an optimization. Retry the generation-checked in-memory update without it.
425
- try {
426
- return await context.publish({ update });
427
- } catch {
428
- return false;
429
- }
430
- }
431
- }
432
-
433
361
  export function createCharmHyperAdapter(
434
362
  fetchFn: typeof globalThis.fetch,
435
363
  discoveryTimeoutMs: number,
436
364
  now: () => number = Date.now,
437
365
  ): ProviderAdapter {
438
- let models = getHyperFallbackModels();
439
- let lastRefreshAt: number | undefined;
440
- let lastCatalogUpdatedAt: number | undefined;
441
- let inFlightRefresh: { signal: AbortSignal; request: Promise<ProviderModelDraft[]> } | undefined;
442
- const catalog: ModelCatalogStatus = { source: "fallback", modelCount: models.length };
443
366
  let provider: ProviderAdapter["provider"];
444
-
445
- const publishModels = (
446
- nextModels: ProviderModelDraft[],
447
- source: ModelCatalogStatus["source"],
448
- updatedAt?: number,
449
- ) => {
450
- models = nextModels;
451
- provider.models = models;
452
- catalog.source = source;
453
- catalog.modelCount = models.length;
454
- catalog.lastError = undefined;
455
- if (updatedAt !== undefined) {
456
- catalog.updatedAt = updatedAt;
457
- lastCatalogUpdatedAt = updatedAt;
458
- }
459
- };
460
-
461
- const restoreStoredModels = async (context: ProviderRefreshContext, entry: HyperModelsStoreEntry): Promise<void> => {
462
- const restoredModels = draftsFromStoredModels(entry);
463
- if (!restoredModels) return;
464
- const checkedAt = isValidTimestamp(entry?.checkedAt) ? entry.checkedAt : undefined;
465
- if (lastCatalogUpdatedAt !== undefined && checkedAt !== undefined && checkedAt <= lastCatalogUpdatedAt) return;
466
- if (lastCatalogUpdatedAt !== undefined && checkedAt === undefined) return;
467
- try {
468
- await context.publish({
469
- update: () => {
470
- publishModels(restoredModels, "live", checkedAt);
471
- if (checkedAt !== undefined) lastRefreshAt = checkedAt;
472
- },
473
- });
474
- } catch {
475
- // A stale or cancelled refresh must not replace the current in-memory catalog.
476
- }
477
- };
478
-
479
- const isFresh = (timestamp: number | undefined, currentTime: number): boolean =>
480
- timestamp !== undefined && Math.max(0, currentTime - timestamp) <= HYPER_MODEL_CATALOG_TTL_MS;
481
-
482
- const refreshModels = async (context: ProviderRefreshContext): Promise<ProviderModelDraft[]> => {
483
- await restoreStoredModels(context, context.stored);
484
- if (context?.allowNetwork !== true || context.signal?.aborted) return [...models];
485
-
486
- const currentTime = now();
487
- if (!context.force && isFresh(lastRefreshAt, currentTime)) return [...models];
488
- if (inFlightRefresh?.signal === context.signal) return inFlightRefresh.request;
489
-
490
- const request = (async (): Promise<ProviderModelDraft[]> => {
491
- try {
492
- const refreshedModels = await discoverHyperModels(fetchFn, discoveryTimeoutMs, context.signal);
493
- if (context.signal?.aborted) {
494
- throw context.signal.reason ?? new DOMException("The operation was aborted", "AbortError");
495
- }
496
- const updatedAt = now();
497
- await publishCatalog(context, refreshedModels, updatedAt, () => {
498
- publishModels(refreshedModels, "live", updatedAt);
499
- lastRefreshAt = updatedAt;
500
- });
501
- return [...models];
502
- } catch (error) {
503
- if (!context.signal.aborted && !isAbortError(error)) {
504
- lastRefreshAt = now();
505
- catalog.lastError = catalogErrorCode(error);
506
- }
507
- throw error;
508
- }
509
- })();
510
- const activeRefresh = { signal: context.signal, request };
511
- inFlightRefresh = activeRefresh;
512
- void request.then(
513
- () => {
514
- if (inFlightRefresh === activeRefresh) inFlightRefresh = undefined;
515
- },
516
- () => {
517
- if (inFlightRefresh === activeRefresh) inFlightRefresh = undefined;
518
- },
519
- );
520
- return request;
521
- };
367
+ const lifecycle = createModelCatalogLifecycle({
368
+ ttlMs: HYPER_MODEL_CATALOG_TTL_MS,
369
+ now,
370
+ discover: (context) => discoverHyperModels(fetchFn, discoveryTimeoutMs, context.signal),
371
+ restore: draftsFromStoredModels,
372
+ persist: (models, checkedAt) => ({ models: storedModelsFromDrafts(models), checkedAt }),
373
+ onUpdate: (models) => {
374
+ if (provider) provider.models = models;
375
+ },
376
+ errorCode: catalogErrorCode,
377
+ });
522
378
 
523
379
  provider = {
524
380
  name: "Charm Hyper",
@@ -526,12 +382,12 @@ export function createCharmHyperAdapter(
526
382
  apiKey: "$HYPER_API_KEY",
527
383
  authHeader: true,
528
384
  api: "openai-completions",
529
- models,
530
- refreshModels,
385
+ models: lifecycle.getModels(),
386
+ refreshModels: lifecycle.refreshModels,
531
387
  oauth: createCharmHyperOAuth(fetchFn, now),
532
388
  };
533
389
 
534
- return { id: "charm-hyper", catalog, provider };
390
+ return { id: "charm-hyper", catalog: lifecycle.catalog, provider };
535
391
  }
536
392
 
537
393
  const charmHyperProviderExtension = defineProviderExtension({