@hyav/pi-provider 0.1.8 → 0.2.0

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/core/types.ts CHANGED
@@ -4,7 +4,10 @@ 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
+ import type { ModelCatalogLifecycle } from "./model-catalog.ts";
9
+ import type { PiCatalogModelMeta, PiCatalogSnapshot } from "./pi-model-metadata.ts";
10
+ export type { PiCatalogModelMeta, PiCatalogSnapshot };
8
11
 
9
12
  export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
10
13
 
@@ -12,9 +15,17 @@ export type ProviderCost = ProviderModelConfig["cost"];
12
15
  export type ProviderModel = ProviderModelConfig;
13
16
  export type PricingSku = "input" | "output" | "cacheRead" | "cacheWrite";
14
17
  /** Pricing provenance used by Pi Provider sidecars; not added to Pi model objects. */
15
- export type ProviderPricingSource = "provider" | "fallback" | "official";
18
+ export type ProviderPricingSource = "provider" | "fallback" | "official" | "pi" | "mixed";
16
19
  export type ModelPricingSource = ProviderPricingSource | "native";
17
- export type ModelFieldSource = ProviderPricingSource | "native" | "default" | "normalized";
20
+ export type ModelFieldSource =
21
+ | "provider"
22
+ | "pi"
23
+ | "native"
24
+ | "default"
25
+ | "normalized"
26
+ | "mixed"
27
+ | "official"
28
+ | "fallback";
18
29
  export type ModelMetadataState = "fresh" | "stale" | "checking" | "unavailable";
19
30
 
