@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.
- package/CHANGELOG.md +11 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/README.zh-CN.md +61 -0
- package/SECURITY.md +36 -0
- package/SUPPORT.md +25 -0
- package/core/adapter-extensions.ts +175 -0
- package/core/adapter-protocol.ts +120 -0
- package/core/adapter-validation.ts +241 -0
- package/core/deadline.ts +78 -0
- package/core/definition.ts +64 -0
- package/core/errors.ts +38 -0
- package/core/extension.ts +20 -0
- package/core/host.ts +462 -0
- package/core/live-check-manager.ts +263 -0
- package/core/official-pricing.ts +881 -0
- package/core/opencode-preflight.ts +66 -0
- package/core/preflight-manager.ts +251 -0
- package/core/pricing-adjustments.ts +118 -0
- package/core/provider-registration.ts +261 -0
- package/core/retry-after.ts +24 -0
- package/core/runtime-config.ts +95 -0
- package/core/runtime.ts +473 -0
- package/core/status-manager.ts +332 -0
- package/core/status-report.ts +592 -0
- package/core/tuner-manager.ts +34 -0
- package/core/types.ts +175 -0
- package/index.ts +108 -0
- package/package.json +81 -0
- package/preflight/charm-hyper.ts +62 -0
- package/preflight/deepseek.ts +73 -0
- package/preflight/google.ts +89 -0
- package/preflight/openai-codex.ts +88 -0
- package/preflight/opencode-go.ts +27 -0
- package/preflight/opencode.ts +27 -0
- package/providers/charm-hyper/constants.ts +31 -0
- package/providers/charm-hyper/oauth.ts +360 -0
- package/providers/charm-hyper.ts +536 -0
- package/status/charm-hyper.ts +76 -0
- package/status/deepseek.ts +102 -0
- package/status/openai-codex.ts +224 -0
- package/status/opencode-go.ts +133 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { definePreflightExtension } from "../core/adapter-extensions.ts";
|
|
2
|
+
import { createOpenCodeCatalogPreflightAdapter } from "../core/opencode-preflight.ts";
|
|
3
|
+
import type { PreflightAdapter } from "../core/preflight-manager.ts";
|
|
4
|
+
|
|
5
|
+
export const OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models";
|
|
6
|
+
|
|
7
|
+
export function createOpenCodeGoPreflightAdapter(requestTimeoutMs: number): PreflightAdapter {
|
|
8
|
+
return createOpenCodeCatalogPreflightAdapter(
|
|
9
|
+
{
|
|
10
|
+
id: "opencode-go-preflight",
|
|
11
|
+
providerId: "opencode-go",
|
|
12
|
+
name: "OpenCode Go",
|
|
13
|
+
modelsUrl: OPENCODE_GO_MODELS_URL,
|
|
14
|
+
},
|
|
15
|
+
requestTimeoutMs,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const openCodeGoPreflightAdapter = createOpenCodeGoPreflightAdapter(8_000);
|
|
20
|
+
|
|
21
|
+
const openCodeGoPreflightExtension = definePreflightExtension({
|
|
22
|
+
id: "opencode-go-preflight",
|
|
23
|
+
providerId: "opencode-go",
|
|
24
|
+
create: ({ statusRequestTimeoutMs }) => createOpenCodeGoPreflightAdapter(statusRequestTimeoutMs),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default openCodeGoPreflightExtension;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { definePreflightExtension } from "../core/adapter-extensions.ts";
|
|
2
|
+
import { createOpenCodeCatalogPreflightAdapter } from "../core/opencode-preflight.ts";
|
|
3
|
+
import type { PreflightAdapter } from "../core/preflight-manager.ts";
|
|
4
|
+
|
|
5
|
+
export const OPENCODE_MODELS_URL = "https://opencode.ai/zen/v1/models";
|
|
6
|
+
|
|
7
|
+
export function createOpenCodePreflightAdapter(requestTimeoutMs: number): PreflightAdapter {
|
|
8
|
+
return createOpenCodeCatalogPreflightAdapter(
|
|
9
|
+
{
|
|
10
|
+
id: "opencode-preflight",
|
|
11
|
+
providerId: "opencode",
|
|
12
|
+
name: "OpenCode Zen",
|
|
13
|
+
modelsUrl: OPENCODE_MODELS_URL,
|
|
14
|
+
},
|
|
15
|
+
requestTimeoutMs,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const openCodePreflightAdapter = createOpenCodePreflightAdapter(8_000);
|
|
20
|
+
|
|
21
|
+
const openCodePreflightExtension = definePreflightExtension({
|
|
22
|
+
id: "opencode-preflight",
|
|
23
|
+
providerId: "opencode",
|
|
24
|
+
create: ({ statusRequestTimeoutMs }) => createOpenCodePreflightAdapter(statusRequestTimeoutMs),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export default openCodePreflightExtension;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const packageIdentity = parsePackageIdentity(require("../../package.json"));
|
|
5
|
+
|
|
6
|
+
export const HYPER_ROOT_URL = "https://hyper.charm.land";
|
|
7
|
+
export const HYPER_BASE_URL = `${HYPER_ROOT_URL}/v1`;
|
|
8
|
+
export const HYPER_USER_AGENT = `${packageIdentity.name}/${packageIdentity.version}`;
|
|
9
|
+
|
|
10
|
+
export function hyperJsonHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
|
11
|
+
return {
|
|
12
|
+
Accept: "application/json",
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"User-Agent": HYPER_USER_AGENT,
|
|
15
|
+
...headers,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parsePackageIdentity(value: unknown): { name: string; version: string } {
|
|
20
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
21
|
+
throw new Error("@hyav/pi-provider package.json must contain an object");
|
|
22
|
+
}
|
|
23
|
+
const packageJson = value as Record<string, unknown>;
|
|
24
|
+
if (typeof packageJson.name !== "string" || packageJson.name.trim() === "") {
|
|
25
|
+
throw new Error("@hyav/pi-provider package.json must contain a name");
|
|
26
|
+
}
|
|
27
|
+
if (typeof packageJson.version !== "string" || packageJson.version.trim() === "") {
|
|
28
|
+
throw new Error("@hyav/pi-provider package.json must contain a version");
|
|
29
|
+
}
|
|
30
|
+
return { name: packageJson.name, version: packageJson.version };
|
|
31
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { withDeadline } from "../../core/deadline.ts";
|
|
3
|
+
import type { ProviderDefinition } from "../../core/types.ts";
|
|
4
|
+
import { HYPER_ROOT_URL, hyperJsonHeaders } from "./constants.ts";
|
|
5
|
+
|
|
6
|
+
const DEVICE_POLL_INTERVAL_SECONDS = 5;
|
|
7
|
+
const TOKEN_EXPIRY_BUFFER_MS = 30_000;
|
|
8
|
+
const OAUTH_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5_000;
|
|
10
|
+
const MINIMUM_POLL_INTERVAL_MS = 1_000;
|
|
11
|
+
|
|
12
|
+
const DEVICE_AUTH_URL = `${HYPER_ROOT_URL}/device/auth`;
|
|
13
|
+
const TOKEN_EXCHANGE_URL = `${HYPER_ROOT_URL}/token/exchange`;
|
|
14
|
+
|
|
15
|
+
type HyperOAuth = NonNullable<ProviderDefinition["oauth"]>;
|
|
16
|
+
type OAuthCredentials = Parameters<HyperOAuth["getApiKey"]>[0];
|
|
17
|
+
type DeviceAuthResponse = {
|
|
18
|
+
deviceCode: string;
|
|
19
|
+
expiresInSeconds: number;
|
|
20
|
+
userCode: string;
|
|
21
|
+
verificationUrl: string;
|
|
22
|
+
intervalSeconds: number;
|
|
23
|
+
};
|
|
24
|
+
type DevicePollSuccess = { refreshToken: string; teamName: string };
|
|
25
|
+
type DevicePollResult =
|
|
26
|
+
| { status: "pending" }
|
|
27
|
+
| { status: "slow_down" }
|
|
28
|
+
| { status: "failed"; message: string }
|
|
29
|
+
| { status: "complete"; value: DevicePollSuccess };
|
|
30
|
+
type TokenExchangeResponse = {
|
|
31
|
+
accessToken: string;
|
|
32
|
+
refreshToken: string;
|
|
33
|
+
expiresInSeconds?: number;
|
|
34
|
+
expiresAtSeconds?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
class HyperOAuthHttpError extends Error {
|
|
38
|
+
readonly name = "HyperOAuthHttpError";
|
|
39
|
+
readonly #payload: unknown;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
readonly status: number,
|
|
43
|
+
payload: unknown,
|
|
44
|
+
) {
|
|
45
|
+
super(`Charm Hyper OAuth request failed with HTTP ${status}`);
|
|
46
|
+
this.#payload = payload;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
matchesPayload(predicate: (payload: unknown) => boolean): boolean {
|
|
50
|
+
return predicate(this.#payload);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nonEmptyString(value: unknown): value is string {
|
|
59
|
+
return typeof value === "string" && value.trim() !== "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function positiveInteger(value: unknown): value is number {
|
|
63
|
+
return typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
67
|
+
const allowedKeys = new Set(allowed);
|
|
68
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchJsonResponse(
|
|
72
|
+
fetchFn: typeof globalThis.fetch,
|
|
73
|
+
url: string,
|
|
74
|
+
init: RequestInit,
|
|
75
|
+
externalSignal: AbortSignal | undefined,
|
|
76
|
+
): Promise<{ status: number; ok: boolean; payload: unknown }> {
|
|
77
|
+
return withDeadline(
|
|
78
|
+
async (signal) => {
|
|
79
|
+
const response = await fetchFn(url, { ...init, signal });
|
|
80
|
+
let payload: unknown;
|
|
81
|
+
try {
|
|
82
|
+
payload = await response.json();
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`Charm Hyper OAuth returned invalid JSON from ${url}`);
|
|
85
|
+
}
|
|
86
|
+
return { status: response.status, ok: response.ok, payload };
|
|
87
|
+
},
|
|
88
|
+
OAUTH_REQUEST_TIMEOUT_MS,
|
|
89
|
+
externalSignal,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchJson(
|
|
94
|
+
fetchFn: typeof globalThis.fetch,
|
|
95
|
+
url: string,
|
|
96
|
+
init: RequestInit,
|
|
97
|
+
externalSignal?: AbortSignal,
|
|
98
|
+
): Promise<unknown> {
|
|
99
|
+
const response = await fetchJsonResponse(fetchFn, url, init, externalSignal);
|
|
100
|
+
if (!response.ok) throw new HyperOAuthHttpError(response.status, response.payload);
|
|
101
|
+
return response.payload;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseDeviceAuthResponse(payload: unknown): DeviceAuthResponse {
|
|
105
|
+
if (
|
|
106
|
+
!isRecord(payload) ||
|
|
107
|
+
!hasOnlyKeys(payload, ["device_code", "expires_in", "user_code", "verification_url", "interval"]) ||
|
|
108
|
+
!nonEmptyString(payload.device_code) ||
|
|
109
|
+
!positiveInteger(payload.expires_in) ||
|
|
110
|
+
!nonEmptyString(payload.user_code) ||
|
|
111
|
+
!nonEmptyString(payload.verification_url) ||
|
|
112
|
+
(payload.interval !== undefined && !positiveInteger(payload.interval))
|
|
113
|
+
) {
|
|
114
|
+
throw new Error("Charm Hyper device auth response is invalid");
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
deviceCode: payload.device_code,
|
|
118
|
+
expiresInSeconds: payload.expires_in,
|
|
119
|
+
userCode: payload.user_code,
|
|
120
|
+
verificationUrl: payload.verification_url,
|
|
121
|
+
intervalSeconds: payload.interval ?? DEVICE_POLL_INTERVAL_SECONDS,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseDevicePollResponse(payload: unknown): DevicePollResult {
|
|
126
|
+
if (
|
|
127
|
+
isRecord(payload) &&
|
|
128
|
+
hasOnlyKeys(payload, ["refresh_token", "team_id", "team_name", "user_id"]) &&
|
|
129
|
+
nonEmptyString(payload.refresh_token) &&
|
|
130
|
+
nonEmptyString(payload.team_name) &&
|
|
131
|
+
nonEmptyString(payload.team_id) &&
|
|
132
|
+
nonEmptyString(payload.user_id)
|
|
133
|
+
) {
|
|
134
|
+
return { status: "complete", value: { refreshToken: payload.refresh_token, teamName: payload.team_name } };
|
|
135
|
+
}
|
|
136
|
+
if (!isRecord(payload) || !hasOnlyKeys(payload, ["error", "error_description"]) || !nonEmptyString(payload.error)) {
|
|
137
|
+
throw new Error("Charm Hyper device token response is invalid");
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
payload.error !== "authorization_pending" &&
|
|
141
|
+
payload.error !== "slow_down" &&
|
|
142
|
+
payload.error !== "access_denied" &&
|
|
143
|
+
payload.error !== "expired_token" &&
|
|
144
|
+
payload.error !== "invalid_request" &&
|
|
145
|
+
payload.error !== "invalid_grant"
|
|
146
|
+
) {
|
|
147
|
+
throw new Error("Charm Hyper device token response is invalid");
|
|
148
|
+
}
|
|
149
|
+
if (payload.error_description !== undefined && !nonEmptyString(payload.error_description)) {
|
|
150
|
+
throw new Error("Charm Hyper device token response is invalid");
|
|
151
|
+
}
|
|
152
|
+
if (payload.error === "authorization_pending") return { status: "pending" };
|
|
153
|
+
if (payload.error === "slow_down") return { status: "slow_down" };
|
|
154
|
+
const description = nonEmptyString(payload.error_description) ? payload.error_description : payload.error;
|
|
155
|
+
return { status: "failed", message: `Charm Hyper device authorization failed: ${description}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseTokenExchangeResponse(payload: unknown): TokenExchangeResponse {
|
|
159
|
+
if (
|
|
160
|
+
!isRecord(payload) ||
|
|
161
|
+
!nonEmptyString(payload.access_token) ||
|
|
162
|
+
!nonEmptyString(payload.token_type) ||
|
|
163
|
+
!nonEmptyString(payload.refresh_token) ||
|
|
164
|
+
!nonEmptyString(payload.expiry)
|
|
165
|
+
) {
|
|
166
|
+
throw new Error("Charm Hyper token exchange response is invalid");
|
|
167
|
+
}
|
|
168
|
+
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
|
+
) {
|
|
174
|
+
throw new Error("Charm Hyper token exchange response has an invalid expiry");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
accessToken: payload.access_token,
|
|
178
|
+
refreshToken: payload.refresh_token,
|
|
179
|
+
expiresInSeconds: payload.expires_in,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
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
|
+
) {
|
|
187
|
+
throw new Error("Charm Hyper token exchange response has an invalid expiry");
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
accessToken: payload.access_token,
|
|
191
|
+
refreshToken: payload.refresh_token,
|
|
192
|
+
expiresAtSeconds: payload.expires_at,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
throw new Error("Charm Hyper token exchange response has an invalid expiry");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function initiateDeviceAuth(
|
|
199
|
+
fetchFn: typeof globalThis.fetch,
|
|
200
|
+
signal: AbortSignal | undefined,
|
|
201
|
+
): Promise<DeviceAuthResponse> {
|
|
202
|
+
const payload = await fetchJson(
|
|
203
|
+
fetchFn,
|
|
204
|
+
DEVICE_AUTH_URL,
|
|
205
|
+
{
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: hyperJsonHeaders(),
|
|
208
|
+
body: JSON.stringify({ device_name: deviceName() }),
|
|
209
|
+
},
|
|
210
|
+
signal,
|
|
211
|
+
);
|
|
212
|
+
return parseDeviceAuthResponse(payload);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function deviceName(): string {
|
|
216
|
+
const host = hostname();
|
|
217
|
+
return host ? `Pi (${host})` : "Pi";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function pollDeviceAuth(
|
|
221
|
+
fetchFn: typeof globalThis.fetch,
|
|
222
|
+
deviceAuth: DeviceAuthResponse,
|
|
223
|
+
now: () => number,
|
|
224
|
+
signal: AbortSignal | undefined,
|
|
225
|
+
): Promise<DevicePollSuccess> {
|
|
226
|
+
const deadline = now() + deviceAuth.expiresInSeconds * 1_000;
|
|
227
|
+
let intervalMs = Math.max(MINIMUM_POLL_INTERVAL_MS, Math.floor(deviceAuth.intervalSeconds * 1_000));
|
|
228
|
+
let slowDownResponses = 0;
|
|
229
|
+
|
|
230
|
+
while (now() < deadline) {
|
|
231
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
232
|
+
const response = await fetchJsonResponse(
|
|
233
|
+
fetchFn,
|
|
234
|
+
`${DEVICE_AUTH_URL}/${encodeURIComponent(deviceAuth.deviceCode)}`,
|
|
235
|
+
{ headers: hyperJsonHeaders() },
|
|
236
|
+
signal,
|
|
237
|
+
);
|
|
238
|
+
const result = parseDevicePollResponse(response.payload);
|
|
239
|
+
if (result.status === "complete") return result.value;
|
|
240
|
+
if (result.status === "failed") throw new Error(result.message);
|
|
241
|
+
if (result.status === "slow_down") {
|
|
242
|
+
slowDownResponses++;
|
|
243
|
+
intervalMs += SLOW_DOWN_INTERVAL_INCREMENT_MS;
|
|
244
|
+
}
|
|
245
|
+
const remainingMs = deadline - now();
|
|
246
|
+
if (remainingMs <= 0) break;
|
|
247
|
+
await abortableSleep(Math.min(intervalMs, remainingMs), signal);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
throw new Error(
|
|
251
|
+
slowDownResponses > 0
|
|
252
|
+
? "Charm Hyper device flow timed out after slow_down responses"
|
|
253
|
+
: "Charm Hyper device flow timed out",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
if (signal?.aborted) {
|
|
260
|
+
reject(new Error("Login cancelled"));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const timeout = setTimeout(() => {
|
|
264
|
+
signal?.removeEventListener("abort", onAbort);
|
|
265
|
+
resolve();
|
|
266
|
+
}, ms);
|
|
267
|
+
const onAbort = () => {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
reject(new Error("Login cancelled"));
|
|
270
|
+
};
|
|
271
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function exchangeRefreshToken(
|
|
276
|
+
fetchFn: typeof globalThis.fetch,
|
|
277
|
+
refreshToken: string,
|
|
278
|
+
signal: AbortSignal | undefined,
|
|
279
|
+
): Promise<TokenExchangeResponse> {
|
|
280
|
+
const payload = await fetchJson(
|
|
281
|
+
fetchFn,
|
|
282
|
+
TOKEN_EXCHANGE_URL,
|
|
283
|
+
{
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: hyperJsonHeaders(),
|
|
286
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
287
|
+
},
|
|
288
|
+
signal,
|
|
289
|
+
);
|
|
290
|
+
return parseTokenExchangeResponse(payload);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function tokenExpiresAtMs(token: TokenExchangeResponse, now: () => number): number {
|
|
294
|
+
const currentTime = now();
|
|
295
|
+
const expiresAt =
|
|
296
|
+
token.expiresInSeconds !== undefined
|
|
297
|
+
? currentTime + token.expiresInSeconds * 1_000
|
|
298
|
+
: (token.expiresAtSeconds ?? 0) * 1_000;
|
|
299
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= currentTime) {
|
|
300
|
+
throw new Error("Charm Hyper token exchange response contains an expired token");
|
|
301
|
+
}
|
|
302
|
+
const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, Math.floor((expiresAt - currentTime) / 2));
|
|
303
|
+
return expiresAt - bufferMs;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tokenToCredentials(
|
|
307
|
+
token: TokenExchangeResponse,
|
|
308
|
+
fallbackRefreshToken: string,
|
|
309
|
+
now: () => number,
|
|
310
|
+
metadata: { teamName?: string } = {},
|
|
311
|
+
): OAuthCredentials {
|
|
312
|
+
return {
|
|
313
|
+
type: "oauth",
|
|
314
|
+
refresh: token.refreshToken || fallbackRefreshToken,
|
|
315
|
+
access: token.accessToken,
|
|
316
|
+
expires: tokenExpiresAtMs(token, now),
|
|
317
|
+
...metadata,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isRejectedRefreshToken(error: unknown): error is HyperOAuthHttpError {
|
|
322
|
+
return (
|
|
323
|
+
error instanceof HyperOAuthHttpError &&
|
|
324
|
+
error.status === 401 &&
|
|
325
|
+
error.matchesPayload((payload) => isRecord(payload) && payload.error === "could not get refresh token: not found")
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function createCharmHyperOAuth(fetchFn: typeof globalThis.fetch, now: () => number = Date.now): HyperOAuth {
|
|
330
|
+
return {
|
|
331
|
+
name: "Charm Hyper",
|
|
332
|
+
login: async (callbacks) => {
|
|
333
|
+
const deviceAuth = await initiateDeviceAuth(fetchFn, callbacks.signal);
|
|
334
|
+
callbacks.onDeviceCode({
|
|
335
|
+
userCode: deviceAuth.userCode,
|
|
336
|
+
verificationUri: deviceAuth.verificationUrl,
|
|
337
|
+
intervalSeconds: deviceAuth.intervalSeconds,
|
|
338
|
+
expiresInSeconds: deviceAuth.expiresInSeconds,
|
|
339
|
+
});
|
|
340
|
+
const deviceToken = await pollDeviceAuth(fetchFn, deviceAuth, now, callbacks.signal);
|
|
341
|
+
const token = await exchangeRefreshToken(fetchFn, deviceToken.refreshToken, callbacks.signal);
|
|
342
|
+
return tokenToCredentials(token, deviceToken.refreshToken, now, { teamName: deviceToken.teamName });
|
|
343
|
+
},
|
|
344
|
+
refreshToken: async (credentials, signal) => {
|
|
345
|
+
try {
|
|
346
|
+
const token = await exchangeRefreshToken(fetchFn, credentials.refresh, signal);
|
|
347
|
+
const teamName = typeof credentials.teamName === "string" ? credentials.teamName : undefined;
|
|
348
|
+
return tokenToCredentials(token, credentials.refresh, now, teamName ? { teamName } : {});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (isRejectedRefreshToken(error)) {
|
|
351
|
+
throw new Error("Your Charm Hyper session is no longer valid. Run /login to re-authenticate.", {
|
|
352
|
+
cause: error,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
getApiKey: (credentials) => credentials.access,
|
|
359
|
+
};
|
|
360
|
+
}
|