anpord 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,372 @@
1
- import { i as make, n as DEFAULT_BASE_URL, r as layer, t as AnpordApi } from "./client-bJBNwTKq.mjs";
2
- import { n as MissingApiKey, r as asAnpordError, t as AnpordError } from "./errors-DEfS6oaQ.mjs";
1
+ import { i as make, n as DEFAULT_BASE_URL, r as layer, t as AnpordApi } from "./client-BfhmGRQl.mjs";
2
+ import { i as tokenMatcher, n as MissingApiKey, r as asAnpordError, t as AnpordError } from "./errors-C9bQUcA3.mjs";
3
+ import { n as files, r as repo, t as empty } from "./source-Fg1h2VNn.mjs";
3
4
  import { FetchHttpClient } from "@effect/platform";
4
- import { Cause, Effect, Exit, Option, Redacted } from "effect";
5
+ import { Cache, Cause, Clock, Context, Data, DateTime, Duration, Effect, Exit, Layer, ManagedRuntime, Option, Redacted } from "effect";
6
+ //#region ../template/src/errors.ts
7
+ /**
8
+ * A template asked for something the caller did not supply.
9
+ *
10
+ * A plain `Error` rather than a tagged one: this package has no dependencies,
11
+ * and it is thrown across the published SDK boundary where callers reach for
12
+ * `instanceof`.
13
+ */
14
+ var MissingVariables = class extends Error {
15
+ missing;
16
+ constructor(missing) {
17
+ const names = missing.map((name) => `{{${name}}}`).join(", ");
18
+ super(`No value for ${names}. Pass them, or set onMissing to "keep" or "empty".`);
19
+ this.name = "MissingVariables";
20
+ this.missing = missing;
21
+ }
22
+ };
23
+ //#endregion
24
+ //#region ../template/src/render.ts
25
+ /** Null and undefined read as absent rather than as the strings "null" and
26
+ * "undefined", because that is what `{ name: user.name }` produces when the
27
+ * field is not there. */
28
+ const suppliedValue = (values, name) => {
29
+ const value = values[name];
30
+ return value === null || value === void 0 ? void 0 : String(value);
31
+ };
32
+ /**
33
+ * Fills `{{name}}` from a flat map.
34
+ *
35
+ * Dots and hyphens are part of the name rather than a path: `{{user.name}}`
36
+ * reads the key `"user.name"`. One rule beats two, and a caller who wants
37
+ * nesting writes the flat key themselves.
38
+ */
39
+ function render(template, values, options = {}) {
40
+ const onMissing = options.onMissing ?? "throw";
41
+ const missing = [];
42
+ const content = template.replace(tokenMatcher(), (match, open, close, name) => {
43
+ if (open !== void 0) return "{{";
44
+ if (close !== void 0) return "}}";
45
+ if (name === void 0) return match;
46
+ const value = suppliedValue(values, name);
47
+ if (value !== void 0) return value;
48
+ if (!missing.includes(name)) missing.push(name);
49
+ return onMissing === "empty" ? "" : match;
50
+ });
51
+ if (onMissing === "throw" && missing.length > 0) throw new MissingVariables(missing);
52
+ return {
53
+ content,
54
+ missing
55
+ };
56
+ }
57
+ //#endregion
58
+ //#region src/client/cache/cache-key.ts
59
+ /** Structural rather than referential, so two identical selectors are one
60
+ * entry and one in-flight request rather than two. A plain object literal
61
+ * hashes by reference and every call would miss. */
62
+ const cacheKey = (selector) => Data.struct({
63
+ channel: selector.channel,
64
+ id: selector.id,
65
+ includeVersions: selector.includeVersions,
66
+ version: selector.version
67
+ });
68
+ //#endregion
69
+ //#region src/client/cache/keys.ts
70
+ const NAMESPACE = "prompt";
71
+ /**
72
+ * Which version a selector asks for.
73
+ *
74
+ * Asking for no channel is its own selector rather than a named one: the
75
+ * organisation decides which channel answers, it can rename or drop that
76
+ * channel, and naming a guess here would key the answer under a channel the
77
+ * server never resolved.
78
+ */
79
+ const selectorSegment = (selector) => {
80
+ if (selector.version !== void 0) return `v:${selector.version}`;
81
+ return selector.channel === void 0 ? "default" : `c:${selector.channel}`;
82
+ };
83
+ /**
84
+ * What makes one cached answer different from another.
85
+ *
86
+ * `includeVersions` belongs here even though the server's key omits it: this
87
+ * cache holds the response rather than the resolution, and a response asked
88
+ * for without history has no `versions` to give to a caller who wanted it.
89
+ */
90
+ const promptKey = (selector) => [
91
+ NAMESPACE,
92
+ encodeURIComponent(selector.id),
93
+ selectorSegment(selector),
94
+ selector.includeVersions === true ? "full" : "bare"
95
+ ].join(":");
96
+ /** Every key for one prompt, so a write invalidates all of them. */
97
+ const promptPrefix = (id) => `${NAMESPACE}:${encodeURIComponent(id)}:`;
98
+ //#endregion
99
+ //#region src/client/cache/prompt-cache.ts
100
+ var PromptCache = class extends Context.Tag("@anpord/sdk/PromptCache")() {};
101
+ const make$1 = (settings, fetch) => Effect.gen(function* () {
102
+ const scope = yield* Effect.scope;
103
+ const permits = yield* Effect.makeSemaphore(settings.maxConcurrentRefresh);
104
+ const cache = yield* Cache.makeWith({
105
+ capacity: settings.capacity,
106
+ lookup: (selector) => fetch(selector),
107
+ /** A failure is not an answer, so it is not kept: the next call tries
108
+ * again rather than waiting out a whole window on one bad response. */
109
+ timeToLive: (exit) => Exit.isFailure(exit) ? Duration.zero : Duration.infinity
110
+ });
111
+ /** A pinned version cannot change, so only capacity ever evicts it. A
112
+ * channel can be repointed at any moment. */
113
+ const ttlFor = (selector) => selector.version === void 0 ? settings.ttlMs : Number.POSITIVE_INFINITY;
114
+ const ageOf = (key) => Effect.gen(function* () {
115
+ const stats = yield* cache.entryStats(key);
116
+ if (Option.isNone(stats)) return Option.none();
117
+ const now = yield* Clock.currentTimeMillis;
118
+ return Option.some(Math.max(0, now - stats.value.loadedMillis));
119
+ });
120
+ /** Forked into the cache's own scope rather than as a daemon, so a client
121
+ * that is disposed takes its refreshes with it. A refresh that cannot get
122
+ * a permit is dropped: the next call will ask again. */
123
+ const revalidate = (key) => permits.withPermitsIfAvailable(1)(cache.refresh(key)).pipe(Effect.catchAllCause(() => Effect.void), Effect.forkIn(scope), Effect.asVoid);
124
+ const heldWithin = (selector, limitMs) => Effect.gen(function* () {
125
+ const key = cacheKey(selector);
126
+ const ttl = ttlFor(key);
127
+ const value = yield* cache.getOptionComplete(key);
128
+ const age = yield* ageOf(key);
129
+ if (Option.isNone(value) || Option.isNone(age)) return Option.none();
130
+ if (age.value > limitMs) return Option.none();
131
+ if (age.value > ttl) yield* revalidate(key);
132
+ return Option.some({
133
+ ageMs: age.value,
134
+ value: value.value
135
+ });
136
+ });
137
+ return {
138
+ held: (selector) => heldWithin(selector, settings.maxStaleMs),
139
+ invalidate: (id) => Effect.gen(function* () {
140
+ const prefix = promptPrefix(id);
141
+ const keys = yield* cache.keys;
142
+ yield* Effect.forEach(keys.filter((key) => promptKey(key).startsWith(prefix)), (key) => cache.invalidate(key), { discard: true });
143
+ }).pipe(Effect.withSpan("PromptCache.invalidate")),
144
+ load: (selector) => Effect.gen(function* () {
145
+ const key = cacheKey(selector);
146
+ const value = yield* fetch(selector);
147
+ yield* cache.set(key, value);
148
+ return value;
149
+ }).pipe(Effect.withSpan("PromptCache.load", { attributes: { key: promptKey(selector) } })),
150
+ stale: (selector) => heldWithin(selector, Number.POSITIVE_INFINITY)
151
+ };
152
+ });
153
+ const layer$1 = (settings, fetch) => Layer.scoped(PromptCache, make$1(settings, fetch));
154
+ //#endregion
155
+ //#region src/client/cache/noop.ts
156
+ /** Disabled caching is a cache that holds nothing, so the one path through
157
+ * `resolvePrompt` still runs rather than being wrapped in a condition. */
158
+ const noopLayer = (fetch) => Layer.succeed(PromptCache, {
159
+ /** Effects rather than bare values: yielding an Option would make an empty
160
+ * one a failure, and every read would end in NoSuchElementException. */
161
+ held: () => Effect.succeedNone,
162
+ invalidate: () => Effect.void,
163
+ load: fetch,
164
+ stale: () => Effect.succeedNone
165
+ });
166
+ //#endregion
167
+ //#region src/client/cache/failure.ts
168
+ /** A status the server chose deliberately, telling the caller something true
169
+ * about their request rather than about its own health. */
170
+ const DEFINITIVE = /* @__PURE__ */ new Set([
171
+ 400,
172
+ 401,
173
+ 403,
174
+ 404,
175
+ 409,
176
+ 422
177
+ ]);
178
+ /**
179
+ * Whether a failure means the answer is unavailable rather than no.
180
+ *
181
+ * A 404 is a successful reply carrying real information: this prompt does not
182
+ * exist. Covering it with a fallback turns a typo in a prompt id into a silent
183
+ * permanent substitution that ships and is found weeks later. Serving stale
184
+ * content covers the case where a prompt someone was using is removed, and it
185
+ * sits ahead of the fallback anyway.
186
+ *
187
+ * An unrecognised failure counts as unavailable: a transport error carries no
188
+ * status at all, and guessing the other way would turn a flaky network into an
189
+ * outage.
190
+ */
191
+ const isAvailabilityFailure = (error) => error.status === void 0 || !DEFINITIVE.has(error.status);
192
+ //#endregion
193
+ //#region src/client/cache/fallback.ts
194
+ /** No version the API can return is zero, so a caller who logs it knows
195
+ * without being told that they are reading a fallback rather than a prompt. */
196
+ const FALLBACK_VERSION = 0;
197
+ /**
198
+ * The shape of a prompt, built from what the caller had to hand.
199
+ *
200
+ * The branded fields are asserted rather than decoded: this value never came
201
+ * from the API and never goes back to it, and a fallback that could fail to
202
+ * parse would defeat the reason it exists.
203
+ */
204
+ const fallbackPrompt = (selector, fallback, now) => {
205
+ const given = typeof fallback === "string" ? { content: fallback } : fallback;
206
+ return {
207
+ channel: selector.channel ?? null,
208
+ config: given.config ?? {},
209
+ content: given.content,
210
+ createdAt: DateTime.unsafeMake(now),
211
+ id: selector.id,
212
+ message: null,
213
+ name: selector.id,
214
+ version: FALLBACK_VERSION
215
+ };
216
+ };
217
+ //#endregion
218
+ //#region src/client/cache/resolve.ts
219
+ /**
220
+ * Which answer the caller gets, in the order the answers are worth having.
221
+ *
222
+ * Fresh cache first, because that is the whole point. Then the API, since it
223
+ * is the only source that can be right. Then whatever is held however old,
224
+ * because a prompt someone wrote and promoted beats a string written at deploy
225
+ * time. Then the fallback, which the caller asked for. Then the failure, since
226
+ * with nothing cached and no fallback there is no honest answer to give.
227
+ */
228
+ const resolvePrompt = (options) => Effect.gen(function* () {
229
+ const cache = yield* PromptCache;
230
+ const key = promptKey(options);
231
+ const held = yield* cache.held(options);
232
+ if (Option.isSome(held)) return {
233
+ metadata: {
234
+ ageMs: held.value.ageMs,
235
+ /** Where the answer came from, not how old it is. An entry read in
236
+ * the same millisecond it was stored is still an entry, and calling
237
+ * it fresh made a cache hit indistinguishable from a network read. */
238
+ freshness: "cached",
239
+ key
240
+ },
241
+ value: held.value.value
242
+ };
243
+ const loaded = yield* Effect.either(cache.load(options));
244
+ if (loaded._tag === "Right") return {
245
+ metadata: {
246
+ ageMs: 0,
247
+ freshness: "fresh",
248
+ key
249
+ },
250
+ value: loaded.right
251
+ };
252
+ const error = asAnpordError(loaded.left);
253
+ if (!isAvailabilityFailure(error)) return yield* Effect.fail(error);
254
+ const remembered = yield* cache.stale(options);
255
+ if (Option.isSome(remembered)) return {
256
+ metadata: {
257
+ ageMs: remembered.value.ageMs,
258
+ freshness: "stale",
259
+ key,
260
+ reason: error.message
261
+ },
262
+ value: remembered.value.value
263
+ };
264
+ if (options.fallback === void 0) return yield* Effect.fail(error);
265
+ const now = yield* Clock.currentTimeMillis;
266
+ return {
267
+ metadata: {
268
+ ageMs: 0,
269
+ freshness: "fallback",
270
+ key,
271
+ reason: error.message
272
+ },
273
+ value: fallbackPrompt(options, options.fallback, now)
274
+ };
275
+ }).pipe(Effect.withSpan("Prompts.resolve", { attributes: { key: promptKey(options) } }));
276
+ //#endregion
277
+ //#region src/client/cache/settings.ts
278
+ /**
279
+ * How long a channel's answer is trusted without asking again.
280
+ *
281
+ * This is the delay between a promotion and callers seeing it, so it is short:
282
+ * the dashboard tells whoever promotes that callers receive the new version
283
+ * immediately, and a minute of drift makes that untrue exactly when someone is
284
+ * rolling back something bad. Serving stale while refreshing means the number
285
+ * costs no latency, only freshness.
286
+ */
287
+ const TTL_MS = 15e3;
288
+ /** Long enough to cover a deploy or an incident, short enough that a value
289
+ * this old is worth saying out loud. Only reached while the API answers; when
290
+ * it cannot be reached at all there is no bound, since a day-old real prompt
291
+ * beats no prompt. */
292
+ const MAX_STALE_MS = 864e5;
293
+ /** Least recently used past this. A thousand prompts is far more than one
294
+ * process serves and still bounds the memory a long-running server holds. */
295
+ const CAPACITY = 1e3;
296
+ /** A cold cache going stale at once must not fork a refresh per prompt. */
297
+ const MAX_CONCURRENT_REFRESH = 8;
298
+ const positive = (value, fallback) => value !== void 0 && Number.isFinite(value) && value > 0 ? value : fallback;
299
+ const settingsFrom = (options) => {
300
+ const given = typeof options === "object" ? options : {};
301
+ return {
302
+ capacity: positive(given.capacity, CAPACITY),
303
+ maxConcurrentRefresh: positive(given.maxConcurrentRefresh, MAX_CONCURRENT_REFRESH),
304
+ maxStaleMs: positive(given.maxStaleMs, MAX_STALE_MS),
305
+ ttlMs: positive(given.ttlMs, TTL_MS)
306
+ };
307
+ };
308
+ const cacheEnabled = (options) => options !== false;
309
+ //#endregion
310
+ //#region src/client/promised.ts
311
+ const promised = (group) => Object.fromEntries(Object.entries(group).map(([method, run]) => [method, async (request) => {
312
+ const exit = await Effect.runPromiseExit(run({ payload: request ?? {} }));
313
+ if (Exit.isSuccess(exit)) return exit.value;
314
+ throw asAnpordError(Cause.failureOption(exit.cause).pipe(Option.getOrElse(() => Cause.squash(exit.cause))));
315
+ }]));
316
+ //#endregion
317
+ //#region src/client/wait.ts
318
+ var EvalTimeout = class extends Error {
319
+ name = "EvalTimeout";
320
+ runId;
321
+ constructor(runId, elapsedMs) {
322
+ const elapsed = elapsedMs < 1e3 ? `${elapsedMs}ms` : `${Math.round(elapsedMs / 1e3)}s`;
323
+ super(`Eval ${runId} was still running after ${elapsed}. It was not cancelled — read it later with evals.get({ id: "${runId}" }).`);
324
+ this.runId = runId;
325
+ }
326
+ };
327
+ var EvalAborted = class extends Error {
328
+ name = "EvalAborted";
329
+ runId;
330
+ constructor(runId) {
331
+ super(`Waiting on eval ${runId} was aborted. The run was not cancelled — read it later with evals.get({ id: "${runId}" }).`);
332
+ this.runId = runId;
333
+ }
334
+ };
335
+ const DEFAULT_INTERVAL = 2e3;
336
+ const DEFAULT_MAX_INTERVAL = 1e4;
337
+ const DEFAULT_TIMEOUT = 36e5;
338
+ const nextInterval = (current, max) => Math.min(Math.round(current * 1.5), max);
339
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
340
+ const timer = setTimeout(() => {
341
+ signal?.removeEventListener("abort", onAbort);
342
+ resolve();
343
+ }, ms);
344
+ function onAbort() {
345
+ clearTimeout(timer);
346
+ reject(signal?.reason);
347
+ }
348
+ signal?.addEventListener("abort", onAbort, { once: true });
349
+ });
350
+ const waitForRun = async (get, id, options = {}) => {
351
+ const { maxIntervalMs = DEFAULT_MAX_INTERVAL, onProgress, pollIntervalMs = DEFAULT_INTERVAL, signal, timeoutMs = DEFAULT_TIMEOUT } = options;
352
+ const startedAt = Date.now();
353
+ let interval = pollIntervalMs;
354
+ for (;;) {
355
+ if (signal?.aborted) throw new EvalAborted(id);
356
+ const run = await get({ id });
357
+ onProgress?.(run);
358
+ if (run.status !== "running") return run;
359
+ const elapsed = Date.now() - startedAt;
360
+ if (elapsed >= timeoutMs) throw new EvalTimeout(id, elapsed);
361
+ try {
362
+ await sleep(Math.min(interval, timeoutMs - elapsed), signal);
363
+ } catch {
364
+ throw new EvalAborted(id);
365
+ }
366
+ interval = nextInterval(interval, maxIntervalMs);
367
+ }
368
+ };
369
+ //#endregion
5
370
  //#region src/client/anpord.ts
