@hyav/pi-provider 0.1.0-oidc-bootstrap.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CONTRIBUTING.md +63 -0
  3. package/LICENSE +21 -0
  4. package/README.md +61 -0
  5. package/README.zh-CN.md +61 -0
  6. package/SECURITY.md +36 -0
  7. package/SUPPORT.md +25 -0
  8. package/core/adapter-extensions.ts +175 -0
  9. package/core/adapter-protocol.ts +120 -0
  10. package/core/adapter-validation.ts +241 -0
  11. package/core/deadline.ts +78 -0
  12. package/core/definition.ts +64 -0
  13. package/core/errors.ts +38 -0
  14. package/core/extension.ts +20 -0
  15. package/core/host.ts +462 -0
  16. package/core/live-check-manager.ts +263 -0
  17. package/core/official-pricing.ts +881 -0
  18. package/core/opencode-preflight.ts +66 -0
  19. package/core/preflight-manager.ts +251 -0
  20. package/core/pricing-adjustments.ts +118 -0
  21. package/core/provider-registration.ts +261 -0
  22. package/core/retry-after.ts +24 -0
  23. package/core/runtime-config.ts +95 -0
  24. package/core/runtime.ts +473 -0
  25. package/core/status-manager.ts +332 -0
  26. package/core/status-report.ts +592 -0
  27. package/core/tuner-manager.ts +34 -0
  28. package/core/types.ts +175 -0
  29. package/index.ts +108 -0
  30. package/package.json +81 -0
  31. package/preflight/charm-hyper.ts +62 -0
  32. package/preflight/deepseek.ts +73 -0
  33. package/preflight/google.ts +89 -0
  34. package/preflight/openai-codex.ts +88 -0
  35. package/preflight/opencode-go.ts +27 -0
  36. package/preflight/opencode.ts +27 -0
  37. package/providers/charm-hyper/constants.ts +31 -0
  38. package/providers/charm-hyper/oauth.ts +360 -0
  39. package/providers/charm-hyper.ts +536 -0
  40. package/status/charm-hyper.ts +76 -0
  41. package/status/deepseek.ts +102 -0
  42. package/status/openai-codex.ts +224 -0
  43. package/status/opencode-go.ts +133 -0
