@hyav/pi-provider 0.1.7 → 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 +12 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/core/adapter-validation.ts +14 -1
- package/core/host.ts +5 -5
- package/core/model-catalog.ts +277 -0
- package/core/official-pricing.ts +30 -6
- package/core/provider-registration.ts +85 -27
- package/core/public-adapters.ts +7 -0
- package/core/runtime.ts +7 -5
- package/core/status-report.ts +19 -1
- package/core/types.ts +10 -2
- package/index.ts +10 -0
- package/package.json +1 -1
- package/providers/charm-hyper.ts +83 -227
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
5
17
|
## 0.1.7 - 2026-08-24
|
|
6
18
|
|
|
7
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`.
|
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
|
|
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
|
|
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
|
|
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 !== "
|
|
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 {
|
|
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
|
-
?
|
|
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
|
|
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
|
|
468
|
+
function fetchOfficialModelMetadataForHost(
|
|
469
469
|
runtime: PiProviderDependencies,
|
|
470
470
|
options: { allowNetwork?: boolean; signal?: AbortSignal } = {},
|
|
471
471
|
) {
|
|
472
|
-
return
|
|
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
|
+
}
|
package/core/official-pricing.ts
CHANGED
|
@@ -41,7 +41,7 @@ export interface OfficialModelMeta {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export interface
|
|
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
|
|
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:
|
|
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
|
|
887
|
+
export function applyOfficialModelMetadata(
|
|
872
888
|
models: ProviderModelDraft[],
|
|
873
|
-
|
|
889
|
+
dynamicMetadata: Record<string, OfficialModelMeta | ProviderCost> = {},
|
|
874
890
|
): ProviderModelDraft[] {
|
|
875
891
|
return models.map((model) => {
|
|
876
|
-
const meta = findOfficialMeta(model.id,
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
maxTokens:
|
|
139
|
-
originalDraft?.maxTokens
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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(
|
|
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,
|
package/core/public-adapters.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
? {
|
|
@@ -467,8 +469,8 @@ export function createPiProviderRuntime(
|
|
|
467
469
|
let pricingRefreshController: AbortController | undefined;
|
|
468
470
|
const cachePath =
|
|
469
471
|
runtime.officialPricingUrl === OPENROUTER_MODELS_URL ? runtime.openRouterMetadataCachePath : undefined;
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
+
const fetchMetadata = (options: { allowNetwork?: boolean; signal?: AbortSignal } = {}) =>
|
|
473
|
+
fetchOfficialModelMetadata(
|
|
472
474
|
runtime.fetch,
|
|
473
475
|
runtime.officialPricingUrl,
|
|
474
476
|
runtime.officialPricingTimeoutMs,
|
|
@@ -478,7 +480,7 @@ export function createPiProviderRuntime(
|
|
|
478
480
|
{ cachePath, ...options },
|
|
479
481
|
);
|
|
480
482
|
const officialPricingPromise = runtime.enableOfficialPricingFallback
|
|
481
|
-
?
|
|
483
|
+
? fetchMetadata({ allowNetwork: false })
|
|
482
484
|
: Promise.resolve({});
|
|
483
485
|
const definitionPromise = loadDefinition(runtime);
|
|
484
486
|
const [officialPricing, definition] = await Promise.all([officialPricingPromise, definitionPromise]);
|
|
@@ -492,7 +494,7 @@ export function createPiProviderRuntime(
|
|
|
492
494
|
}
|
|
493
495
|
const controller = new AbortController();
|
|
494
496
|
pricingRefreshController = controller;
|
|
495
|
-
void
|
|
497
|
+
void fetchMetadata({ signal: controller.signal })
|
|
496
498
|
.then((snapshot) => {
|
|
497
499
|
if (disposed || controller.signal.aborted) return;
|
|
498
500
|
installedController?.updateOfficialPricing?.(snapshot);
|
package/core/status-report.ts
CHANGED
|
@@ -177,7 +177,9 @@ function formatModelFieldSource(source: ModelFieldSource | undefined): string {
|
|
|
177
177
|
? "OpenRouter"
|
|
178
178
|
: source === "fallback"
|
|
179
179
|
? "Provider fallback"
|
|
180
|
-
:
|
|
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.
|
|
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",
|
package/providers/charm-hyper.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
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
|
|
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>):
|
|
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)
|
|
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)
|
|
171
|
+
if (model === undefined) {
|
|
172
|
+
rejectedCount++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
252
175
|
const normalizedId = model.id.toLowerCase();
|
|
253
|
-
if (seenIds.has(normalizedId))
|
|
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>):
|
|
261
|
-
if (!Array.isArray(payload.data)
|
|
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() === "")
|
|
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 (
|
|
270
|
-
|
|
271
|
-
|
|
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)
|
|
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
|
|
314
|
-
pricingSource: cost ? "provider" : "fallback",
|
|
261
|
+
...(cost ? { cost, pricingSource: "provider" as const } : {}),
|
|
315
262
|
compat: { ...baseHyperCompat, supportsReasoningEffort },
|
|
316
263
|
};
|
|
317
|
-
models.push(
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
models
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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({
|