@telemetry-dev/sdk 0.1.10 → 0.1.12
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/README.md +68 -0
- package/dist/index.d.mts +156 -2
- package/dist/index.mjs +329 -3
- package/package.json +1 -1
- package/src/config.ts +1 -1
- package/src/index.ts +19 -0
- package/src/prompts.ts +740 -0
- package/src/span.ts +3 -0
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { type Attributes, type Context, createContextKey } from "@opentelemetry/api";
|
|
2
|
+
import {
|
|
3
|
+
activeContext,
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
jsonAttr,
|
|
6
|
+
omitUndefined,
|
|
7
|
+
reportError,
|
|
8
|
+
resolveEnv,
|
|
9
|
+
sessionIdOf,
|
|
10
|
+
sha256,
|
|
11
|
+
withContext,
|
|
12
|
+
} from "@telemetry-dev/otel";
|
|
13
|
+
|
|
14
|
+
import type { FieldCaptureConfig } from "./attrs.ts";
|
|
15
|
+
import { prepareCaptureValue } from "./capture.ts";
|
|
16
|
+
import { currentClient } from "./client.ts";
|
|
17
|
+
|
|
18
|
+
/** A message of a chat prompt. A placeholder stands for a list of messages that the app passes. */
|
|
19
|
+
export type PromptMessage =
|
|
20
|
+
| { readonly role: "system" | "user" | "assistant"; readonly content: string }
|
|
21
|
+
| { readonly role: "placeholder"; readonly name: string };
|
|
22
|
+
|
|
23
|
+
/** A message that `compile()` makes from a prompt message. */
|
|
24
|
+
export interface ChatMessage {
|
|
25
|
+
role: "system" | "user" | "assistant";
|
|
26
|
+
content: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PromptModel {
|
|
30
|
+
readonly provider: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The parameters that are set in the version. A parameter that is not set is absent. */
|
|
35
|
+
export interface PromptParameters {
|
|
36
|
+
readonly temperature?: number;
|
|
37
|
+
readonly topP?: number;
|
|
38
|
+
readonly maxTokens?: number;
|
|
39
|
+
readonly frequencyPenalty?: number;
|
|
40
|
+
readonly presencePenalty?: number;
|
|
41
|
+
readonly stop?: readonly string[];
|
|
42
|
+
readonly seed?: number;
|
|
43
|
+
readonly toolChoice?: "auto" | "none" | "required" | { readonly tool: string };
|
|
44
|
+
readonly parallelToolCalls?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PromptTool {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly description: string;
|
|
50
|
+
/** JSON schema of the tool arguments. */
|
|
51
|
+
readonly parameters: Readonly<Record<string, unknown>>;
|
|
52
|
+
readonly strict: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PromptResponseFormat =
|
|
56
|
+
| { readonly type: "text" }
|
|
57
|
+
| { readonly type: "json_object" }
|
|
58
|
+
| {
|
|
59
|
+
readonly type: "json_schema";
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly schema: Readonly<Record<string, unknown>>;
|
|
62
|
+
readonly strict: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Values for `{{name}}` variables. A missing value, null, or undefined becomes an empty text, and a
|
|
67
|
+
* value that is not a string becomes JSON. A placeholder message takes a list of messages.
|
|
68
|
+
*/
|
|
69
|
+
export type PromptVariables<M = ChatMessage> = Readonly<
|
|
70
|
+
Record<string, string | number | boolean | null | undefined | readonly M[]>
|
|
71
|
+
>;
|
|
72
|
+
|
|
73
|
+
interface PromptBase {
|
|
74
|
+
readonly name: string;
|
|
75
|
+
/** The version that the app got; null for the fallback. */
|
|
76
|
+
readonly version: number | null;
|
|
77
|
+
/** The label that the prompt was fetched by; null when it was fetched by version. */
|
|
78
|
+
readonly label: string | null;
|
|
79
|
+
/** Null for the fallback. */
|
|
80
|
+
readonly model: PromptModel | null;
|
|
81
|
+
readonly parameters: PromptParameters;
|
|
82
|
+
readonly tools: readonly PromptTool[];
|
|
83
|
+
readonly responseFormat: PromptResponseFormat;
|
|
84
|
+
/** True when getPrompt could not fetch the prompt and returned the `fallback` option. */
|
|
85
|
+
readonly isFallback: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ChatPrompt extends PromptBase {
|
|
89
|
+
readonly type: "chat";
|
|
90
|
+
readonly messages: readonly PromptMessage[];
|
|
91
|
+
readonly text: null;
|
|
92
|
+
/**
|
|
93
|
+
* Fills in the variables and replaces each placeholder message with the messages in its
|
|
94
|
+
* variable. `withPrompt` records the variables of the last call before it starts or inside it.
|
|
95
|
+
*/
|
|
96
|
+
compile<M = ChatMessage>(variables?: PromptVariables<M>): (ChatMessage | M)[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TextPrompt extends PromptBase {
|
|
100
|
+
readonly type: "text";
|
|
101
|
+
readonly messages: readonly [];
|
|
102
|
+
readonly text: string;
|
|
103
|
+
/**
|
|
104
|
+
* Fills in the variables. `withPrompt` records the variables of the last call before it starts
|
|
105
|
+
* or inside it.
|
|
106
|
+
*/
|
|
107
|
+
compile(variables?: PromptVariables): string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type Prompt = ChatPrompt | TextPrompt;
|
|
111
|
+
|
|
112
|
+
interface GetPromptOptionsBase {
|
|
113
|
+
/** Fetch the version that this label points to. Default "production" when `version` is not set. */
|
|
114
|
+
label?: string;
|
|
115
|
+
/** Fetch this version number. Do not set it together with `label`. */
|
|
116
|
+
version?: number;
|
|
117
|
+
/** Seconds before a call fetches a kept prompt again, in the background. Default 60. */
|
|
118
|
+
cacheTtlSeconds?: number;
|
|
119
|
+
/** API key with the prompts:read scope. Falls back to the initialized client, then `TELEMETRY_DEV_API_KEY`. */
|
|
120
|
+
apiKey?: string;
|
|
121
|
+
/** Falls back to the initialized client, then `TELEMETRY_DEV_BASE_URL`, then `https://ingest.telemetry.dev`. */
|
|
122
|
+
baseUrl?: string;
|
|
123
|
+
/** Falls back to the initialized client's `fetch`, then `globalThis.fetch`. */
|
|
124
|
+
fetch?: typeof fetch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface GetChatPromptOptions extends GetPromptOptionsBase {
|
|
128
|
+
/** Default "chat". getPrompt rejects a prompt of the other type. */
|
|
129
|
+
type?: "chat";
|
|
130
|
+
/** Messages to use when the prompt cannot be fetched and no copy of it is kept. */
|
|
131
|
+
fallback?: readonly PromptMessage[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface GetTextPromptOptions extends GetPromptOptionsBase {
|
|
135
|
+
type: "text";
|
|
136
|
+
/** Text to use when the prompt cannot be fetched and no copy of it is kept. */
|
|
137
|
+
fallback?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type GetPromptOptions = GetChatPromptOptions | GetTextPromptOptions;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Why getPrompt failed. `code` is the code of the API error (`prompt_not_found`,
|
|
144
|
+
* `label_not_found`, `insufficient_scope`, `invalid_api_key`, …), or `missing_api_key`,
|
|
145
|
+
* `timeout`, `network_error`, `invalid_response`, or `type_mismatch`. A response with a different
|
|
146
|
+
* status and no API error gives `http_<status>`, for example `http_302` for a redirect: getPrompt
|
|
147
|
+
* does not go to the location of a redirect.
|
|
148
|
+
*/
|
|
149
|
+
export class PromptError extends Error {
|
|
150
|
+
override readonly name = "PromptError";
|
|
151
|
+
|
|
152
|
+
constructor(
|
|
153
|
+
readonly code: string,
|
|
154
|
+
message: string,
|
|
155
|
+
readonly status?: number,
|
|
156
|
+
options?: { cause?: unknown },
|
|
157
|
+
) {
|
|
158
|
+
super(message, options);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface PromptContent {
|
|
163
|
+
readonly messages: readonly PromptMessage[];
|
|
164
|
+
readonly text: string | null;
|
|
165
|
+
readonly model: PromptModel;
|
|
166
|
+
readonly parameters: PromptParameters;
|
|
167
|
+
readonly tools: readonly PromptTool[];
|
|
168
|
+
readonly responseFormat: PromptResponseFormat;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface PromptResponse extends PromptContent {
|
|
172
|
+
readonly name: string;
|
|
173
|
+
readonly type: "chat" | "text";
|
|
174
|
+
readonly label: string | null;
|
|
175
|
+
readonly version: number;
|
|
176
|
+
readonly rollout: (PromptContent & { readonly percent: number; readonly version: number }) | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface CacheEntry {
|
|
180
|
+
response?: PromptResponse;
|
|
181
|
+
/** Why the last fetch failed, when there was no response to keep. */
|
|
182
|
+
error?: unknown;
|
|
183
|
+
/** When the last fetch ended. */
|
|
184
|
+
checkedAt: number;
|
|
185
|
+
/** No fetch before this time: the Retry-After of the last 429 or 503 response. */
|
|
186
|
+
retryAt: number;
|
|
187
|
+
pending?: Promise<PromptResponse>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The prompt of a `withPrompt` call and the variables that its spans record. */
|
|
191
|
+
interface PromptScope {
|
|
192
|
+
readonly prompt: Prompt;
|
|
193
|
+
variables: object | undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
197
|
+
/** The longest Retry-After that getPrompt honors, so that a bad header cannot stop fetches. */
|
|
198
|
+
const MAX_RETRY_AFTER_MS = 3_600_000;
|
|
199
|
+
/** How long a failed fetch with no copy answers calls, so that they do not each fetch again. */
|
|
200
|
+
const ERROR_TTL_MS = 10_000;
|
|
201
|
+
const MAX_CACHE_ENTRIES = 1_000;
|
|
202
|
+
/** IMF-fixdate, the HTTP date that servers send: `Sun, 06 Nov 1994 08:49:37 GMT`. */
|
|
203
|
+
const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
204
|
+
const VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
205
|
+
const MODEL_CALL_OPERATIONS = new Set<unknown>(["chat", "text_completion", "generate_content"]);
|
|
206
|
+
const PROMPT_KEY = createContextKey("telemetry.dev prompt");
|
|
207
|
+
|
|
208
|
+
const cache = new Map<string, CacheEntry>();
|
|
209
|
+
const compiledVariables = new WeakMap<Prompt, object>();
|
|
210
|
+
/** The Retry-After of the response that failed with this error, in milliseconds. */
|
|
211
|
+
const retryAfter = new WeakMap<PromptError, number>();
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetches a prompt version from telemetry.dev and keeps it. When the copy is older than
|
|
215
|
+
* `cacheTtlSeconds`, a call returns it at once and fetches a new one in the background. Only a call
|
|
216
|
+
* with no copy waits for a fetch, at most 10 seconds. Calls for the same prompt share one fetch.
|
|
217
|
+
* When a fetch fails, getPrompt returns the last copy that it kept; with no copy it returns
|
|
218
|
+
* `fallback`. With neither it rejects with a `PromptError`: unlike the telemetry functions,
|
|
219
|
+
* getPrompt cannot fail open, because the app cannot run without the prompt. With no copy, the
|
|
220
|
+
* calls of the next 10 seconds (or the TTL, if it is shorter) fail the same way without a fetch.
|
|
221
|
+
*
|
|
222
|
+
* A label with a gradual rollout gives each session one version: the choice uses the session id
|
|
223
|
+
* of `propagateAttributes`, or a random choice per call without one.
|
|
224
|
+
*/
|
|
225
|
+
export function getPrompt(name: string, options: GetTextPromptOptions): Promise<TextPrompt>;
|
|
226
|
+
export function getPrompt(name: string, options?: GetChatPromptOptions): Promise<ChatPrompt>;
|
|
227
|
+
export async function getPrompt(name: string, options: GetPromptOptions = {}): Promise<Prompt> {
|
|
228
|
+
const type = options.type ?? "chat";
|
|
229
|
+
checkOptions(name, options, type);
|
|
230
|
+
|
|
231
|
+
const core = currentClient().core;
|
|
232
|
+
const env = resolveEnv();
|
|
233
|
+
const apiKey = options.apiKey ?? core?.config.apiKey ?? env.TELEMETRY_DEV_API_KEY;
|
|
234
|
+
|
|
235
|
+
// An empty value counts as unset, as in the Python SDK.
|
|
236
|
+
const baseUrl = (
|
|
237
|
+
options.baseUrl ||
|
|
238
|
+
core?.config.baseUrl ||
|
|
239
|
+
env.TELEMETRY_DEV_BASE_URL ||
|
|
240
|
+
DEFAULT_BASE_URL
|
|
241
|
+
).replace(/\/+$/, "");
|
|
242
|
+
|
|
243
|
+
const label = options.version === undefined ? (options.label ?? "production") : null;
|
|
244
|
+
const onError = core?.config.onError;
|
|
245
|
+
let response: PromptResponse;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
if (!apiKey) {
|
|
249
|
+
throw new PromptError(
|
|
250
|
+
"missing_api_key",
|
|
251
|
+
"No API key: pass apiKey or set TELEMETRY_DEV_API_KEY",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const url = new URL(`${baseUrl}/v1/prompts`);
|
|
256
|
+
url.searchParams.set("name", name);
|
|
257
|
+
|
|
258
|
+
if (label === null) url.searchParams.set("version", String(options.version));
|
|
259
|
+
else url.searchParams.set("label", label);
|
|
260
|
+
|
|
261
|
+
response = await load(
|
|
262
|
+
[
|
|
263
|
+
baseUrl,
|
|
264
|
+
keyFingerprint(apiKey),
|
|
265
|
+
name,
|
|
266
|
+
label === null ? `version:${options.version}` : `label:${label}`,
|
|
267
|
+
].join("\0"),
|
|
268
|
+
(options.cacheTtlSeconds ?? 60) * 1000,
|
|
269
|
+
() =>
|
|
270
|
+
fetchPrompt(
|
|
271
|
+
url,
|
|
272
|
+
{
|
|
273
|
+
authorization: `Bearer ${apiKey}`,
|
|
274
|
+
"x-telemetry-dev-sdk": core?.config.sdkName ?? "@telemetry-dev/sdk",
|
|
275
|
+
},
|
|
276
|
+
options.fetch ?? core?.config.fetchImpl ?? globalThis.fetch,
|
|
277
|
+
),
|
|
278
|
+
onError,
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (options.fallback === undefined) throw error;
|
|
282
|
+
reportError(onError, error);
|
|
283
|
+
|
|
284
|
+
return createPrompt({
|
|
285
|
+
name,
|
|
286
|
+
type,
|
|
287
|
+
label,
|
|
288
|
+
version: null,
|
|
289
|
+
isFallback: true,
|
|
290
|
+
content: {
|
|
291
|
+
messages: type === "chat" ? (options.fallback as readonly PromptMessage[]) : [],
|
|
292
|
+
text: type === "text" ? (options.fallback as string) : null,
|
|
293
|
+
model: null,
|
|
294
|
+
parameters: {},
|
|
295
|
+
tools: [],
|
|
296
|
+
responseFormat: { type: "text" },
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (response.type !== type) {
|
|
302
|
+
throw new PromptError(
|
|
303
|
+
"type_mismatch",
|
|
304
|
+
`${name} is a ${response.type} prompt; pass type: "${response.type}"`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const chosen = response.rollout && inRollout(response) ? response.rollout : response;
|
|
309
|
+
|
|
310
|
+
return createPrompt({
|
|
311
|
+
name: response.name,
|
|
312
|
+
type,
|
|
313
|
+
label: response.label,
|
|
314
|
+
version: chosen.version,
|
|
315
|
+
isFallback: false,
|
|
316
|
+
content: chosen,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Runs `fn` with `prompt` as the prompt of each model call (generation span) that starts inside
|
|
322
|
+
* it: the spans get `td.prompt.name`, `td.prompt.version`, `td.prompt.label`, and, when input
|
|
323
|
+
* capture is on, `td.prompt.variables`. The variables are those of the last `compile()` of this
|
|
324
|
+
* prompt object before withPrompt starts, or of the last `compile()` inside `fn`, so a compile in
|
|
325
|
+
* another task does not change them once withPrompt runs. Concurrent tasks in `fn` share the
|
|
326
|
+
* variables, so call withPrompt in each task that compiles its own.
|
|
327
|
+
*/
|
|
328
|
+
export function withPrompt<T>(prompt: Prompt, fn: () => T): T {
|
|
329
|
+
const scope: PromptScope = { prompt, variables: compiledVariables.get(prompt) };
|
|
330
|
+
|
|
331
|
+
return withContext(activeContext().setValue(PROMPT_KEY, scope), fn);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The td.prompt.* attributes for a span with this operation that starts in `ctx`. */
|
|
335
|
+
export function promptAttributes(
|
|
336
|
+
ctx: Context,
|
|
337
|
+
operation: unknown,
|
|
338
|
+
capture: FieldCaptureConfig,
|
|
339
|
+
): Attributes | undefined {
|
|
340
|
+
if (!MODEL_CALL_OPERATIONS.has(operation)) return undefined;
|
|
341
|
+
|
|
342
|
+
const scope = (ctx.getValue(PROMPT_KEY) ?? activeContext().getValue(PROMPT_KEY)) as
|
|
343
|
+
| PromptScope
|
|
344
|
+
| undefined;
|
|
345
|
+
|
|
346
|
+
if (!scope) return undefined;
|
|
347
|
+
const { prompt, variables } = scope;
|
|
348
|
+
|
|
349
|
+
return omitUndefined({
|
|
350
|
+
"td.prompt.name": prompt.name,
|
|
351
|
+
"td.prompt.version": prompt.version ?? undefined,
|
|
352
|
+
"td.prompt.label": prompt.label ?? undefined,
|
|
353
|
+
"td.prompt.variables":
|
|
354
|
+
variables !== undefined && capture.captureInput
|
|
355
|
+
? prepareCaptureValue("td.prompt.variables", variables, capture)
|
|
356
|
+
: undefined,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function checkOptions(name: string, options: GetPromptOptions, type: string): void {
|
|
361
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
362
|
+
throw new TypeError("name must be a non-empty string");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (type !== "chat" && type !== "text") throw new TypeError('type must be "chat" or "text"');
|
|
366
|
+
|
|
367
|
+
if (options.label !== undefined && options.version !== undefined) {
|
|
368
|
+
throw new TypeError("Pass label or version, not both");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (
|
|
372
|
+
options.version !== undefined &&
|
|
373
|
+
(!Number.isSafeInteger(options.version) || options.version < 1)
|
|
374
|
+
) {
|
|
375
|
+
throw new RangeError("version must be a positive integer");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ttl = options.cacheTtlSeconds;
|
|
379
|
+
|
|
380
|
+
if (ttl !== undefined && !(Number.isFinite(ttl) && ttl >= 0)) {
|
|
381
|
+
throw new RangeError("cacheTtlSeconds must be a non-negative number");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
options.fallback !== undefined &&
|
|
386
|
+
(type === "chat" ? !Array.isArray(options.fallback) : typeof options.fallback !== "string")
|
|
387
|
+
) {
|
|
388
|
+
throw new TypeError(
|
|
389
|
+
type === "chat" ? "fallback must be a list of messages" : "fallback must be a string",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Returns the kept copy of a prompt, and fetches a new copy in the background when it is older
|
|
396
|
+
* than the TTL. Only a call with no copy waits for a fetch. A failed fetch with no copy fails the
|
|
397
|
+
* calls of the next ERROR_TTL_MS (or the TTL, if it is shorter) without a fetch.
|
|
398
|
+
*/
|
|
399
|
+
async function load(
|
|
400
|
+
key: string,
|
|
401
|
+
ttlMs: number,
|
|
402
|
+
fetchResponse: () => Promise<PromptResponse>,
|
|
403
|
+
onError: ((cause: Error) => void) | undefined,
|
|
404
|
+
): Promise<PromptResponse> {
|
|
405
|
+
let entry = cache.get(key);
|
|
406
|
+
|
|
407
|
+
if (!entry) {
|
|
408
|
+
if (cache.size >= MAX_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
|
|
409
|
+
entry = { checkedAt: 0, retryAt: 0 };
|
|
410
|
+
cache.set(key, entry);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const fresh =
|
|
414
|
+
Date.now() <
|
|
415
|
+
Math.max(
|
|
416
|
+
entry.checkedAt + (entry.response ? ttlMs : Math.min(ttlMs, ERROR_TTL_MS)),
|
|
417
|
+
entry.retryAt,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
if (entry.response) {
|
|
421
|
+
if (!fresh) entry.pending ??= refresh(entry, fetchResponse, onError);
|
|
422
|
+
|
|
423
|
+
return entry.response;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (fresh) throw entry.error;
|
|
427
|
+
|
|
428
|
+
return (entry.pending ??= refresh(entry, fetchResponse, onError));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function refresh(
|
|
432
|
+
entry: CacheEntry,
|
|
433
|
+
fetchResponse: () => Promise<PromptResponse>,
|
|
434
|
+
onError: ((cause: Error) => void) | undefined,
|
|
435
|
+
): Promise<PromptResponse> {
|
|
436
|
+
return fetchResponse()
|
|
437
|
+
.then(
|
|
438
|
+
(response) => {
|
|
439
|
+
entry.response = response;
|
|
440
|
+
entry.retryAt = 0;
|
|
441
|
+
|
|
442
|
+
return response;
|
|
443
|
+
},
|
|
444
|
+
(error: unknown) => {
|
|
445
|
+
const delay = error instanceof PromptError ? retryAfter.get(error) : undefined;
|
|
446
|
+
entry.retryAt = delay === undefined ? 0 : Date.now() + delay;
|
|
447
|
+
|
|
448
|
+
if (!entry.response) {
|
|
449
|
+
entry.error = error;
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
reportError(onError, error);
|
|
454
|
+
|
|
455
|
+
return entry.response;
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
.finally(() => {
|
|
459
|
+
// A failed fetch counts too, so that calls do not each start a fetch while it fails.
|
|
460
|
+
entry.checkedAt = Date.now();
|
|
461
|
+
entry.pending = undefined;
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function fetchPrompt(
|
|
466
|
+
url: URL,
|
|
467
|
+
headers: Record<string, string>,
|
|
468
|
+
fetchImpl: typeof fetch,
|
|
469
|
+
): Promise<PromptResponse> {
|
|
470
|
+
const controller = new AbortController();
|
|
471
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const res = await fetchImpl(url, {
|
|
475
|
+
method: "GET",
|
|
476
|
+
headers,
|
|
477
|
+
signal: controller.signal,
|
|
478
|
+
// A redirect to another host must not get the API key, so a 3xx fails below like any status
|
|
479
|
+
// that is not OK. Cloudflare Workers reject redirect: "error".
|
|
480
|
+
redirect: "manual",
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const body: unknown = await res.json().catch(() => undefined);
|
|
484
|
+
|
|
485
|
+
if (!res.ok) {
|
|
486
|
+
const error = isRecord(body) && isRecord(body.error) ? body.error : {};
|
|
487
|
+
|
|
488
|
+
const failure = new PromptError(
|
|
489
|
+
typeof error.code === "string" ? error.code : `http_${res.status}`,
|
|
490
|
+
typeof error.message === "string" ? error.message : `telemetry.dev returned ${res.status}`,
|
|
491
|
+
res.status,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const delay = retryAfterMs(res);
|
|
495
|
+
if (delay !== undefined) retryAfter.set(failure, delay);
|
|
496
|
+
|
|
497
|
+
throw failure;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const response = parseResponse(body);
|
|
501
|
+
|
|
502
|
+
if (!response) {
|
|
503
|
+
throw new PromptError("invalid_response", "telemetry.dev returned a prompt it cannot read");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return response;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
// The timeout can also cut the body, which then reads as an invalid response.
|
|
509
|
+
if (controller.signal.aborted) {
|
|
510
|
+
throw new PromptError("timeout", `Fetching the prompt took more than ${FETCH_TIMEOUT_MS} ms`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (error instanceof PromptError) throw error;
|
|
514
|
+
|
|
515
|
+
throw new PromptError("network_error", "Could not reach telemetry.dev", undefined, {
|
|
516
|
+
cause: error,
|
|
517
|
+
});
|
|
518
|
+
} finally {
|
|
519
|
+
clearTimeout(timer);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function createPrompt(input: {
|
|
524
|
+
name: string;
|
|
525
|
+
type: "chat" | "text";
|
|
526
|
+
label: string | null;
|
|
527
|
+
version: number | null;
|
|
528
|
+
isFallback: boolean;
|
|
529
|
+
content: Omit<PromptContent, "model"> & { readonly model: PromptModel | null };
|
|
530
|
+
}): Prompt {
|
|
531
|
+
const { content, type } = input;
|
|
532
|
+
|
|
533
|
+
const prompt: Prompt = Object.freeze({
|
|
534
|
+
name: input.name,
|
|
535
|
+
type,
|
|
536
|
+
version: input.version,
|
|
537
|
+
label: input.label,
|
|
538
|
+
messages: content.messages,
|
|
539
|
+
text: content.text,
|
|
540
|
+
model: content.model,
|
|
541
|
+
parameters: content.parameters,
|
|
542
|
+
tools: content.tools,
|
|
543
|
+
responseFormat: content.responseFormat,
|
|
544
|
+
isFallback: input.isFallback,
|
|
545
|
+
compile(variables: PromptVariables<unknown> = {}) {
|
|
546
|
+
compiledVariables.set(prompt, variables);
|
|
547
|
+
const scope = activeContext().getValue(PROMPT_KEY) as PromptScope | undefined;
|
|
548
|
+
|
|
549
|
+
if (scope?.prompt === prompt) scope.variables = variables;
|
|
550
|
+
|
|
551
|
+
return type === "chat"
|
|
552
|
+
? compileMessages(content.messages, variables)
|
|
553
|
+
: fill(content.text ?? "", variables);
|
|
554
|
+
},
|
|
555
|
+
}) as Prompt;
|
|
556
|
+
|
|
557
|
+
return prompt;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function compileMessages(
|
|
561
|
+
messages: readonly PromptMessage[],
|
|
562
|
+
variables: PromptVariables<unknown>,
|
|
563
|
+
): unknown[] {
|
|
564
|
+
return messages.flatMap((message) => {
|
|
565
|
+
if (message.role !== "placeholder") {
|
|
566
|
+
return [{ role: message.role, content: fill(message.content, variables) }];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const value = Object.hasOwn(variables, message.name) ? variables[message.name] : undefined;
|
|
570
|
+
|
|
571
|
+
if (value == null) return [];
|
|
572
|
+
|
|
573
|
+
if (!Array.isArray(value)) {
|
|
574
|
+
throw new TypeError(`The ${message.name} variable must be a list of messages`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return value;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function fill(text: string, variables: PromptVariables<unknown>): string {
|
|
582
|
+
return text.replace(VARIABLE, (_tag, name: string) => {
|
|
583
|
+
const value = Object.hasOwn(variables, name) ? variables[name] : undefined;
|
|
584
|
+
|
|
585
|
+
if (value == null) return "";
|
|
586
|
+
|
|
587
|
+
return typeof value === "string" ? value : (jsonAttr(value) ?? "");
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// One session keeps one version: FNV-1a 32-bit of "<name>:<label>:<session id>", mod 100.
|
|
592
|
+
function inRollout(response: PromptResponse): boolean {
|
|
593
|
+
const sessionId = sessionIdOf(activeContext()) ?? currentClient().core?.config.processSessionId;
|
|
594
|
+
|
|
595
|
+
const bucket =
|
|
596
|
+
sessionId === undefined
|
|
597
|
+
? Math.floor(Math.random() * 100)
|
|
598
|
+
: fnv1a(`${response.name}:${response.label}:${sessionId}`) % 100;
|
|
599
|
+
|
|
600
|
+
return bucket < response.rollout!.percent;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function fnv1a(text: string): number {
|
|
604
|
+
let hash = 2166136261;
|
|
605
|
+
|
|
606
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
607
|
+
hash = Math.imul(hash ^ byte, 16777619) >>> 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return hash;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function keyFingerprint(apiKey: string): string {
|
|
614
|
+
const digest = sha256(new TextEncoder().encode(apiKey)).subarray(0, 16);
|
|
615
|
+
|
|
616
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The Retry-After of a 429 or 503 response, as seconds or an IMF-fixdate HTTP date, capped. */
|
|
620
|
+
function retryAfterMs(res: Response): number | undefined {
|
|
621
|
+
if (res.status !== 429 && res.status !== 503) return undefined;
|
|
622
|
+
|
|
623
|
+
const value = res.headers.get("retry-after")?.trim();
|
|
624
|
+
|
|
625
|
+
if (!value) return undefined;
|
|
626
|
+
|
|
627
|
+
let ms: number;
|
|
628
|
+
|
|
629
|
+
if (/^\d+$/.test(value)) ms = Number(value) * 1000;
|
|
630
|
+
else if (HTTP_DATE.test(value)) ms = Date.parse(value) - Date.now();
|
|
631
|
+
else return undefined;
|
|
632
|
+
|
|
633
|
+
return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function parseResponse(body: unknown): PromptResponse | undefined {
|
|
637
|
+
if (!isRecord(body)) return undefined;
|
|
638
|
+
const { name, type, label, version, rollout } = body;
|
|
639
|
+
|
|
640
|
+
if (
|
|
641
|
+
typeof name !== "string" ||
|
|
642
|
+
(type !== "chat" && type !== "text") ||
|
|
643
|
+
(label !== null && typeof label !== "string") ||
|
|
644
|
+
!isVersion(version)
|
|
645
|
+
) {
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const content = parseContent(body, type);
|
|
650
|
+
|
|
651
|
+
if (!content) return undefined;
|
|
652
|
+
|
|
653
|
+
if (rollout == null) return deepFreeze({ name, type, label, version, ...content, rollout: null });
|
|
654
|
+
|
|
655
|
+
if (
|
|
656
|
+
!isRecord(rollout) ||
|
|
657
|
+
!isVersion(rollout.version) ||
|
|
658
|
+
!Number.isInteger(rollout.percent) ||
|
|
659
|
+
(rollout.percent as number) < 0 ||
|
|
660
|
+
(rollout.percent as number) > 100
|
|
661
|
+
) {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const rolloutContent = parseContent(rollout, type);
|
|
666
|
+
|
|
667
|
+
if (!rolloutContent) return undefined;
|
|
668
|
+
|
|
669
|
+
return deepFreeze({
|
|
670
|
+
name,
|
|
671
|
+
type,
|
|
672
|
+
label,
|
|
673
|
+
version,
|
|
674
|
+
...content,
|
|
675
|
+
rollout: { percent: rollout.percent as number, version: rollout.version, ...rolloutContent },
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function parseContent(
|
|
680
|
+
value: Record<string, unknown>,
|
|
681
|
+
type: "chat" | "text",
|
|
682
|
+
): PromptContent | undefined {
|
|
683
|
+
const { messages, text, model, parameters, tools, responseFormat } = value;
|
|
684
|
+
|
|
685
|
+
const validBody =
|
|
686
|
+
type === "chat"
|
|
687
|
+
? Array.isArray(messages) && messages.every(isPromptMessage)
|
|
688
|
+
: typeof text === "string";
|
|
689
|
+
|
|
690
|
+
if (
|
|
691
|
+
!validBody ||
|
|
692
|
+
!isRecord(model) ||
|
|
693
|
+
typeof model.provider !== "string" ||
|
|
694
|
+
typeof model.name !== "string" ||
|
|
695
|
+
!isRecord(parameters) ||
|
|
696
|
+
!Array.isArray(tools) ||
|
|
697
|
+
!tools.every((tool) => isRecord(tool) && typeof tool.name === "string") ||
|
|
698
|
+
!isRecord(responseFormat) ||
|
|
699
|
+
typeof responseFormat.type !== "string"
|
|
700
|
+
) {
|
|
701
|
+
return undefined;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
messages: type === "chat" ? (messages as PromptMessage[]) : [],
|
|
706
|
+
text: type === "text" ? (text as string) : null,
|
|
707
|
+
model: model as unknown as PromptModel,
|
|
708
|
+
parameters: parameters as PromptParameters,
|
|
709
|
+
tools: tools as PromptTool[],
|
|
710
|
+
responseFormat: responseFormat as unknown as PromptResponseFormat,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isPromptMessage(value: unknown): value is PromptMessage {
|
|
715
|
+
if (!isRecord(value)) return false;
|
|
716
|
+
|
|
717
|
+
return value.role === "placeholder"
|
|
718
|
+
? typeof value.name === "string"
|
|
719
|
+
: (value.role === "system" || value.role === "user" || value.role === "assistant") &&
|
|
720
|
+
typeof value.content === "string";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function isVersion(value: unknown): value is number {
|
|
724
|
+
return Number.isSafeInteger(value) && (value as number) >= 1;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
728
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Prompt objects share the cached response, so an app cannot change the cache through them.
|
|
732
|
+
function deepFreeze<T>(value: T): T {
|
|
733
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
734
|
+
Object.freeze(value);
|
|
735
|
+
|
|
736
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return value;
|
|
740
|
+
}
|