@@ -0,0 +1,332 @@
1
+ import { isValidTimeoutMs, withDeadline } from "./deadline.ts";
2
+ import { isProviderDataError, ProviderDataError } from "./errors.ts";
3
+ import type {
4
+ StatusAdapter,
5
+ StatusAmountEntry,
6
+ StatusEntry,
7
+ StatusSnapshot,
8
+ StatusTextEntry,
9
+ StatusWindowEntry,
10
+ } from "./types.ts";
11
+
12
+ export interface StatusContextLike {
13
+ model?: { provider?: string };
14
+ modelRegistry: {
15
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
16
+ };
17
+ /** Optional credential identity used to isolate cached account data. */
18
+ getCredentialKey?: () => Promise<string | undefined>;
19
+ /** Optional non-secret credential metadata for provider-specific account labels. */
20
+ getCredentialMetadata?: () => unknown;
21
+ }
22
+
23
+ export interface StatusErrorState {
24
+ code: string;
25
+ httpStatus?: number;
26
+ retryAt?: number;
27
+ }
28
+
29
+ export interface StatusDiagnostics {
30
+ snapshot?: StatusSnapshot;
31
+ pending: boolean;
32
+ lastError?: StatusErrorState;
33
+ }
34
+
35
+ export type StatusUpdateResult = "cached" | "refreshed" | "failed" | "skipped";
36
+
37
+ interface PendingStatus {
38
+ promise: Promise<StatusSnapshot>;
39
+ generation: number;
40
+ cancel: () => void;
41
+ }
42
+
43
+ interface StatusState {
44
+ snapshot?: StatusSnapshot;
45
+ pending?: PendingStatus;
46
+ lastError?: StatusErrorState;
47
+ credentialKey?: string;
48
+ credentialKeyKnown: boolean;
49
+ generation: number;
50
+ }
51
+
52
+ function isRecord(value: unknown): value is Record<string, unknown> {
53
+ return value !== null && typeof value === "object" && !Array.isArray(value);
54
+ }
55
+
56
+ function isErrorNamed(error: unknown, name: string): boolean {
57
+ return error !== null && typeof error === "object" && "name" in error && error.name === name;
58
+ }
59
+
60
+ const MAX_STATUS_ENTRIES = 128;
61
+ const MAX_STATUS_TEXT_LENGTH = 1_024;
62
+
63
+ function hasSafeText(value: unknown): value is string {
64
+ return (
65
+ typeof value === "string" &&
66
+ value.trim() !== "" &&
67
+ value.length <= MAX_STATUS_TEXT_LENGTH &&
68
+ !/[\u0000-\u001f\u007f]/.test(value)
69
+ );
70
+ }
71
+
72
+ function optionalFiniteNumber(value: unknown): number | undefined {
73
+ if (value === undefined) return undefined;
74
+ if (typeof value === "number" && Number.isFinite(value)) return value;
75
+ throw new ProviderDataError("Status adapter returned an invalid snapshot", "badjson");
76
+ }
77
+
78
+ function normalizeTextEntry(value: Record<string, unknown>): StatusTextEntry {
79
+ if (!hasSafeText(value.id) || !hasSafeText(value.label) || !hasSafeText(value.value)) {
80
+ throw new ProviderDataError("Status adapter returned an invalid text entry", "badjson");
81
+ }
82
+ return {
83
+ kind: "text",
84
+ id: value.id.trim(),
85
+ label: value.label.trim(),
86
+ value: value.value.trim(),
87
+ };
88
+ }
89
+
90
+ function normalizeAmountEntry(value: Record<string, unknown>): StatusAmountEntry {
91
+ if (!hasSafeText(value.id) || !hasSafeText(value.label) || !hasSafeText(value.unit)) {
92
+ throw new ProviderDataError("Status adapter returned an invalid amount entry", "badjson");
93
+ }
94
+ if (typeof value.value !== "number" || !Number.isFinite(value.value)) {
95
+ throw new ProviderDataError("Status adapter returned an invalid amount entry", "badjson");
96
+ }
97
+ return {
98
+ kind: "amount",
99
+ id: value.id.trim(),
100
+ label: value.label.trim(),
101
+ value: value.value,
102
+ unit: value.unit.trim(),
103
+ };
104
+ }
105
+
106
+ function normalizeWindowEntry(value: Record<string, unknown>): StatusWindowEntry {
107
+ if (!hasSafeText(value.id) || !hasSafeText(value.label)) {
108
+ throw new ProviderDataError("Status adapter returned an invalid window entry", "badjson");
109
+ }
110
+ if (
111
+ typeof value.remainingPercent !== "number" ||
112
+ !Number.isFinite(value.remainingPercent) ||
113
+ value.remainingPercent < 0 ||
114
+ value.remainingPercent > 100
115
+ ) {
116
+ throw new ProviderDataError("Status adapter returned an invalid window entry", "badjson");
117
+ }
118
+ const resetAt = optionalFiniteNumber(value.resetAt);
119
+ if (resetAt !== undefined && resetAt < 0) {
120
+ throw new ProviderDataError("Status adapter returned an invalid window entry", "badjson");
121
+ }
122
+ return {
123
+ kind: "window",
124
+ id: value.id.trim(),
125
+ label: value.label.trim(),
126
+ remainingPercent: value.remainingPercent,
127
+ ...(resetAt !== undefined ? { resetAt } : {}),
128
+ };
129
+ }
130
+
131
+ function normalizeEntry(value: unknown): StatusEntry {
132
+ if (!isRecord(value) || typeof value.kind !== "string") {
133
+ throw new ProviderDataError("Status adapter returned an invalid entry", "badjson");
134
+ }
135
+ switch (value.kind) {
136
+ case "text":
137
+ return normalizeTextEntry(value);
138
+ case "amount":
139
+ return normalizeAmountEntry(value);
140
+ case "window":
141
+ return normalizeWindowEntry(value);
142
+ default:
143
+ throw new ProviderDataError("Status adapter returned an unknown entry kind", "badjson");
144
+ }
145
+ }
146
+
147
+ function cloneEntry(entry: StatusEntry): StatusEntry {
148
+ return { ...entry };
149
+ }
150
+
151
+ export function normalizeStatusSnapshot(value: unknown): StatusSnapshot {
152
+ if (!isRecord(value) || !Array.isArray(value.entries)) {
153
+ throw new ProviderDataError("Status adapter returned an invalid snapshot", "badjson");
154
+ }
155
+ if (typeof value.updatedAt !== "number" || !Number.isFinite(value.updatedAt)) {
156
+ throw new ProviderDataError("Status adapter returned an invalid snapshot", "badjson");
157
+ }
158
+ if (value.entries.length > MAX_STATUS_ENTRIES) {
159
+ throw new ProviderDataError("Status adapter returned too many entries", "badjson");
160
+ }
161
+ const ids = new Set<string>();
162
+ const entries = value.entries.map((entry) => {
163
+ const normalized = normalizeEntry(entry);
164
+ if (ids.has(normalized.id)) {
165
+ throw new ProviderDataError("Status adapter returned duplicate entry IDs", "badjson");
166
+ }
167
+ ids.add(normalized.id);
168
+ return normalized;
169
+ });
170
+ return { entries, updatedAt: value.updatedAt };
171
+ }
172
+
173
+ function cloneSnapshot(snapshot: StatusSnapshot): StatusSnapshot {
174
+ return { updatedAt: snapshot.updatedAt, entries: snapshot.entries.map(cloneEntry) };
175
+ }
176
+
177
+ export class StatusManager {
178
+ private readonly states = new Map<string, StatusState>();
179
+
180
+ constructor(
181
+ private readonly adapters: StatusAdapter[],
182
+ private readonly fetchFn: typeof globalThis.fetch,
183
+ private readonly now: () => number,
184
+ ) {}
185
+
186
+ async update(ctx: StatusContextLike, options: { force?: boolean } = {}): Promise<StatusUpdateResult> {
187
+ const adapter = this.adapters.find(({ providerId }) => providerId === ctx.model?.provider);
188
+ if (!adapter) return "skipped";
189
+
190
+ const state = this.getState(adapter.providerId);
191
+ let credentialKey: string | undefined;
192
+ let credentialKeyKnown = false;
193
+ if (options.force && ctx.getCredentialKey) {
194
+ try {
195
+ credentialKey = await ctx.getCredentialKey();
196
+ credentialKeyKnown = true;
197
+ } catch {
198
+ // The adapter remains responsible for reporting credential resolution failures.
199
+ }
200
+ }
201
+ if (credentialKeyKnown && state.credentialKeyKnown && state.credentialKey !== credentialKey) {
202
+ state.generation++;
203
+ state.pending?.cancel();
204
+ state.pending = undefined;
205
+ state.snapshot = undefined;
206
+ state.lastError = undefined;
207
+ }
208
+ const now = this.now();
209
+ const snapshotAge = state.snapshot ? now - state.snapshot.updatedAt : undefined;
210
+ if (!options.force && snapshotAge !== undefined && snapshotAge >= 0 && snapshotAge < adapter.cacheTtlMs) {
211
+ return "cached";
212
+ }
213
+ const retryAt = state.lastError?.retryAt;
214
+ if (retryAt !== undefined && Number.isFinite(retryAt) && now < retryAt) return "skipped";
215
+ if (!isValidTimeoutMs(adapter.requestTimeoutMs)) {
216
+ state.lastError = { code: "config" };
217
+ return "failed";
218
+ }
219
+
220
+ let pending = state.pending;
221
+ if (!pending) {
222
+ const cancellation = new AbortController();
223
+ const generation = ++state.generation;
224
+ const promise = withDeadline(
225
+ (signal) =>
226
+ adapter.fetch({
227
+ fetch: this.fetchFn,
228
+ getApiKey: () => ctx.modelRegistry.getApiKeyForProvider(adapter.providerId),
229
+ ...(ctx.getCredentialMetadata ? { getCredentialMetadata: ctx.getCredentialMetadata } : {}),
230
+ now: this.now,
231
+ signal,
232
+ }),
233
+ adapter.requestTimeoutMs,
234
+ cancellation.signal,
235
+ );
236
+ pending = {
237
+ promise,
238
+ generation,
239
+ cancel: () => cancellation.abort(),
240
+ };
241
+ state.pending = pending;
242
+ }
243
+ const activePending = pending;
244
+ const { generation } = activePending;
245
+
246
+ try {
247
+ const snapshot = normalizeStatusSnapshot(await activePending.promise);
248
+ if (state.generation !== generation) return "skipped";
249
+ state.snapshot = snapshot;
250
+ state.lastError = undefined;
251
+ if (credentialKeyKnown) {
252
+ state.credentialKey = credentialKey;
253
+ state.credentialKeyKnown = true;
254
+ }
255
+ return "refreshed";
256
+ } catch (error) {
257
+ if (state.generation !== generation || isErrorNamed(error, "AbortError")) return "skipped";
258
+ const dataError = isProviderDataError(error) ? error : undefined;
259
+ const code = dataError?.code ?? (isErrorNamed(error, "TimeoutError") ? "timeout" : "fetch");
260
+ const retryAt =
261
+ dataError?.retryAt !== undefined && Number.isFinite(dataError.retryAt) ? dataError.retryAt : undefined;
262
+ const httpStatus = dataError?.httpStatus;
263
+ state.lastError = {
264
+ code,
265
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
266
+ ...(retryAt !== undefined ? { retryAt } : {}),
267
+ };
268
+ if (code === "timeout") {
269
+ state.generation++;
270
+ if (state.pending === activePending) state.pending = undefined;
271
+ }
272
+ return "failed";
273
+ } finally {
274
+ if (state.generation === generation && state.pending === activePending) state.pending = undefined;
275
+ }
276
+ }
277
+
278
+ async refresh(ctx: StatusContextLike): Promise<StatusUpdateResult> {
279
+ return await this.update(ctx, { force: true });
280
+ }
281
+
282
+ invalidate(provider: string | undefined): void {
283
+ if (!provider) return;
284
+ const state = this.states.get(provider);
285
+ if (!state) return;
286
+ state.generation++;
287
+ state.pending?.cancel();
288
+ state.pending = undefined;
289
+ state.snapshot = undefined;
290
+ state.lastError = undefined;
291
+ }
292
+
293
+ cancelAll(): void {
294
+ for (const state of this.states.values()) {
295
+ state.generation++;
296
+ state.pending?.cancel();
297
+ state.pending = undefined;
298
+ }
299
+ }
300
+
301
+ clear(provider?: string): void {
302
+ if (provider) {
303
+ this.invalidate(provider);
304
+ this.states.delete(provider);
305
+ } else {
306
+ this.cancelAll();
307
+ this.states.clear();
308
+ }
309
+ }
310
+
311
+ getSnapshot(provider: string): StatusSnapshot | undefined {
312
+ const snapshot = this.states.get(provider)?.snapshot;
313
+ return snapshot ? cloneSnapshot(snapshot) : undefined;
314
+ }
315
+
316
+ getDiagnostics(provider: string): StatusDiagnostics {
317
+ const state = this.states.get(provider);
318
+ return {
319
+ snapshot: state?.snapshot ? cloneSnapshot(state.snapshot) : undefined,
320
+ pending: state?.pending !== undefined,
321
+ lastError: state?.lastError ? { ...state.lastError } : undefined,
322
+ };
323
+ }
324
+
325
+ private getState(provider: string): StatusState {
326
+ const existing = this.states.get(provider);
327
+ if (existing) return existing;
328
+ const state: StatusState = { credentialKeyKnown: false, generation: 0 };
329
+ this.states.set(provider, state);
330
+ return state;
331
+ }
332
+ }