20
31
  export interface ModelMetadataStatus {
@@ -41,8 +52,16 @@ export interface ProviderRequestAuth {
41
52
  env?: Record<string, string>;
42
53
  }
43
54
 
55
+ export interface ModelCostBySkuSources {
56
+ input?: ModelFieldSource;
57
+ output?: ModelFieldSource;
58
+ cacheRead?: ModelFieldSource;
59
+ cacheWrite?: ModelFieldSource;
60
+ }
61
+
44
62
  export interface ModelFieldSources {
45
63
  cost?: ModelFieldSource;
64
+ costBySku?: ModelCostBySkuSources;
46
65
  contextWindow?: ModelFieldSource;
47
66
  maxTokens?: ModelFieldSource;
48
67
  input?: ModelFieldSource;
@@ -72,26 +91,12 @@ export interface ModelPricingDetails {
72
91
  effectiveCost?: ProviderCost;
73
92
  adjustment?: ProviderPricingAdjustment;
74
93
  note?: string;
75
- }
76
-
77
- export interface ModelQualityScore {
78
- source: string;
79
- benchmark: string;
80
- category: string;
81
- metric: "elo" | "rating" | "score" | "ips";
82
- value: number;
83
- rank?: number;
84
- winRate?: number;
85
- confidenceInterval?: {
86
- lower: number;
87
- upper: number;
88
- };
94
+ costBySku?: ModelCostBySkuSources;
89
95
  }
90
96
 
91
97
  export interface ProviderModelMetadata {
92
98
  pricing: ModelPricingDetails;
93
99
  fieldSources?: ModelFieldSources;
94
- quality?: ModelQualityScore[];
95
100
  }
96
101
 
97
102
  export type ProviderModelDraft = Partial<ProviderModel> &
@@ -199,12 +204,16 @@ export interface ProviderAdapter {
199
204
  /** Optional explicit price adjustments owned by this Provider. */
200
205
  pricing?: ProviderPricingPolicy;
201
206
  catalog?: ModelCatalogStatus;
207
+ lifecycle?: ModelCatalogLifecycle;
208
+ /** Whether to fallback to Pi's builtin catalog when fields are missing. Defaults to true. */
209
+ usePiModelMetaFallback?: boolean;
202
210
  /** @internal Draft state shared across isolated Adapter and Host contexts. */
203
211
  registration?: {
204
212
  modelDrafts: ProviderModelDraft[];
205
213
  normalizedModels: ProviderModel[];
206
214
  modelMetadata: Record<string, ProviderModelMetadata>;
207
- officialPricing: Record<string, OfficialModelMeta>;
215
+ piCatalog?: PiCatalogSnapshot;
216
+ officialPricing?: Record<string, any>;
208
217
  activeRefreshes: number;
209
218
  deferredRegistration?: () => void;
210
219
  };
package/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as piBuiltinCatalog from "@earendil-works/pi-ai/providers/all";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { getAgentDir, readStoredCredential } from "@earendil-works/pi-coding-agent";
3
4
  import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
@@ -17,7 +18,7 @@ export {
17
18
  defineStatusExtension,
18
19
  defineTunerExtension,
19
20
  } from "./core/adapter-extensions.ts";
20
- export { MAX_PROVIDER_MODEL_COUNT } from "./core/adapter-validation.ts";
21
+ export { MAX_PROVIDER_MODEL_COUNT, validateProviderModelDrafts } from "./core/adapter-validation.ts";
21
22
  export { createCatalogPreflightAdapter } from "./core/catalog-preflight.ts";
22
23
  export { withDeadline } from "./core/deadline.ts";
23
24
  export {
@@ -61,26 +62,18 @@ export type {
61
62
  ModelCatalogLifecycleOptions,
62
63
  } from "./core/model-catalog.ts";
63
64
  export { createModelCatalogLifecycle } from "./core/model-catalog.ts";
64
- export {
65
- applyOfficialModelCosts,
66
- applyOfficialModelMetadata,
67
- clearPricingCache,
68
- fetchOfficialModelMetadata,
69
- fetchOfficialPricing,
70
- findOfficialCost,
71
- findOfficialMeta,
72
- getDefaultOpenRouterMetadataCachePath,
73
- getPricingCache,
74
- getPricingCacheAge,
75
- type OfficialModelMeta,
76
- type OfficialModelMetadataFetchOptions,
77
- type OfficialPricingFetchOptions,
78
- OPENROUTER_MODELS_URL,
79
- parseOpenRouterModels,
80
- parseOpenRouterPricing,
81
- setPricingCache,
82
- } from "./core/official-pricing.ts";
83
65
  export { createOpenCodeCatalogPreflightAdapter } from "./core/opencode-preflight.ts";
66
+ export type { PiCatalogModelMeta, PiCatalogSnapshot } from "./core/pi-model-metadata.ts";
67
+ export {
68
+ findPiCatalogModel,
69
+ isLegacyNormalizedModel,
70
+ isLegacyNormalizedSnapshot,
71
+ loadPiCatalog,
72
+ mergeModelWithPiCatalog,
73
+ ORIGINAL_PI_PROVIDER_ALLOWLIST,
74
+ parsePiCatalogFromProviders,
75
+ toPiCatalogSnapshot,
76
+ } from "./core/pi-model-metadata.ts";
84
77
  export type {
85
78
  PreflightAdapter,
86
79
  PreflightContext,
@@ -97,18 +90,23 @@ export type { RateLimitWindow } from "./core/ratelimit-headers.ts";
97
90
  export { parseRetryAfter } from "./core/retry-after.ts";
98
91
  export type { StatusDiagnostics, StatusErrorState } from "./core/status-manager.ts";
99
92
  export { normalizeStatusSnapshot, StatusManager } from "./core/status-manager.ts";
93
+ export {
94
+ formatProviderStatus,
95
+ getStatusModeCompletions,
96
+ parseStatusMode,
97
+ } from "./core/status-report.ts";
100
98
  export { applyTunerAdapters, sortTunerAdapters } from "./core/tuner-manager.ts";
101
99
  export type {
102
100
  ActiveModel,
103
101
  ModelCatalogSource,
104
102
  ModelCatalogStatus,
103
+ ModelCostBySkuSources,
105
104
  ModelFieldSource,
106
105
  ModelFieldSources,
107
106
  ModelMetadataState,
108
107
  ModelMetadataStatus,
109
108
  ModelPricingDetails,
110
109
  ModelPricingSource,
111
- ModelQualityScore,
112
110
  PiApi,
113
111
  PricingSku,
114
112
  ProviderAdapter,
@@ -156,6 +154,11 @@ export function createPiProviderExtension(
156
154
  wrapTextWithAnsi: typeof wrapTextWithAnsi;
157
155
  adapterRoot?: string;
158
156
  dependencies?: Partial<PiProviderDependencies>;
157
+ piCatalogSource: {
158
+ getBuiltinProviders: () => string[];
159
+ getBuiltinModels: (provider: string) => unknown[];
160
+ getBuiltinModelDataGeneratedAt: () => number | undefined;
161
+ };
159
162
  },
160
163
  ) => Promise<void>;
161
164
  };
@@ -165,6 +168,12 @@ export function createPiProviderExtension(
165
168
  wrapTextWithAnsi,
166
169
  adapterRoot: options.adapterRoot,
167
170
  dependencies: options.dependencies,
171
+ piCatalogSource: {
172
+ getBuiltinProviders: () => piBuiltinCatalog.getBuiltinProviders(),
173
+ getBuiltinModels: (provider) =>
174
+ piBuiltinCatalog.getBuiltinModels(provider as Parameters<typeof piBuiltinCatalog.getBuiltinModels>[0]),
175
+ getBuiltinModelDataGeneratedAt: () => piBuiltinCatalog.getBuiltinModelDataGeneratedAt?.(),
176
+ },
168
177
  });
169
178
  };
170
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyav/pi-provider",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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",
@@ -44,11 +44,13 @@
44
44
  "node": ">=22.19.0"
45
45
  },
46
46
  "peerDependencies": {
47
+ "@earendil-works/pi-ai": "*",
47
48
  "@earendil-works/pi-coding-agent": "*",
48
49
  "@earendil-works/pi-tui": "*"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@biomejs/biome": "2.3.5",
53
+ "@earendil-works/pi-ai": "0.84.1",
52
54
  "@earendil-works/pi-coding-agent": "0.84.1",
53
55
  "@earendil-works/pi-tui": "0.84.1",
54
56
  "@types/node": "22.20.1",
@@ -13,9 +13,13 @@ export function createCharmHyperPreflightAdapter(requestTimeoutMs: number): Pref
13
13
  supportsModel: (model) => hasBaseUrlOrigin(model.baseUrl, HYPER_PROVIDER_URL),
14
14
  async fetch(context) {
15
15
  const apiKey = await context.getApiKey();
16
- if (!apiKey) return { passed: false, checks: ["auth"], updatedAt: context.now() };
16
+ // "proxy-managed" is not a usable credential for the official endpoints,
17
+ // so it fails closed together with a missing key.
18
+ if (!apiKey || apiKey === "proxy-managed") {
19
+ return { passed: false, checks: ["auth"], updatedAt: context.now() };
20
+ }
17
21
  const headers = new Headers(hyperJsonHeaders());
18
- if (apiKey !== "proxy-managed") headers.set("Authorization", `Bearer ${apiKey}`);
22
+ headers.set("Authorization", `Bearer ${apiKey}`);
19
23
  let endpoint = HYPER_PROVIDER_URL;
20
24
  let response = await context.fetch(endpoint, { headers, signal: context.signal });
21
25
  if (response.status === 404) {
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
 
4
10
  export const DEEPSEEK_MODELS_URL = "https://api.deepseek.com/models";
5
11
 
@@ -44,6 +50,9 @@ export const deepSeekPreflightAdapter: PreflightAdapter = {
44
50
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
45
51
  throw new ProviderDataError("DeepSeek preflight returned invalid catalog data", "badjson");
46
52
  }
53
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
54
+ throw new ProviderDataError("DeepSeek preflight catalog exceeds the maximum model count", "badjson");
55
+ }
47
56
  const modelIds = new Set(
48
57
  payload.data
49
58
  .filter(isRecord)
@@ -4,6 +4,7 @@ import {
4
4
  authDefinesHeader,
5
5
  definePreflightExtension,
6
6
  getContextAuth,
7
+ MAX_PROVIDER_MODEL_COUNT,
7
8
  mergeDiagnosticHeaders,
8
9
  ProviderDataError,
9
10
  parseRetryAfter,
@@ -57,6 +58,9 @@ export const githubCopilotPreflightAdapter: PreflightAdapter = {
57
58
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
58
59
  throw new ProviderDataError("GitHub Copilot preflight returned invalid catalog data", "badjson");
59
60
  }
61
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
62
+ throw new ProviderDataError("GitHub Copilot preflight catalog exceeds the maximum model count", "badjson");
63
+ }
60
64
  const modelIds = new Set(
61
65
  payload.data.flatMap((model) => {
62
66
  if (!isRecord(model) || typeof model.id !== "string") return [];
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
 
4
10
  export const GOOGLE_MODELS_URL = "https://generativelanguage.googleapis.com/v1beta/models";
5
11
 
@@ -59,6 +65,9 @@ export const googlePreflightAdapter: PreflightAdapter = {
59
65
  if (!isRecord(payload) || !Array.isArray(payload.models)) {
60
66
  throw new ProviderDataError("Google preflight returned invalid catalog data", "badjson");
61
67
  }
68
+ if (payload.models.length > MAX_PROVIDER_MODEL_COUNT) {
69
+ throw new ProviderDataError("Google preflight catalog exceeds the maximum model count", "badjson");
70
+ }
62
71
  const modelIds = new Set(
63
72
  payload.models.flatMap((value) => {
64
73
  if (!isRecord(value)) return [];
package/preflight/groq.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
  import { GROQ_MODELS_URL } from "../status/groq.ts";
4
10
 
5
11
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -43,6 +49,9 @@ export const groqPreflightAdapter: PreflightAdapter = {
43
49
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
44
50
  throw new ProviderDataError("Groq preflight returned invalid catalog data", "badjson");
45
51
  }
52
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
53
+ throw new ProviderDataError("Groq preflight catalog exceeds the maximum model count", "badjson");
54
+ }
46
55
  const activeIds = new Set(
47
56
  payload.data.flatMap((model) => {
48
57
  if (!isRecord(model) || typeof model.id !== "string") return [];
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
  import { extractCodexAccountId } from "../status/openai-codex.ts";
4
10
 
5
11
  export const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
@@ -13,6 +19,9 @@ function codexModelIds(payload: unknown): Set<string> {
13
19
  if (!isRecord(payload) || !Array.isArray(payload.models)) {
14
20
  throw new ProviderDataError("OpenAI Codex preflight returned invalid catalog data", "badjson");
15
21
  }
22
+ if (payload.models.length > MAX_PROVIDER_MODEL_COUNT) {
23
+ throw new ProviderDataError("OpenAI Codex preflight catalog exceeds the maximum model count", "badjson");
24
+ }
16
25
  return new Set(
17
26
  payload.models
18
27
  .filter(isRecord)
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
  import { OPENROUTER_KEY_URL } from "../status/openrouter.ts";
4
10
 
5
11
  export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
@@ -34,6 +40,9 @@ async function collectModelIds(context: Parameters<PreflightAdapter["fetch"]>[0]
34
40
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
35
41
  throw new ProviderDataError("OpenRouter preflight returned invalid catalog data", "badjson");
36
42
  }
43
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
44
+ throw new ProviderDataError("OpenRouter preflight catalog exceeds the maximum model count", "badjson");
45
+ }
37
46
  return new Set(
38
47
  payload.data
39
48
  .filter(isRecord)
@@ -85,7 +94,7 @@ export const openRouterPreflightAdapter: PreflightAdapter = {
85
94
  const apiKey = await context.getApiKey();
86
95
  const checks: string[] = ["endpoint", "catalog"];
87
96
  if (!apiKey || apiKey === "proxy-managed") {
88
- return { passed: modelIds.has(context.model.id), checks: [...checks, "auth"], updatedAt: context.now() };
97
+ return { passed: false, checks: [...checks, "auth"], updatedAt: context.now() };
89
98
  }
90
99
  // Management keys use /api/v1/credits as the only key endpoint and get 404 here.
91
100
  const authStatus = await checkCredential(context, apiKey);
@@ -1,5 +1,10 @@
1
1
  import type { PreflightAdapter, PreflightSnapshot } from "@hyav/pi-provider";
2
- import { definePreflightExtension, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ MAX_PROVIDER_MODEL_COUNT,
5
+ ProviderDataError,
6
+ parseRetryAfter,
7
+ } from "@hyav/pi-provider";
3
8
  import { VERCEL_PROVIDER_ID } from "../status/vercel-ai-gateway/constants.ts";
4
9
 
5
10
  export const VERCEL_MODELS_URL = "https://ai-gateway.vercel.sh/v1/models";
@@ -12,6 +17,9 @@ export function parseVercelModelIds(payload: unknown): Set<string> {
12
17
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
13
18
  throw new ProviderDataError("Vercel AI Gateway preflight returned invalid catalog data", "badjson");
14
19
  }
20
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
21
+ throw new ProviderDataError("Vercel AI Gateway preflight catalog exceeds the maximum model count", "badjson");
22
+ }
15
23
 
16
24
  const modelIds = new Set<string>();
17
25
  for (const value of payload.data) {
@@ -37,6 +45,8 @@ export function createVercelAIGatewayPreflightAdapter(requestTimeoutMs: number):
37
45
  cacheTtlMs: 30_000,
38
46
  requestTimeoutMs,
39
47
  async fetch(context): Promise<PreflightSnapshot> {
48
+ // Vercel's gateway catalog is public and needs no credential; the
49
+ // check intentionally reports no "auth" check.
40
50
  const response = await context.fetch(VERCEL_MODELS_URL, {
41
51
  headers: {
42
52
  Accept: "application/json",
package/preflight/xai.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { PreflightAdapter } from "@hyav/pi-provider";
2
- import { definePreflightExtension, hasBaseUrlOrigin, ProviderDataError, parseRetryAfter } from "@hyav/pi-provider";
2
+ import {
3
+ definePreflightExtension,
4
+ hasBaseUrlOrigin,
5
+ MAX_PROVIDER_MODEL_COUNT,
6
+ ProviderDataError,
7
+ parseRetryAfter,
8
+ } from "@hyav/pi-provider";
3
9
  import { XAI_MODELS_URL } from "../status/xai.ts";
4
10
 
5
11
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -15,12 +21,18 @@ export const xaiPreflightAdapter: PreflightAdapter = {
15
21
  supportsModel: (model) => hasBaseUrlOrigin(model.baseUrl, XAI_MODELS_URL),
16
22
  async fetch(context) {
17
23
  const apiKey = await context.getApiKey();
24
+ if (!apiKey || apiKey === "proxy-managed") {
25
+ // xAI authenticates every route, including /v1/models, so without a
26
+ // credential the catalog request cannot succeed. Fail closed instead
27
+ // of reporting a catalog match as a usable model.
28
+ return { passed: false, checks: ["auth"], updatedAt: context.now() };
29
+ }
18
30
  const authHeaders: Record<string, string> = {
19
31
  Accept: "application/json",
20
32
  "Accept-Encoding": "identity",
21
33
  "User-Agent": "@hyav/pi-provider",
34
+ Authorization: `Bearer ${apiKey}`,
22
35
  };
23
- if (apiKey && apiKey !== "proxy-managed") authHeaders.Authorization = `Bearer ${apiKey}`;
24
36
  const response = await context.fetch(XAI_MODELS_URL, {
25
37
  headers: authHeaders,
26
38
  signal: context.signal,
@@ -42,16 +54,18 @@ export const xaiPreflightAdapter: PreflightAdapter = {
42
54
  if (!isRecord(payload) || !Array.isArray(payload.data)) {
43
55
  throw new ProviderDataError("xAI preflight returned invalid catalog data", "badjson");
44
56
  }
57
+ if (payload.data.length > MAX_PROVIDER_MODEL_COUNT) {
58
+ throw new ProviderDataError("xAI preflight catalog exceeds the maximum model count", "badjson");
59
+ }
45
60
  const modelIds = new Set(
46
61
  payload.data
47
62
  .filter(isRecord)
48
63
  .map((model) => (typeof model.id === "string" ? model.id.trim() : undefined))
49
64
  .filter((id): id is string => id !== undefined && id !== ""),
50
65
  );
51
- const checks = apiKey && apiKey !== "proxy-managed" ? ["endpoint", "catalog", "auth"] : ["endpoint", "catalog"];
52
66
  return {
53
67
  passed: modelIds.has(context.model.id),
54
- checks,
68
+ checks: ["endpoint", "catalog", "auth"],
55
69
  updatedAt: context.now(),
56
70
  httpStatus: response.status,
57
71
  };
@@ -156,21 +156,20 @@ function parseDevicePollResponse(payload: unknown): DevicePollResult {
156
156
  }
157
157
 
158
158
  function parseTokenExchangeResponse(payload: unknown): TokenExchangeResponse {
159
+ const allowedKeys = ["access_token", "token_type", "refresh_token", "expiry", "expires_in", "expires_at"] as const;
159
160
  if (
160
161
  !isRecord(payload) ||
162
+ !hasOnlyKeys(payload, allowedKeys) ||
161
163
  !nonEmptyString(payload.access_token) ||
162
164
  !nonEmptyString(payload.token_type) ||
163
165
  !nonEmptyString(payload.refresh_token) ||
164
- !nonEmptyString(payload.expiry)
166
+ typeof payload.expiry !== "string"
165
167
  ) {
166
168
  throw new Error("Charm Hyper token exchange response is invalid");
167
169
  }
168
170
  if (Object.hasOwn(payload, "expires_in")) {
169
- if (
170
- !hasOnlyKeys(payload, ["access_token", "token_type", "refresh_token", "expiry", "expires_in"]) ||
171
- !positiveInteger(payload.expires_in) ||
172
- Object.hasOwn(payload, "expires_at")
173
- ) {
171
+ // expires_in is authoritative when other absolute forms are also present.
172
+ if (!positiveInteger(payload.expires_in)) {
174
173
  throw new Error("Charm Hyper token exchange response has an invalid expiry");
175
174
  }
176
175
  return {
@@ -180,10 +179,7 @@ function parseTokenExchangeResponse(payload: unknown): TokenExchangeResponse {
180
179
  };
181
180
  }
182
181
  if (Object.hasOwn(payload, "expires_at")) {
183
- if (
184
- !hasOnlyKeys(payload, ["access_token", "token_type", "refresh_token", "expiry", "expires_at"]) ||
185
- !positiveInteger(payload.expires_at)
186
- ) {
182
+ if (!positiveInteger(payload.expires_at)) {
187
183
  throw new Error("Charm Hyper token exchange response has an invalid expiry");
188
184
  }
189
185
  return {
@@ -192,7 +188,17 @@ function parseTokenExchangeResponse(payload: unknown): TokenExchangeResponse {
192
188
  expiresAtSeconds: payload.expires_at,
193
189
  };
194
190
  }
195
- throw new Error("Charm Hyper token exchange response has an invalid expiry");
191
+ // Some responses only carry the ISO `expiry` timestamp; derive the
192
+ // absolute expiry from it when no numeric form is present.
193
+ const expiryMs = typeof payload.expiry === "string" ? Date.parse(payload.expiry) : NaN;
194
+ if (!Number.isFinite(expiryMs)) {
195
+ throw new Error("Charm Hyper token exchange response has an invalid expiry");
196
+ }
197
+ return {
198
+ accessToken: payload.access_token,
199
+ refreshToken: payload.refresh_token,
200
+ expiresAtSeconds: Math.floor(expiryMs / 1_000),
201
+ };
196
202
  }
197
203
 
198
204
  async function initiateDeviceAuth(
@@ -9,10 +9,11 @@ import type {
9
9
  import {
10
10
  createModelCatalogLifecycle,
11
11
  defineProviderExtension,
12
+ isLegacyNormalizedSnapshot,
12
13
  isProviderDataError,
13
14
  MAX_PROVIDER_MODEL_COUNT,
14
- normalizeProviderModels,
15
15
  ProviderDataError,
16
+ validateProviderModelDrafts,
16
17
  withDeadline,
17
18
  } from "@hyav/pi-provider";
18
19
  import { HYPER_BASE_URL, HYPER_USER_AGENT, hyperJsonHeaders } from "./charm-hyper/constants.ts";
@@ -98,7 +99,8 @@ function parseCurrentHyperModel(value: unknown): ProviderModelDraft | undefined
98
99
  if (typeof value.name !== "string" || value.name.trim() === "") return undefined;
99
100
  if (!isFiniteNonNegative(value.cost_per_1m_in)) return undefined;
100
101
  if (!isFiniteNonNegative(value.cost_per_1m_out)) return undefined;
101
- if (!isFiniteNonNegative(value.cost_per_1m_in_cached)) return undefined;
102
+ // Cached prices are optional; the manifest documents 0/missing as no discount.
103
+ if (value.cost_per_1m_in_cached !== undefined && !isFiniteNonNegative(value.cost_per_1m_in_cached)) return undefined;
102
104
  if (value.cost_per_1m_out_cached !== undefined && !isFiniteNonNegative(value.cost_per_1m_out_cached))
103
105
  return undefined;
104
106
  if (!isPositiveInteger(value.context_window) || !isPositiveInteger(value.default_max_tokens)) return undefined;
@@ -137,8 +139,10 @@ function parseCurrentHyperModel(value: unknown): ProviderModelDraft | undefined
137
139
  cost: {
138
140
  input: value.cost_per_1m_in,
139
141
  output: value.cost_per_1m_out,
140
- cacheRead: value.cost_per_1m_in_cached,
141
- cacheWrite: 0,
142
+ // Official Charm mapping: the cached-output price is the cache-read
143
+ // price and the cached-input price is the cache-write price.
144
+ cacheRead: value.cost_per_1m_out_cached ?? 0,
145
+ cacheWrite: value.cost_per_1m_in_cached ?? 0,
142
146
  },
143
147
  contextWindow: value.context_window,
144
148
  maxTokens: value.default_max_tokens,
@@ -308,7 +312,7 @@ async function discoverHyperModels(
308
312
  if (parsed.models.length === 0) {
309
313
  throw new ProviderDataError("Charm Hyper model discovery returned no valid models", "badjson");
310
314
  }
311
- normalizeProviderModels(parsed.models);
315
+ validateProviderModelDrafts(parsed.models);
312
316
  return parsed;
313
317
  },
314
318
  timeoutMs,
@@ -330,15 +334,18 @@ function catalogErrorCode(error: unknown): string {
330
334
  }
331
335
 
332
336
  type HyperModelsStoreEntry = ProviderRefreshContext["stored"];
333
- type HyperStoredModel = NonNullable<HyperModelsStoreEntry>["models"][number] & {
334
- pricingSource?: ProviderModelDraft["pricingSource"];
337
+ type HyperStoredModel = ProviderModelDraft & {
338
+ provider: string;
339
+ baseUrl: string;
340
+ api: ProviderModelDraft["api"];
335
341
  };
336
342
 
337
343
  function draftsFromStoredModels(entry: HyperModelsStoreEntry): ProviderModelDraft[] | undefined {
338
344
  if (!entry || !Array.isArray(entry.models) || entry.models.length === 0) return undefined;
345
+ if (isLegacyNormalizedSnapshot(entry.models)) return undefined;
339
346
  try {
340
347
  const drafts: ProviderModelDraft[] = entry.models.map(({ provider: _provider, ...model }) => model);
341
- normalizeProviderModels(drafts);
348
+ validateProviderModelDrafts(drafts);
342
349
  return drafts;
343
350
  } catch {
344
351
  return undefined;
@@ -346,10 +353,12 @@ function draftsFromStoredModels(entry: HyperModelsStoreEntry): ProviderModelDraf
346
353
  }
347
354
 
348
355
  function storedModelsFromDrafts(models: ProviderModelDraft[]): HyperStoredModel[] {
349
- return normalizeProviderModels(models).map((model) => {
350
- const source = models.find(({ id }) => id === model.id)?.pricingSource;
356
+ validateProviderModelDrafts(models);
357
+ return models.map((model) => {
358
+ const source = model.pricingSource;
351
359
  return {
352
360
  ...model,
361
+ name: typeof model.name === "string" && model.name.trim() !== "" ? model.name.trim() : model.id,
353
362
  ...(source ? { pricingSource: source } : {}),
354
363
  api: model.api ?? "openai-completions",
355
364
  provider: "charm-hyper",
@@ -362,14 +371,19 @@ export function createCharmHyperAdapter(
362
371
  fetchFn: typeof globalThis.fetch,
363
372
  discoveryTimeoutMs: number,
364
373
  now: () => number = Date.now,
374
+ initialModels?: ProviderModelDraft[],
365
375
  ): ProviderAdapter {
366
376
  let provider: ProviderAdapter["provider"];
367
377
  const lifecycle = createModelCatalogLifecycle({
378
+ initialModels,
368
379
  ttlMs: HYPER_MODEL_CATALOG_TTL_MS,
369
380
  now,
370
381
  discover: (context) => discoverHyperModels(fetchFn, discoveryTimeoutMs, context.signal),
371
382
  restore: draftsFromStoredModels,
372
- persist: (models, checkedAt) => ({ models: storedModelsFromDrafts(models), checkedAt }),
383
+ persist: (models, checkedAt) => ({
384
+ models: storedModelsFromDrafts(models) as unknown as NonNullable<HyperModelsStoreEntry>["models"],
385
+ checkedAt,
386
+ }),
373
387
  onUpdate: (models) => {
374
388
  if (provider) provider.models = models;
375
389
  },
@@ -387,7 +401,7 @@ export function createCharmHyperAdapter(
387
401
  oauth: createCharmHyperOAuth(fetchFn, now),
388
402
  };
389
403
 
390
- return { id: "charm-hyper", catalog: lifecycle.catalog, provider };
404
+ return { id: "charm-hyper", catalog: lifecycle.catalog, lifecycle, provider };
391
405
  }
392
406
 
393
407
  const charmHyperProviderExtension = defineProviderExtension({
@@ -31,10 +31,11 @@ export function parseHuggingFaceAccount(payload: unknown): HuggingFaceAccount {
31
31
  }
32
32
  // Response envelope: { type, id, name, emailVerified, canPay, isPro, plan, periodEnd, credits, ... }
33
33
  const plan = safeText(payload.plan);
34
+ const credits = finiteNumber(payload.credits);
34
35
  // Older token generations omit the envelope fields entirely.
35
36
  return {
36
37
  ...(plan !== undefined ? { plan } : {}),
37
- ...(finiteNumber(payload.credits) !== undefined ? { credits: finiteNumber(payload.credits) } : {}),
38
+ ...(credits !== undefined ? { credits } : {}),
38
39
  };
39
40
  }
40
41
 
@@ -149,8 +149,14 @@ export const openRouterStatusAdapter: StatusAdapter = {
149
149
  unit: "USD",
150
150
  };
151
151
  } catch (error) {
152
- // Safe fallback: key credits, free-tier, and key-level limit still display.
153
- if (!(error instanceof ProviderDataError)) throw error;
152
+ // /credits requires a management key, so permission-shaped failures
153
+ // degrade to the key payload; real failures must still surface.
154
+ if (
155
+ !(error instanceof ProviderDataError) ||
156
+ (error.httpStatus !== 401 && error.httpStatus !== 403 && error.httpStatus !== 404)
157
+ ) {
158
+ throw error;
159
+ }
154
160
  }
155
161
 
156
162
  return {