@lingara/api 0.0.0-reserved.0 → 0.1.0-alpha.6

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/dist/index.mjs ADDED
@@ -0,0 +1,907 @@
1
+ // src/errors.ts
2
+ var REDACTED = "[REDACTED]";
3
+ var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
4
+ function brand(name) {
5
+ return /* @__PURE__ */ Symbol.for(`lingara.error.${name}`);
6
+ }
7
+ function mark(target, name) {
8
+ Object.defineProperty(target, brand(name), { value: true, enumerable: false });
9
+ }
10
+ function hasBrand(value, name) {
11
+ return typeof value === "object" && value !== null && value[brand(name)] === true;
12
+ }
13
+ var LingaraError = class extends Error {
14
+ static [Symbol.hasInstance](value) {
15
+ return hasBrand(value, "LingaraError");
16
+ }
17
+ constructor(message, options) {
18
+ super(message, options);
19
+ this.name = new.target.name;
20
+ mark(this, "LingaraError");
21
+ }
22
+ /** The fields a caller reads, for `JSON.stringify` and inspection. */
23
+ toJSON() {
24
+ return { name: this.name, message: this.message, ...this.fields() };
25
+ }
26
+ [INSPECT]() {
27
+ return `${this.name}: ${this.message} ${JSON.stringify(this.fields())}`;
28
+ }
29
+ fields() {
30
+ return {};
31
+ }
32
+ };
33
+ var ApiError = class extends LingaraError {
34
+ static [Symbol.hasInstance](value) {
35
+ return hasBrand(value, "ApiError");
36
+ }
37
+ status;
38
+ code;
39
+ retryAfter;
40
+ planId;
41
+ servedVersion;
42
+ constructor(init) {
43
+ super(init.message);
44
+ mark(this, "ApiError");
45
+ this.status = init.status;
46
+ this.code = init.code;
47
+ if (init.retryAfter !== void 0) this.retryAfter = init.retryAfter;
48
+ if (init.planId !== void 0) this.planId = init.planId;
49
+ if (init.servedVersion !== void 0) this.servedVersion = init.servedVersion;
50
+ }
51
+ fields() {
52
+ const { status, code, retryAfter, planId, servedVersion } = this;
53
+ return { status, code, retryAfter, planId, servedVersion };
54
+ }
55
+ };
56
+ var OAuthError = class extends LingaraError {
57
+ static [Symbol.hasInstance](value) {
58
+ return hasBrand(value, "OAuthError");
59
+ }
60
+ status;
61
+ error;
62
+ description;
63
+ retryAfter;
64
+ constructor(init) {
65
+ super(init.description ? `${init.error}: ${init.description}` : init.error);
66
+ mark(this, "OAuthError");
67
+ this.status = init.status;
68
+ this.error = init.error;
69
+ if (init.description !== void 0) this.description = init.description;
70
+ if (init.retryAfter !== void 0) this.retryAfter = init.retryAfter;
71
+ }
72
+ fields() {
73
+ const { status, error, description, retryAfter } = this;
74
+ return { status, error, description, retryAfter };
75
+ }
76
+ };
77
+ var MaintenanceError = class extends LingaraError {
78
+ static [Symbol.hasInstance](value) {
79
+ return hasBrand(value, "MaintenanceError");
80
+ }
81
+ /** The response text, at most 1 KiB. */
82
+ body;
83
+ retryAfter;
84
+ constructor(body, retryAfter) {
85
+ super("the Lingara API is under maintenance");
86
+ mark(this, "MaintenanceError");
87
+ this.body = body;
88
+ if (retryAfter !== void 0) this.retryAfter = retryAfter;
89
+ }
90
+ fields() {
91
+ return { body: this.body, retryAfter: this.retryAfter };
92
+ }
93
+ };
94
+ var TransportError = class extends LingaraError {
95
+ static [Symbol.hasInstance](value) {
96
+ return hasBrand(value, "TransportError");
97
+ }
98
+ kind;
99
+ constructor(kind, cause) {
100
+ super(`transport failure: ${kind}`, cause === void 0 ? void 0 : { cause });
101
+ mark(this, "TransportError");
102
+ this.kind = kind;
103
+ }
104
+ fields() {
105
+ return { kind: this.kind };
106
+ }
107
+ };
108
+ var MAINTENANCE_BODY_BYTES = 1024;
109
+ async function errorFromResponse(res, ctx) {
110
+ const text = await res.text().catch(() => "");
111
+ if (res.status === 503 && !isJson(res)) {
112
+ return new MaintenanceError(truncateUtf8(text, MAINTENANCE_BODY_BYTES), ctx.retryAfter);
113
+ }
114
+ const body = parseObject(text);
115
+ return ctx.endpoint === "token" ? oauthError(res.status, body, ctx) : apiError(res.status, body, ctx);
116
+ }
117
+ function apiError(status, body, ctx) {
118
+ const code = body?.["code"];
119
+ const message = body?.["error"];
120
+ const envelope = typeof code === "string" && typeof message === "string";
121
+ return new ApiError({
122
+ status,
123
+ code: envelope ? code : `http_${status}`,
124
+ message: envelope ? message : `HTTP ${status}`,
125
+ retryAfter: ctx.retryAfter,
126
+ servedVersion: ctx.servedVersion
127
+ });
128
+ }
129
+ function oauthError(status, body, ctx) {
130
+ const error = body?.["error"];
131
+ if (typeof error !== "string") return new OAuthError({ status, error: `http_${status}`, retryAfter: ctx.retryAfter });
132
+ const description = body?.["error_description"];
133
+ return new OAuthError({
134
+ status,
135
+ error,
136
+ description: typeof description === "string" ? description : void 0,
137
+ retryAfter: ctx.retryAfter
138
+ });
139
+ }
140
+ function mediaType(res) {
141
+ return (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
142
+ }
143
+ function isJson(res) {
144
+ const type = mediaType(res);
145
+ return type === "application/json" || type.endsWith("+json");
146
+ }
147
+ function parseObject(text) {
148
+ try {
149
+ const value = JSON.parse(text);
150
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
151
+ } catch {
152
+ return void 0;
153
+ }
154
+ }
155
+ function truncateUtf8(text, maxBytes) {
156
+ const bytes = new TextEncoder().encode(text);
157
+ if (bytes.length <= maxBytes) return text;
158
+ return new TextDecoder().decode(bytes.slice(0, maxBytes)).replace(/�$/, "");
159
+ }
160
+ var TLS = /cert|ssl|tls|unable_to_verify/i;
161
+ var CONNECT = /refused|enotfound|eai_again|dns/i;
162
+ var RESET = /reset|epipe|socket|closed|aborted/i;
163
+ function transportKind(err, phase) {
164
+ const text = describe(err).join("\n");
165
+ if (TLS.test(text)) return "tls";
166
+ if (CONNECT.test(text)) return "connect";
167
+ if (RESET.test(text)) return "reset";
168
+ return phase === "fetch" ? "connect" : "reset";
169
+ }
170
+ function describe(err, depth = 0) {
171
+ if (typeof err !== "object" || err === null || depth > 4) return [];
172
+ const e = err;
173
+ const own = [e.code, e.message].filter((v) => typeof v === "string");
174
+ return [...own, ...describe(e.cause, depth + 1)];
175
+ }
176
+ function redactCause(err, secrets, depth = 0) {
177
+ const live = secrets.filter((s) => typeof s === "string" && s.length > 0);
178
+ if (typeof err === "string") return scrub(err, live);
179
+ if (typeof err !== "object" || err === null || depth > 4) return err;
180
+ return redactObject(err, live, depth);
181
+ }
182
+ function redactObject(e, secrets, depth) {
183
+ const copy = new Error(scrub(String(e.message ?? ""), secrets));
184
+ copy.name = typeof e.name === "string" ? e.name : "Error";
185
+ if (typeof e.stack === "string") copy.stack = scrub(e.stack, secrets);
186
+ if (typeof e.code === "string") Object.assign(copy, { code: e.code });
187
+ if (e.cause !== void 0) copy.cause = redactCause(e.cause, secrets, depth + 1);
188
+ return copy;
189
+ }
190
+ function scrub(text, secrets) {
191
+ return secrets.reduce((acc, s) => acc.split(s).join(REDACTED), text);
192
+ }
193
+
194
+ // src/generated/streams.ts
195
+ var STREAMS = {
196
+ generateVocabulary: {
197
+ method: "POST",
198
+ path: "/v1/vocab/stream",
199
+ requestBody: "VocabRequest",
200
+ union: "GenerateVocabularyEvent",
201
+ events: ["started", "item", "done", "error"],
202
+ ends: { done: "end", error: "raise" }
203
+ },
204
+ createLessonPlan: {
205
+ method: "POST",
206
+ path: "/v1/lesson-plans",
207
+ requestBody: "LessonPlanCreateRequest",
208
+ union: "CreateLessonPlanEvent",
209
+ events: ["started", "phase", "result", "error"],
210
+ ends: { result: "yield", error: "raise" }
211
+ },
212
+ streamLessonPlan: {
213
+ method: "GET",
214
+ path: "/v1/lesson-plans/{id}/stream",
215
+ requestBody: null,
216
+ union: "StreamLessonPlanEvent",
217
+ events: ["started", "phase", "result", "pending", "error"],
218
+ ends: { result: "yield", pending: "yield", error: "raise" }
219
+ },
220
+ sendTutorMessage: {
221
+ method: "POST",
222
+ path: "/v1/tutor/message",
223
+ requestBody: "TutorTurnRequest",
224
+ union: "SendTutorMessageEvent",
225
+ events: ["delta", "notice", "done", "error"],
226
+ ends: { done: "end", error: "raise" }
227
+ }
228
+ };
229
+
230
+ // src/retry.ts
231
+ var DELTA_SECONDS = /^\d+$/;
232
+ function parseRetryAfter(value, clock) {
233
+ if (value === null) return void 0;
234
+ const trimmed = value.trim();
235
+ if (DELTA_SECONDS.test(trimmed)) return Number(trimmed);
236
+ const at = Date.parse(trimmed);
237
+ if (Number.isNaN(at)) return void 0;
238
+ return Math.max(0, Math.ceil((at - clock.now()) / 1e3));
239
+ }
240
+ async function withRetries(policy, signal, attempt) {
241
+ for (let tries = 1; ; tries++) {
242
+ const res = await attempt();
243
+ const wait = retryWait(res, policy, tries);
244
+ if (wait === void 0) return res;
245
+ await res.body?.cancel().catch(() => void 0);
246
+ await policy.sleeper(wait * 1e3, signal);
247
+ }
248
+ }
249
+ function retryWait(res, policy, tries) {
250
+ if (res.status !== 429 && res.status !== 503) return void 0;
251
+ if (tries >= policy.maxAttempts) return void 0;
252
+ const seconds = parseRetryAfter(res.headers.get("retry-after"), policy.clock);
253
+ if (seconds === void 0 || seconds > policy.retryAfterCapSeconds) return void 0;
254
+ return seconds;
255
+ }
256
+ async function withTokenRetry(tokens, signal, send) {
257
+ const options = signal ? { signal } : {};
258
+ const first = await tokens.token(options);
259
+ const res = await send(first);
260
+ if (res.status !== 401) return res;
261
+ await res.body?.cancel().catch(() => void 0);
262
+ tokens.invalidate(first);
263
+ return send(await tokens.token(options));
264
+ }
265
+
266
+ // src/seams.ts
267
+ var systemClock = { now: () => Date.now() };
268
+ var realSleeper = (ms, signal) => raceAbort(
269
+ new Promise((resolve) => {
270
+ const timer = setTimeout(resolve, ms);
271
+ signal?.addEventListener("abort", () => clearTimeout(timer), { once: true });
272
+ }),
273
+ signal
274
+ );
275
+ function raceAbort(promise, signal) {
276
+ if (!signal) return promise;
277
+ if (signal.aborted) {
278
+ promise.catch(() => void 0);
279
+ return Promise.reject(signal.reason);
280
+ }
281
+ return new Promise((resolve, reject) => {
282
+ const onAbort = () => reject(signal.reason);
283
+ signal.addEventListener("abort", onAbort, { once: true });
284
+ promise.then(
285
+ (value) => {
286
+ signal.removeEventListener("abort", onAbort);
287
+ resolve(value);
288
+ },
289
+ (error) => {
290
+ signal.removeEventListener("abort", onAbort);
291
+ reject(error);
292
+ }
293
+ );
294
+ });
295
+ }
296
+
297
+ // src/sse.ts
298
+ var SseParser = class {
299
+ #buffer = "";
300
+ #event = "";
301
+ #data = [];
302
+ #hasData = false;
303
+ // A chunk ended on `\r`: a `\n` opening the next chunk is the same line end.
304
+ #skipLf = false;
305
+ /** Feeds decoded text; returns every frame it completed. */
306
+ push(text) {
307
+ let input = text;
308
+ if (this.#skipLf && input.length > 0) {
309
+ if (input.startsWith("\n")) input = input.slice(1);
310
+ this.#skipLf = false;
311
+ }
312
+ const frames = [];
313
+ const s = this.#buffer + input;
314
+ let start = 0;
315
+ for (; ; ) {
316
+ const end = lineEnd(s, start);
317
+ if (end < 0) break;
318
+ this.#line(s.slice(start, end), frames);
319
+ start = this.#afterEnding(s, end);
320
+ }
321
+ this.#buffer = s.slice(start);
322
+ return frames;
323
+ }
324
+ /** End of input: an undispatched frame is discarded, as WHATWG says. */
325
+ end() {
326
+ this.#buffer = "";
327
+ this.#reset();
328
+ return [];
329
+ }
330
+ #afterEnding(s, end) {
331
+ if (s[end] === "\n") return end + 1;
332
+ if (end + 1 < s.length) return s[end + 1] === "\n" ? end + 2 : end + 1;
333
+ this.#skipLf = true;
334
+ return end + 1;
335
+ }
336
+ #line(line, frames) {
337
+ if (line === "") {
338
+ if (this.#hasData) frames.push({ event: this.#event || "message", data: this.#data.join("\n") });
339
+ this.#reset();
340
+ return;
341
+ }
342
+ if (line.startsWith(":")) return;
343
+ const colon = line.indexOf(":");
344
+ const field = colon < 0 ? line : line.slice(0, colon);
345
+ let value = colon < 0 ? "" : line.slice(colon + 1);
346
+ if (value.startsWith(" ")) value = value.slice(1);
347
+ if (field === "event") this.#event = value;
348
+ if (field === "data") {
349
+ this.#data.push(value);
350
+ this.#hasData = true;
351
+ }
352
+ }
353
+ #reset() {
354
+ this.#event = "";
355
+ this.#data = [];
356
+ this.#hasData = false;
357
+ }
358
+ };
359
+ function lineEnd(s, from) {
360
+ for (let i = from; i < s.length; i++) {
361
+ const c = s[i];
362
+ if (c === "\n" || c === "\r") return i;
363
+ }
364
+ return -1;
365
+ }
366
+
367
+ // src/stream.ts
368
+ function outcomeOf(operation, event) {
369
+ const ends = STREAMS[operation].ends;
370
+ return Object.hasOwn(ends, event) ? ends[event] : void 0;
371
+ }
372
+ var IDLE = /* @__PURE__ */ Symbol("lingara.idle");
373
+ var CLOSED = /* @__PURE__ */ Symbol("lingara.closed");
374
+ var EventStream = class {
375
+ #init;
376
+ #own = new AbortController();
377
+ #signal;
378
+ #parser = new SseParser();
379
+ #decoder = new TextDecoder("utf-8");
380
+ #frames = [];
381
+ #started;
382
+ #reader;
383
+ #servedVersion;
384
+ #eof = false;
385
+ #done = false;
386
+ constructor(init) {
387
+ this.#init = init;
388
+ this.#signal = init.signal ? AbortSignal.any([init.signal, this.#own.signal]) : this.#own.signal;
389
+ }
390
+ /**
391
+ * The `Lingara-Version` echo. Reading it starts the request if iteration
392
+ * has not. Never rejects: `undefined` when the request fails or the stream
393
+ * closes first.
394
+ */
395
+ get servedVersion() {
396
+ if (this.#done && !this.#started) return Promise.resolve(void 0);
397
+ return this.#start().then(
398
+ (opened) => opened.servedVersion,
399
+ () => void 0
400
+ );
401
+ }
402
+ [Symbol.asyncIterator]() {
403
+ return this;
404
+ }
405
+ async next() {
406
+ if (this.#done) return { done: true, value: void 0 };
407
+ try {
408
+ await this.#start();
409
+ for (; ; ) {
410
+ const frame = this.#frames.shift();
411
+ if (frame) {
412
+ const result = this.#handle(frame);
413
+ if (result) return result;
414
+ continue;
415
+ }
416
+ if (this.#eof) throw new TransportError("stream_ended_early");
417
+ await this.#read();
418
+ }
419
+ } catch (err) {
420
+ return this.#fail(err);
421
+ }
422
+ }
423
+ async return() {
424
+ await this.close();
425
+ return { done: true, value: void 0 };
426
+ }
427
+ /** Ends the stream and closes the connection. Idempotent. */
428
+ async close() {
429
+ this.#finish(CLOSED);
430
+ }
431
+ #start() {
432
+ this.#started ??= this.#init.open(this.#signal).then((opened) => {
433
+ this.#servedVersion = opened.servedVersion;
434
+ const body = opened.response.body;
435
+ if (!body) throw new TransportError("stream_ended_early");
436
+ this.#reader = body.getReader();
437
+ if (this.#done) this.#reader.cancel().catch(() => void 0);
438
+ return opened;
439
+ });
440
+ return this.#started;
441
+ }
442
+ async #read() {
443
+ const reader = this.#reader;
444
+ const timer = setTimeout(() => this.#own.abort(IDLE), this.#init.idleTimeoutMs);
445
+ try {
446
+ const chunk = await raceAbort(reader.read(), this.#signal);
447
+ if (chunk.done) {
448
+ this.#frames.push(...this.#parser.push(this.#decoder.decode()), ...this.#parser.end());
449
+ this.#eof = true;
450
+ } else {
451
+ this.#frames.push(...this.#parser.push(this.#decoder.decode(chunk.value, { stream: true })));
452
+ }
453
+ } finally {
454
+ clearTimeout(timer);
455
+ }
456
+ }
457
+ #handle(frame) {
458
+ const events = STREAMS[this.#init.operation].events;
459
+ if (!events.includes(frame.event)) return void 0;
460
+ const data = decode(frame.data);
461
+ const outcome = outcomeOf(this.#init.operation, frame.event);
462
+ if (outcome === "raise") throw this.#streamError(data);
463
+ const value = { event: frame.event, data };
464
+ if (outcome === void 0) return { done: false, value };
465
+ this.#finish(CLOSED);
466
+ if (outcome === "end") return { done: true, value: void 0 };
467
+ return { done: false, value };
468
+ }
469
+ #streamError(data) {
470
+ const d = data ?? {};
471
+ return new ApiError({
472
+ status: 200,
473
+ code: typeof d.code === "string" ? d.code : "stream_error",
474
+ message: typeof d.message === "string" ? d.message : "the stream reported an error",
475
+ planId: typeof d.plan_id === "string" ? d.plan_id : void 0,
476
+ servedVersion: this.#servedVersion
477
+ });
478
+ }
479
+ #fail(err) {
480
+ const reason = this.#signal.aborted ? this.#signal.reason : void 0;
481
+ this.#finish(CLOSED);
482
+ if (reason === CLOSED) return { done: true, value: void 0 };
483
+ if (reason === IDLE) throw new TransportError("timeout");
484
+ if (reason !== void 0) throw reason;
485
+ if (err instanceof LingaraError) throw err;
486
+ throw new TransportError(transportKind(err, "body"), redactCause(err, []));
487
+ }
488
+ /** Marks the stream done and closes the connection; clears nothing twice. */
489
+ #finish(reason) {
490
+ if (this.#done) return;
491
+ this.#done = true;
492
+ if (!this.#own.signal.aborted) this.#own.abort(reason);
493
+ this.#reader?.cancel().catch(() => void 0);
494
+ }
495
+ };
496
+ function decode(data) {
497
+ try {
498
+ return JSON.parse(data);
499
+ } catch {
500
+ throw new TransportError("malformed_event");
501
+ }
502
+ }
503
+
504
+ // src/transport.ts
505
+ async function fetchOnce(req) {
506
+ try {
507
+ return await req.fetch(req.url, req.init);
508
+ } catch (err) {
509
+ throw transportFailure(err, req.init.signal ?? void 0, "fetch", req.secrets);
510
+ }
511
+ }
512
+ function transportFailure(err, signal, phase, secrets) {
513
+ if (signal?.aborted) return signal.reason;
514
+ if (err instanceof TransportError) return err;
515
+ return new TransportError(transportKind(err, phase), redactCause(err, secrets));
516
+ }
517
+
518
+ // src/userAgent.ts
519
+ var LIBRARY_VERSION = "0.1.0-alpha.6";
520
+ function runtimeToken(g = globalThis) {
521
+ const bun = g.Bun?.version;
522
+ if (typeof bun === "string") return clean(`bun/${bun}`);
523
+ const deno = g.Deno?.version?.deno;
524
+ if (typeof deno === "string") return clean(`deno/${deno}`);
525
+ const node = g.process?.versions?.node;
526
+ if (typeof node === "string") return clean(`node/${node}`);
527
+ return "unknown";
528
+ }
529
+ function clean(value) {
530
+ return value.replace(/[^\x21-\x28\x2A-\x7E]/g, "");
531
+ }
532
+ function userAgent(suffix) {
533
+ const own = `lingara-typescript/${LIBRARY_VERSION} (${runtimeToken()})`;
534
+ return suffix ? `${own} ${suffix}` : own;
535
+ }
536
+
537
+ // src/token.ts
538
+ var DEFAULT_TOKEN_URL = "https://api.getlingara.com/oauth/token";
539
+ var ClientCredentials = class {
540
+ clientId;
541
+ #secret;
542
+ #cached;
543
+ #flight;
544
+ #options;
545
+ constructor(options) {
546
+ this.clientId = options.clientId;
547
+ this.#secret = options.clientSecret;
548
+ this.#options = {
549
+ auth: options.auth ?? "basic",
550
+ scopes: options.scopes,
551
+ tokenUrl: options.tokenUrl ?? DEFAULT_TOKEN_URL,
552
+ maxAttempts: options.maxAttempts ?? 3,
553
+ retryAfterCapSeconds: options.retryAfterCapSeconds ?? 60,
554
+ tokenRequestTimeoutMs: options.tokenRequestTimeoutMs ?? 3e4,
555
+ userAgent: userAgent(options.userAgentSuffix),
556
+ clock: options.clock ?? systemClock,
557
+ sleeper: options.sleeper ?? realSleeper,
558
+ fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init))
559
+ };
560
+ }
561
+ async token(options = {}) {
562
+ options.signal?.throwIfAborted();
563
+ const cached = this.#cached;
564
+ if (cached && this.#options.clock.now() < cached.staleAt) return cached.token;
565
+ if (!this.#flight) {
566
+ const flight = this.#exchange();
567
+ this.#flight = flight;
568
+ flight.then(
569
+ () => this.#settle(flight),
570
+ () => this.#settle(flight)
571
+ );
572
+ }
573
+ return raceAbort(this.#flight, options.signal);
574
+ }
575
+ invalidate(token) {
576
+ if (this.#cached?.token === token) this.#cached = void 0;
577
+ }
578
+ /** The cached access token, raw. The one accessor that does not redact. */
579
+ exposeToken() {
580
+ return this.#cached?.token;
581
+ }
582
+ toJSON() {
583
+ return { clientId: this.clientId, clientSecret: REDACTED, token: this.#cached ? REDACTED : void 0 };
584
+ }
585
+ [INSPECT]() {
586
+ return `ClientCredentials ${JSON.stringify(this.toJSON())}`;
587
+ }
588
+ toString() {
589
+ return this[INSPECT]();
590
+ }
591
+ #settle(flight) {
592
+ if (this.#flight === flight) this.#flight = void 0;
593
+ }
594
+ async #exchange() {
595
+ const o = this.#options;
596
+ const policy = { maxAttempts: o.maxAttempts, retryAfterCapSeconds: o.retryAfterCapSeconds, clock: o.clock, sleeper: o.sleeper };
597
+ let sentAt = o.clock.now();
598
+ const res = await withRetries(policy, void 0, () => {
599
+ sentAt = o.clock.now();
600
+ return this.#post();
601
+ });
602
+ if (!res.ok) {
603
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"), o.clock);
604
+ throw await errorFromResponse(res, { endpoint: "token", retryAfter });
605
+ }
606
+ const grant = await this.#readGrant(res);
607
+ const skew = Math.min(60, grant.expiresIn / 2);
608
+ this.#cached = { token: grant.token, staleAt: sentAt + (grant.expiresIn - skew) * 1e3 };
609
+ return grant.token;
610
+ }
611
+ async #post() {
612
+ const o = this.#options;
613
+ const headers = {
614
+ "content-type": "application/x-www-form-urlencoded",
615
+ accept: "application/json",
616
+ "user-agent": o.userAgent
617
+ };
618
+ const body = new URLSearchParams({ grant_type: "client_credentials" });
619
+ if (o.scopes && o.scopes.length > 0) body.set("scope", o.scopes.join(" "));
620
+ if (o.auth === "post") {
621
+ body.set("client_id", this.clientId);
622
+ body.set("client_secret", this.#secret);
623
+ } else {
624
+ headers["authorization"] = `Basic ${btoa(`${formEncode(this.clientId)}:${formEncode(this.#secret)}`)}`;
625
+ }
626
+ const signal = AbortSignal.timeout(o.tokenRequestTimeoutMs);
627
+ try {
628
+ return await fetchOnce({ fetch: o.fetch, url: o.tokenUrl, init: { method: "POST", headers, body: body.toString(), signal }, secrets: [this.#secret] });
629
+ } catch (err) {
630
+ throw signal.aborted ? new TransportError("timeout") : err;
631
+ }
632
+ }
633
+ async #readGrant(res) {
634
+ let body;
635
+ try {
636
+ body = await res.json();
637
+ } catch (err) {
638
+ throw this.#readFailure(err);
639
+ }
640
+ const grant = body ?? {};
641
+ const { access_token: token, expires_in: expiresIn, token_type: type } = grant;
642
+ const bearer = typeof type === "string" && type.toLowerCase() === "bearer";
643
+ if (typeof token !== "string" || typeof expiresIn !== "number" || !bearer) {
644
+ throw new TransportError("malformed_response");
645
+ }
646
+ return { token, expiresIn };
647
+ }
648
+ #readFailure(err) {
649
+ if (err instanceof SyntaxError) return new TransportError("malformed_response");
650
+ if (err?.name === "TimeoutError") return new TransportError("timeout");
651
+ return transportFailure(err, void 0, "body", [this.#secret]);
652
+ }
653
+ };
654
+ function formEncode(value) {
655
+ return new URLSearchParams([["", value]]).toString().slice(1);
656
+ }
657
+
658
+ // src/generated/specVersion.ts
659
+ var GENERATED_FOR_VERSION = "2026-09-glowing-hoatzin";
660
+
661
+ // src/version.ts
662
+ var UNIX_SECONDS = /^@(-?\d+)$/;
663
+ var IMF_FIXDATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
664
+ var LINK_TARGET = /^\s*<([^>]*)>/;
665
+ function parseDeprecation(value) {
666
+ const match = UNIX_SECONDS.exec(value.trim());
667
+ return match ? new Date(Number(match[1]) * 1e3) : void 0;
668
+ }
669
+ function parseSunset(value) {
670
+ const trimmed = value.trim();
671
+ if (!IMF_FIXDATE.test(trimmed)) return void 0;
672
+ const at = Date.parse(trimmed);
673
+ return Number.isNaN(at) ? void 0 : new Date(at);
674
+ }
675
+ function parseLink(raw, requestUrl) {
676
+ const target = LINK_TARGET.exec(raw)?.[1];
677
+ if (target === void 0) return { raw };
678
+ try {
679
+ return { raw, url: new URL(target, requestUrl) };
680
+ } catch {
681
+ return { raw };
682
+ }
683
+ }
684
+ function deprecationNotice(headers, requestUrl) {
685
+ const deprecation = headers.get("deprecation");
686
+ if (deprecation === null) return void 0;
687
+ const notice = { deprecation };
688
+ const version = headers.get("lingara-version");
689
+ const sunset = headers.get("sunset");
690
+ const link = headers.get("link");
691
+ const deprecatedAt = parseDeprecation(deprecation);
692
+ if (version !== null) notice.version = version;
693
+ if (deprecatedAt) notice.deprecatedAt = deprecatedAt;
694
+ if (sunset !== null) {
695
+ notice.sunset = sunset;
696
+ const sunsetAt = parseSunset(sunset);
697
+ if (sunsetAt) notice.sunsetAt = sunsetAt;
698
+ }
699
+ if (link !== null) notice.link = parseLink(link, requestUrl);
700
+ return notice;
701
+ }
702
+ var VersionObserver = class {
703
+ #hook;
704
+ #warned = /* @__PURE__ */ new Set();
705
+ // Its own set: sharing `#warned` would let a version that is both
706
+ // deprecated and mismatched warn only once in total.
707
+ #mismatched = /* @__PURE__ */ new Set();
708
+ constructor(hook) {
709
+ this.#hook = hook;
710
+ }
711
+ /** Returns the `Lingara-Version` echo, after reporting any deprecation or mismatch. */
712
+ observe(res, requestUrl) {
713
+ const notice = deprecationNotice(res.headers, requestUrl);
714
+ if (notice) this.#report(notice);
715
+ const served = res.headers.get("lingara-version") ?? void 0;
716
+ if (served !== void 0) this.#checkGenerated(served);
717
+ return served;
718
+ }
719
+ #checkGenerated(served) {
720
+ if (served === GENERATED_FOR_VERSION || this.#mismatched.has(served)) return;
721
+ this.#mismatched.add(served);
722
+ console.warn(
723
+ `Lingara API version ${served} served this response, but this library's types were generated for ${GENERATED_FOR_VERSION}; response shapes may differ. Pin the OAuth client to ${GENERATED_FOR_VERSION} or upgrade the library.`
724
+ );
725
+ }
726
+ #report(notice) {
727
+ if (!this.#hook) {
728
+ const id = notice.version ?? "";
729
+ if (this.#warned.has(id)) return;
730
+ this.#warned.add(id);
731
+ const sunset = notice.sunset ? `; sunset ${notice.sunset}` : "";
732
+ console.warn(`Lingara API version ${id || "(unnamed)"} is deprecated${sunset}. See GET /v1/versions.`);
733
+ return;
734
+ }
735
+ try {
736
+ this.#hook(notice);
737
+ } catch (err) {
738
+ console.debug("Lingara deprecation hook threw", err);
739
+ }
740
+ }
741
+ };
742
+
743
+ // src/client.ts
744
+ var DEFAULT_BASE_URL = "https://api.getlingara.com";
745
+ var Lingara = class {
746
+ clientId;
747
+ #tokens;
748
+ #baseUrl;
749
+ #version;
750
+ #versions;
751
+ #policy;
752
+ #idleMs;
753
+ #userAgent;
754
+ #fetch;
755
+ constructor(options = {}) {
756
+ checkOptions(options);
757
+ this.clientId = options.clientId;
758
+ this.#tokens = options.tokenSource ?? credentialsFrom(options);
759
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
760
+ this.#version = options.version;
761
+ this.#versions = new VersionObserver(options.onDeprecation);
762
+ this.#policy = {
763
+ maxAttempts: options.maxAttempts ?? 3,
764
+ retryAfterCapSeconds: options.retryAfterCapSeconds ?? 60,
765
+ clock: options.clock ?? systemClock,
766
+ sleeper: options.sleeper ?? realSleeper
767
+ };
768
+ this.#idleMs = options.streamIdleTimeoutMs ?? 12e4;
769
+ this.#userAgent = userAgent(options.userAgentSuffix);
770
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
771
+ }
772
+ /** The token source in use, if the client has credentials. */
773
+ get tokenSource() {
774
+ return this.#tokens;
775
+ }
776
+ // --- streams -------------------------------------------------------------
777
+ generateVocabulary(body, options = {}) {
778
+ return this.#stream("generateVocabulary", { body }, options);
779
+ }
780
+ createLessonPlan(body, options = {}) {
781
+ return this.#stream("createLessonPlan", { body }, options);
782
+ }
783
+ streamLessonPlan(params, options = {}) {
784
+ return this.#stream("streamLessonPlan", { id: params.id }, options);
785
+ }
786
+ sendTutorMessage(body, options = {}) {
787
+ return this.#stream("sendTutorMessage", { body }, options);
788
+ }
789
+ // --- JSON ----------------------------------------------------------------
790
+ getLessonPlan(params, options = {}) {
791
+ return this.#json(`/v1/lesson-plans/${encodeURIComponent(params.id)}`, true, options);
792
+ }
793
+ getUsage(options = {}) {
794
+ return this.#json("/v1/usage", true, options);
795
+ }
796
+ getOpenApiDocument(options = {}) {
797
+ return this.#json("/v1/openapi.json", false, options);
798
+ }
799
+ listApiVersions(options = {}) {
800
+ return this.#json("/v1/versions", false, options);
801
+ }
802
+ getApiVersion(params, options = {}) {
803
+ return this.#json(`/v1/versions/${encodeURIComponent(params.id)}`, false, options);
804
+ }
805
+ // --- rendering: the secret and tokens never appear ------------------------
806
+ toJSON() {
807
+ const secret = this.#tokens instanceof ClientCredentials ? REDACTED : void 0;
808
+ return { clientId: this.clientId, clientSecret: secret, baseUrl: this.#baseUrl, version: this.#version };
809
+ }
810
+ [INSPECT]() {
811
+ return `Lingara ${JSON.stringify(this.toJSON())}`;
812
+ }
813
+ toString() {
814
+ return this[INSPECT]();
815
+ }
816
+ // --- the pipeline --------------------------------------------------------
817
+ #stream(op, input, options) {
818
+ const route = STREAMS[op];
819
+ const path = input.id === void 0 ? route.path : route.path.replace("{id}", encodeURIComponent(input.id));
820
+ const open = (signal) => this.#openStream({ method: route.method, path, body: input.body, accept: "text/event-stream", needsToken: true, signal });
821
+ return new EventStream({ operation: op, open, signal: options.signal, idleTimeoutMs: this.#idleMs });
822
+ }
823
+ async #openStream(req) {
824
+ const res = await this.#send(req);
825
+ if (mediaType(res) !== "text/event-stream") {
826
+ await res.body?.cancel().catch(() => void 0);
827
+ throw new TransportError("malformed_response");
828
+ }
829
+ const servedVersion = this.#versions.observe(res, this.#baseUrl + req.path);
830
+ return { response: res, servedVersion };
831
+ }
832
+ async #json(path, needsToken, options) {
833
+ const res = await this.#send({ method: "GET", path, accept: "application/json", needsToken, signal: options.signal });
834
+ const servedVersion = this.#versions.observe(res, this.#baseUrl + path);
835
+ let body;
836
+ try {
837
+ body = await res.json();
838
+ } catch (err) {
839
+ if (err instanceof SyntaxError) throw new TransportError("malformed_response");
840
+ throw transportFailure(err, options.signal, "body", []);
841
+ }
842
+ if (servedVersion !== void 0 && typeof body === "object" && body !== null) {
843
+ Object.defineProperty(body, "servedVersion", { value: servedVersion, enumerable: false });
844
+ }
845
+ return body;
846
+ }
847
+ /** Auth, retries and error mapping; resolves with a 2xx response. */
848
+ async #send(req) {
849
+ const url = this.#baseUrl + req.path;
850
+ const attempt = (token) => withRetries(
851
+ this.#policy,
852
+ req.signal,
853
+ () => fetchOnce({ fetch: this.#fetch, url, init: this.#init(req, token), secrets: [token] })
854
+ );
855
+ let res;
856
+ if (!req.needsToken) res = await attempt();
857
+ else if (this.#tokens) res = await withTokenRetry(this.#tokens, req.signal, attempt);
858
+ else throw new LingaraError("this operation needs credentials: construct the client with clientId and clientSecret");
859
+ if (res.ok) return res;
860
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"), this.#policy.clock);
861
+ const servedVersion = res.headers.get("lingara-version") ?? void 0;
862
+ throw await errorFromResponse(res, { endpoint: "v1", retryAfter, servedVersion });
863
+ }
864
+ #init(req, token) {
865
+ const headers = { accept: req.accept, "user-agent": this.#userAgent };
866
+ if (token !== void 0) headers["authorization"] = `Bearer ${token}`;
867
+ if (this.#version !== void 0) headers["lingara-version"] = this.#version;
868
+ const init = { method: req.method, headers };
869
+ if (req.body !== void 0) {
870
+ headers["content-type"] = "application/json";
871
+ init.body = JSON.stringify(req.body);
872
+ }
873
+ if (req.signal) init.signal = req.signal;
874
+ return init;
875
+ }
876
+ };
877
+ function checkOptions(options) {
878
+ if (options.tokenSource && options.clientSecret !== void 0) {
879
+ throw new LingaraError("pass either tokenSource or clientSecret, not both");
880
+ }
881
+ if (options.version === "") throw new LingaraError("version must not be empty");
882
+ if (options.clientSecret !== void 0 && globalThis.document !== void 0) {
883
+ throw new LingaraError(
884
+ "a client secret must not be used in a browser: anyone who loads the page can read it. Call the Lingara API from your server."
885
+ );
886
+ }
887
+ if (options.clientSecret === void 0 !== (options.clientId === void 0) && !options.tokenSource) {
888
+ throw new LingaraError("clientId and clientSecret go together");
889
+ }
890
+ }
891
+ function credentialsFrom(options) {
892
+ const { clientId, clientSecret } = options;
893
+ if (clientId === void 0 || clientSecret === void 0) return void 0;
894
+ return new ClientCredentials({ ...options, clientId, clientSecret });
895
+ }
896
+ export {
897
+ ApiError,
898
+ ClientCredentials,
899
+ DEFAULT_BASE_URL,
900
+ DEFAULT_TOKEN_URL,
901
+ EventStream,
902
+ Lingara,
903
+ LingaraError,
904
+ MaintenanceError,
905
+ OAuthError,
906
+ TransportError
907
+ };