6
371
  const resolveApiKey = (provided) => {
7
372
  const apiKey = provided ?? globalThis.process?.env?.ANPORD_API_KEY;
@@ -9,20 +374,69 @@ const resolveApiKey = (provided) => {
9
374
  return apiKey;
10
375
  };
11
376
  var Anpord = class {
377
+ evals;
12
378
  prompts;
379
+ runtime;
13
380
  constructor(options = {}) {
14
381
  const apiKey = resolveApiKey(options.apiKey);
15
382
  const client = make({
16
383
  apiKey: Redacted.make(apiKey),
17
384
  baseUrl: options.baseUrl ?? "https://api.anpord.com"
18
385
  }).pipe(Effect.provide(FetchHttpClient.layer), Effect.runSync);
19
- this.prompts = promised(client.prompts);
386
+ const fetch = (selector) => client.prompts.get({ payload: selector });
387
+ this.runtime = ManagedRuntime.make(cacheEnabled(options.cache) ? layer$1(settingsFrom(options.cache), fetch) : noopLayer(fetch));
388
+ const group = promised(client.prompts);
389
+ const evals = promised(client.evals);
390
+ this.evals = {
391
+ ...evals,
392
+ startAndWait: async (options) => {
393
+ const { maxIntervalMs, onProgress, pollIntervalMs, signal, timeoutMs, ...request } = options;
394
+ const { id } = await evals.start(request);
395
+ return await waitForRun(evals.get, id, {
396
+ maxIntervalMs,
397
+ onProgress,
398
+ pollIntervalMs,
399
+ signal,
400
+ timeoutMs
401
+ });
402
+ },
403
+ wait: ({ id, ...options }) => waitForRun(evals.get, id, options)
404
+ };
405
+ const forget = (id) => this.runtime.runPromise(Effect.flatMap(PromptCache, (cache) => cache.invalidate(id))).catch(() => void 0);
406
+ this.prompts = {
407
+ ...group,
408
+ get: (request) => this.resolve(request),
409
+ promote: invalidating(group.promote, forget),
410
+ update: invalidating(group.update, forget)
411
+ };
412
+ }
413
+ dispose() {
414
+ return this.runtime.dispose();
415
+ }
416
+ [Symbol.asyncDispose]() {
417
+ return this.runtime.dispose();
418
+ }
419
+ async resolve(options) {
420
+ const exit = await this.runtime.runPromiseExit(resolvePrompt(options));
421
+ if (Exit.isFailure(exit)) throw asAnpordError(Cause.failureOption(exit.cause).pipe(Option.getOrElse(() => Cause.squash(exit.cause))));
422
+ const prompt = exit.value.value;
423
+ const value = options.variables === void 0 ? { ...prompt } : {
424
+ ...prompt,
425
+ content: render(prompt.content, options.variables).content
426
+ };
427
+ return Object.defineProperty(value, "anpord", {
428
+ enumerable: false,
429
+ value: exit.value.metadata
430
+ });
20
431
  }
21
432
  };
22
- const promised = (group) => Object.fromEntries(Object.entries(group).map(([method, run]) => [method, async (request) => {
23
- const exit = await Effect.runPromiseExit(run({ payload: request ?? {} }));
24
- if (Exit.isSuccess(exit)) return exit.value;
25
- throw asAnpordError(Cause.failureOption(exit.cause).pipe(Option.getOrElse(() => Cause.squash(exit.cause))));
26
- }]));
433
+ const invalidating = (write, forget) => async (request) => {
434
+ const result = await write(request);
435
+ await forget(request.id);
436
+ return result;
437
+ };
438
+ //#endregion
439
+ //#region src/evals/define.ts
440
+ const defineEval = (definition) => definition;
27
441
  //#endregion
28
- export { Anpord, AnpordApi, AnpordError, DEFAULT_BASE_URL, MissingApiKey, layer, make };
442
+ export { Anpord, AnpordApi, AnpordError, DEFAULT_BASE_URL, EvalAborted, EvalTimeout, MissingApiKey, defineEval, empty, files, layer, make, repo };