@to11ai/sdk 1.0.0 → 2.0.0-rc.1

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/src/context.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Observability context — the `session → conversation → turn` hierarchy that
3
+ * maps onto to11's telemetry grouping (Session → Conversation → Turn →
4
+ * LLM-Call). Each level provides a lazy default for the level below; explicit
5
+ * control is opt-in per use case. See
6
+ * docs/product-specs/2026-07-14-to11-2691-sdk-simpler-setup.md.
7
+ *
8
+ * Header-only: `turn.headers(prompt?)` returns a plain object the caller applies
9
+ * to their own provider client. It never sends a request.
10
+ */
11
+
12
+ import {
13
+ type GatewayPromptProvenance,
14
+ gatewayAuthHeaders,
15
+ gatewayPromptHeaders,
16
+ } from "./gateway";
17
+
18
+ /** Auth/config the client hands to the context for gateway header building. */
19
+ export interface TurnAuthContext {
20
+ readonly apiKey: string;
21
+ readonly projectId: string;
22
+ readonly env?: string;
23
+ }
24
+
25
+ /**
26
+ * A prompt argument accepted by `turn.headers()`. Either the flat provenance
27
+ * (a neutral `FetchedPrompt`) or a `format`-shaped result carrying it under
28
+ * `metadata` — both are accepted so the format and neutral render paths carry
29
+ * provenance identically.
30
+ */
31
+ export type PromptForHeaders =
32
+ | GatewayPromptProvenance
33
+ | { metadata: GatewayPromptProvenance };
34
+
35
+ function randomHex(bytes: number): string {
36
+ const buf = new Uint8Array(bytes);
37
+ crypto.getRandomValues(buf);
38
+ let out = "";
39
+ for (const b of buf) out += b.toString(16).padStart(2, "0");
40
+ return out;
41
+ }
42
+
43
+ /** A fresh, sampled W3C `traceparent` (`00-<32hex>-<16hex>-01`). */
44
+ function newTraceparent(): string {
45
+ return `00-${randomHex(16)}-${randomHex(8)}-01`;
46
+ }
47
+
48
+ function newId(): string {
49
+ return crypto.randomUUID();
50
+ }
51
+
52
+ function provenanceOf(prompt: PromptForHeaders): GatewayPromptProvenance {
53
+ return "metadata" in prompt ? prompt.metadata : prompt;
54
+ }
55
+
56
+ /**
57
+ * One agent turn — a single trace. A turn's `traceparent` is fixed for its
58
+ * lifetime, so every LLM-call made under it groups into one trace.
59
+ */
60
+ export class Turn {
61
+ /** The fixed W3C traceparent for this turn (one trace). */
62
+ readonly traceparent: string;
63
+
64
+ constructor(
65
+ private readonly auth: TurnAuthContext,
66
+ readonly sessionId: string,
67
+ readonly conversationId: string,
68
+ traceparent?: string,
69
+ ) {
70
+ this.traceparent = traceparent ?? newTraceparent();
71
+ }
72
+
73
+ /**
74
+ * The complete gateway header bag: auth + session + conversation +
75
+ * traceparent, plus prompt provenance when a `prompt` is supplied. A pure
76
+ * read of the turn's fixed ids — reuse it across a tool-loop or call per
77
+ * request; the result is identical.
78
+ *
79
+ * Only valid for requests targeting the gateway (`GATEWAY_URL`); it carries
80
+ * the to11 API key and must never be attached to a direct provider endpoint.
81
+ *
82
+ * @example One call (inline turn)
83
+ * const res = await openai.chat.completions.create(
84
+ * { ...prompt.config, messages: prompt.messages },
85
+ * { headers: to11.turn().headers(prompt) },
86
+ * );
87
+ *
88
+ * @example Tool loop — hoist the turn so every call shares one trace
89
+ * const turn = to11.turn();
90
+ * const headers = turn.headers(prompt);
91
+ * while (true) {
92
+ * const res = await openai.chat.completions.create({ ...prompt.config, messages, tools }, { headers });
93
+ * // …reuse `headers` for every call in the loop…
94
+ * }
95
+ */
96
+ headers(prompt?: PromptForHeaders): Record<string, string> {
97
+ const headers: Record<string, string> = {
98
+ ...gatewayAuthHeaders({
99
+ apiKey: this.auth.apiKey,
100
+ projectId: this.auth.projectId,
101
+ ...(this.auth.env !== undefined ? { env: this.auth.env } : {}),
102
+ }),
103
+ "x-to11-session-id": this.sessionId,
104
+ "x-to11-conversation-id": this.conversationId,
105
+ traceparent: this.traceparent,
106
+ };
107
+ if (prompt !== undefined) {
108
+ Object.assign(headers, gatewayPromptHeaders(provenanceOf(prompt)));
109
+ }
110
+ return headers;
111
+ }
112
+ }
113
+
114
+ /** One agent thread — a stable conversation id shared across all its turns. */
115
+ export class Conversation {
116
+ /** Stable conversation id, reused by every turn created from this object. */
117
+ readonly id: string;
118
+
119
+ constructor(
120
+ private readonly auth: TurnAuthContext,
121
+ readonly sessionId: string,
122
+ id?: string,
123
+ ) {
124
+ this.id = id ?? newId();
125
+ }
126
+
127
+ /** Start a new turn (fresh trace) in this conversation. */
128
+ turn(): Turn {
129
+ return new Turn(this.auth, this.sessionId, this.id);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * One unit of work. Groups conversations under a single session id. A session
135
+ * lazily owns one default conversation, used by `session.turn()`; call
136
+ * `session.conversation()` for additional (side/parallel) agent threads.
137
+ */
138
+ export class Session {
139
+ /** Stable session id, carried by every conversation and turn beneath it. */
140
+ readonly id: string;
141
+ private defaultConversation: Conversation | undefined;
142
+
143
+ constructor(
144
+ private readonly auth: TurnAuthContext,
145
+ id?: string,
146
+ ) {
147
+ this.id = id ?? newId();
148
+ }
149
+
150
+ /**
151
+ * Create a distinct conversation (a separate agent thread) in this session.
152
+ * Use this when one unit of work runs multiple agents (side / parallel
153
+ * agents): each conversation gets its own `x-to11-conversation-id`, all under
154
+ * the one session.
155
+ *
156
+ * @example Multiple agents in one session
157
+ * const session = to11.session();
158
+ * const main = session.conversation(); // main agent thread
159
+ * const research = session.conversation(); // separate research thread, same session
160
+ *
161
+ * await openai.chat.completions.create(
162
+ * { ...mainPrompt.config, messages: mainMessages },
163
+ * { headers: main.turn().headers(mainPrompt) },
164
+ * );
165
+ * await openai.chat.completions.create(
166
+ * { ...researchPrompt.config, messages: researchMessages },
167
+ * { headers: research.turn().headers(researchPrompt) },
168
+ * );
169
+ */
170
+ conversation(id?: string): Conversation {
171
+ return new Conversation(this.auth, this.id, id);
172
+ }
173
+
174
+ /**
175
+ * Start a turn in the session's single implicit conversation. The default
176
+ * conversation is created lazily on first call and reused, so consecutive
177
+ * `session.turn()` calls share one `x-to11-conversation-id` (each with its
178
+ * own `traceparent`).
179
+ *
180
+ * @example Multi-turn chat — every turn shares the session's conversation
181
+ * const session = to11.session();
182
+ * for await (const input of inputs) {
183
+ * messages.push({ role: "user", content: input });
184
+ * const turn = session.turn(); // fresh trace, same conversation id
185
+ * const res = await openai.chat.completions.create(
186
+ * { ...prompt.config, messages },
187
+ * { headers: turn.headers(prompt) },
188
+ * );
189
+ * messages.push(res.choices[0].message);
190
+ * }
191
+ */
192
+ turn(): Turn {
193
+ if (this.defaultConversation === undefined) {
194
+ this.defaultConversation = this.conversation();
195
+ }
196
+ return this.defaultConversation.turn();
197
+ }
198
+ }
package/src/errors.ts CHANGED
@@ -113,6 +113,36 @@ export class To11aiResponseValidationError extends To11aiError {
113
113
  }
114
114
  }
115
115
 
116
+ /**
117
+ * Thrown by the provider-shape converters (`toOpenAIMessages` /
118
+ * `toAnthropicMessages`, and `render({ format })`) in `onUnknownRole: "error"`
119
+ * mode when a message carries a role outside the known set
120
+ * (`system | user | assistant | developer | tool`). `code` is the stable
121
+ * `"unknown_role"` slug; `role` / `index` locate the offending turn.
122
+ *
123
+ * A client-side conversion error — it extends `To11aiError` (not
124
+ * `To11aiApiError`), so it never carries an HTTP status.
125
+ */
126
+ export class UnknownRoleError extends To11aiError {
127
+ readonly code = "unknown_role";
128
+ readonly role: string;
129
+ readonly index: number;
130
+
131
+ constructor(role: string, index: number) {
132
+ super(
133
+ `[to11ai/sdk] message[${index}] has unknown role ${JSON.stringify(role)}; expected system|user|assistant|developer|tool`,
134
+ );
135
+ this.name = "UnknownRoleError";
136
+ this.role = role;
137
+ this.index = index;
138
+ Object.setPrototypeOf(this, new.target.prototype);
139
+ }
140
+
141
+ static is(err: unknown): err is UnknownRoleError {
142
+ return err instanceof UnknownRoleError;
143
+ }
144
+ }
145
+
116
146
  /**
117
147
  * Thrown when strict-mode rejects a label move for an unregistered label.
118
148
  *
@@ -252,7 +282,7 @@ export class EnvNotEnabledForProviderError extends To11aiApiError {
252
282
 
253
283
  // ─── Prompt resolution typed errors (TO11-980 / S9) ────────────────────────
254
284
  //
255
- // These four subclasses give `prompts.fetch()` callers a discriminable error
285
+ // These four subclasses give `prompts.render()` callers a discriminable error
256
286
  // family for prompt resolution. Each:
257
287
  // - extends `To11aiApiError`, so existing broad `catch (err instanceof
258
288
  // To11aiApiError)` blocks keep matching (AC8),
@@ -327,7 +357,7 @@ export interface EnvNotFoundReason {
327
357
  }
328
358
 
329
359
  /**
330
- * Thrown when no serving environment can be resolved for a `prompts.fetch()`
360
+ * Thrown when no serving environment can be resolved for a `prompts.render()`
331
361
  * call. One class, two `reason.source` discriminants (see
332
362
  * `EnvNotFoundReason`): client-eager `"missing"` (R4) and server-forwarded
333
363
  * `"unknown"` (`ENV_NOT_FOUND`).
@@ -357,7 +387,7 @@ export class EnvNotFoundError extends To11aiApiError {
357
387
  /** Build the client-eager (missing-at-both) variant — no transport call. */
358
388
  static missing(env?: string): EnvNotFoundError {
359
389
  return new EnvNotFoundError(
360
- "[to11ai/sdk] No environment resolvable for prompts.fetch(): none supplied at client init and none supplied per call. Pass `env` to createClient or fetch(slug, { env }).",
390
+ "[to11ai/sdk] No environment resolvable for prompts.render(): none supplied at client init and none supplied per call. Pass `env` to createClient or render(slug, { env }).",
361
391
  { env, source: "missing" },
362
392
  undefined,
363
393
  0,
package/src/gateway.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import { UnknownRoleError } from "./errors";
1
2
  import {
2
3
  ENV_SLUG_REGEX,
3
- type FetchedPrompt,
4
4
  type RenderedChatMessage,
5
5
  type ResolvedPrompt,
6
6
  type ResolvedToolDefinition,
7
+ type UnknownRoleMode,
7
8
  } from "./prompts/types";
8
9
 
9
10
  export interface GatewayAuthOptions {
@@ -57,7 +58,7 @@ export function gatewayAuthHeaders(
57
58
  }
58
59
  // Enforce the project-environments registry slug rule (lowercase-kebab,
59
60
  // 1-63 chars) eagerly — same `ENV_SLUG_REGEX` already applied on the
60
- // createClient / fetch() paths — so a misconfigured env (e.g. "Prod",
61
+ // createClient / render() paths — so a misconfigured env (e.g. "Prod",
61
62
  // "bad env", >63 chars) fails locally with a clear SDK error instead of
62
63
  // being copied verbatim into x-to11-env and rejected later at the
63
64
  // gateway/API boundary (TO11-1424).
@@ -78,44 +79,53 @@ export function gatewayAuthHeaders(
78
79
  * follows from a resolved prompt:
79
80
  * - `x-to11-prompt-id`, `x-to11-prompt-version` — always.
80
81
  * - `x-to11-release-id` — when the result carries a non-null `releaseId`
81
- * (the enriched `fetch()` result; a legacy `resolve()` result has no
82
+ * (the enriched `render()` result; a legacy `resolve()` result has no
82
83
  * `releaseId`, so it is omitted — non-breaking).
83
84
  * - `x-to11-variant-name` — IFF `variantName` is present (i.e. the resolved
84
85
  * release was weighted); omitted for `single`-strategy releases.
85
- * - `x-to11-prompt-labels` — omitted when empty (the gateway rejects empty
86
- * header values); behavior unchanged.
86
+ * - `x-to11-prompt-label` — the single label the prompt resolved through (the
87
+ * env slug today); omitted when empty (the gateway rejects empty values).
87
88
  *
88
- * The parameter type is WIDENED to accept either the enriched `fetch()`
89
+ * The parameter type is WIDENED to accept either the enriched `render()`
89
90
  * result or the legacy `ResolvedPrompt`. The new fields are read when present
90
91
  * and omitted otherwise, so an old `resolve()` result still produces exactly
91
92
  * the original three headers (non-breaking; D1/D7 shape coexistence).
92
93
  *
93
94
  * @example
94
- * const result = await client.prompts.fetch(slug, { env: "prod" });
95
+ * const result = await client.prompts.render(slug, { env: "prod" });
95
96
  * const headers = gatewayPromptHeaders(result);
96
97
  */
98
+ export interface GatewayPromptProvenance {
99
+ promptId: string;
100
+ version: number;
101
+ /** The single label the prompt resolved through (env slug today). */
102
+ label: string;
103
+ /** Present on an enriched result; a legacy `resolve()` result omits it. */
104
+ releaseId?: string | null;
105
+ /** Present IFF the resolved release was weighted. */
106
+ variantName?: string;
107
+ }
108
+
97
109
  export function gatewayPromptHeaders(
98
- prompt: ResolvedPrompt | FetchedPrompt,
110
+ prompt: GatewayPromptProvenance,
99
111
  ): Record<string, string> {
100
112
  const headers: Record<string, string> = {
101
113
  "x-to11-prompt-id": prompt.promptId,
102
114
  "x-to11-prompt-version": String(prompt.version),
103
115
  };
104
- // `releaseId` is present only on the enriched fetch() result; emit the
105
- // header only for a non-null release id (the offline-fallback path can
106
- // carry an explicit `null`, which is omitted — the gateway rejects empty
107
- // header values).
108
- const releaseId = (prompt as Partial<FetchedPrompt>).releaseId;
109
- if (typeof releaseId === "string" && releaseId.length > 0) {
110
- headers["x-to11-release-id"] = releaseId;
116
+ // Emit the release id only when it is a non-null, non-empty string — the
117
+ // offline-fallback path can carry an explicit `null`, and the gateway
118
+ // rejects empty header values.
119
+ if (typeof prompt.releaseId === "string" && prompt.releaseId.length > 0) {
120
+ headers["x-to11-release-id"] = prompt.releaseId;
111
121
  }
112
- // `variantName` is present IFF the release was weighted (R7).
113
- const variantName = (prompt as Partial<FetchedPrompt>).variantName;
114
- if (typeof variantName === "string" && variantName.length > 0) {
115
- headers["x-to11-variant-name"] = variantName;
122
+ if (typeof prompt.variantName === "string" && prompt.variantName.length > 0) {
123
+ headers["x-to11-variant-name"] = prompt.variantName;
116
124
  }
117
- if (prompt.labels.length > 0) {
118
- headers["x-to11-prompt-labels"] = prompt.labels.join(",");
125
+ // The single label the prompt resolved through. Omit when empty — the
126
+ // gateway rejects empty header values.
127
+ if (prompt.label.length > 0) {
128
+ headers["x-to11-prompt-label"] = prompt.label;
119
129
  }
120
130
  return headers;
121
131
  }
@@ -307,6 +317,47 @@ function requireToolCallId(id: string | undefined | null): string {
307
317
  return id;
308
318
  }
309
319
 
320
+ /** Options shared by the message converters. */
321
+ export interface ConvertMessagesOptions {
322
+ /**
323
+ * Policy for a message whose role is outside the known set
324
+ * (`system | user | assistant | developer | tool`). `"drop"` (default) omits
325
+ * it silently; `"warn"` omits it and emits a `console.warn`; `"error"` throws
326
+ * {@link UnknownRoleError}. Both converters apply this identically.
327
+ */
328
+ onUnknownRole?: UnknownRoleMode;
329
+ }
330
+
331
+ const KNOWN_RENDERED_ROLES: ReadonlySet<string> = new Set([
332
+ "system",
333
+ "user",
334
+ "assistant",
335
+ "developer",
336
+ "tool",
337
+ ]);
338
+
339
+ /**
340
+ * Apply the unknown-role policy to a message. Returns `true` when the caller
341
+ * should skip (drop) the message; throws in `"error"` mode. A known role
342
+ * returns `false` (keep). Both converters route through this so their handling
343
+ * of unrecognized roles is identical — no silent divergence.
344
+ */
345
+ function skipUnknownRole(
346
+ role: string,
347
+ index: number,
348
+ mode: UnknownRoleMode,
349
+ ): boolean {
350
+ if (KNOWN_RENDERED_ROLES.has(role)) return false;
351
+ if (mode === "error") throw new UnknownRoleError(role, index);
352
+ if (mode === "warn") {
353
+ console.warn(
354
+ `[to11ai/sdk] dropped message[${index}] with unknown role ${JSON.stringify(role)} (expected system|user|assistant|developer|tool)`,
355
+ );
356
+ }
357
+ // "drop": silent.
358
+ return true;
359
+ }
360
+
310
361
  /**
311
362
  * Shape rendered turns for the OpenAI Chat Completions `messages` field.
312
363
  *
@@ -316,28 +367,37 @@ function requireToolCallId(id: string | undefined | null): string {
316
367
  * become `tool_calls[]` with JSON-stringified `arguments` (the OpenAI wire
317
368
  * shape). A pure tool-call turn keeps `content: null`.
318
369
  * - `tool` (result) → `{ role: "tool", tool_call_id, content }`.
370
+ * - an unrecognized role is handled per `options.onUnknownRole` (default
371
+ * `"drop"`) — dropped, dropped-with-warning, or throws `UnknownRoleError`.
319
372
  *
320
373
  * @example
321
374
  * const messages = toOpenAIMessages(prompt.messages);
375
+ * @example Fail loudly on a malformed message array
376
+ * const messages = toOpenAIMessages(history, { onUnknownRole: "error" });
322
377
  */
323
378
  export function toOpenAIMessages(
324
379
  messages: ReadonlyArray<RenderedChatMessage>,
380
+ options: ConvertMessagesOptions = {},
325
381
  ): OpenAIChatMessage[] {
326
- return messages.map((message) => {
382
+ const mode = options.onUnknownRole ?? "drop";
383
+ const out: OpenAIChatMessage[] = [];
384
+ messages.forEach((message, index) => {
385
+ if (skipUnknownRole(message.role, index, mode)) return;
327
386
  if (message.role === "tool") {
328
- return {
387
+ out.push({
329
388
  role: "tool",
330
389
  tool_call_id: requireToolCallId(message.toolCallId),
331
390
  content: message.content ?? "",
332
- };
391
+ });
392
+ return;
333
393
  }
334
394
  if (message.role === "assistant") {
335
- const out: OpenAIChatMessage = {
395
+ const msg: OpenAIChatMessage = {
336
396
  role: "assistant",
337
397
  content: message.content ?? null,
338
398
  };
339
399
  if (message.toolCalls != null && message.toolCalls.length > 0) {
340
- out.tool_calls = message.toolCalls.map((call) => ({
400
+ msg.tool_calls = message.toolCalls.map((call) => ({
341
401
  id: requireToolCallId(call.id),
342
402
  type: "function",
343
403
  function: {
@@ -346,10 +406,12 @@ export function toOpenAIMessages(
346
406
  },
347
407
  }));
348
408
  }
349
- return out;
409
+ out.push(msg);
410
+ return;
350
411
  }
351
- return { role: message.role, content: message.content ?? "" };
412
+ out.push({ role: message.role, content: message.content ?? "" });
352
413
  });
414
+ return out;
353
415
  }
354
416
 
355
417
  /**
@@ -364,18 +426,28 @@ export function toOpenAIMessages(
364
426
  * ...tool_use] }`; without calls → `{ role: "assistant", content }`.
365
427
  * - `tool` (result) → `{ role: "user", content: [{ type: "tool_result",
366
428
  * tool_use_id, content }] }`.
429
+ * - an unrecognized role is handled per `options.onUnknownRole` (default
430
+ * `"drop"`) — dropped, dropped-with-warning, or throws `UnknownRoleError` —
431
+ * identically to {@link toOpenAIMessages}. (`system`/`developer` are known
432
+ * roles folded into `system`, not "unknown"; they are never dropped here.)
367
433
  *
368
434
  * @example
369
435
  * const messages = toAnthropicMessages(prompt.messages);
436
+ * @example Fail loudly on a malformed message array
437
+ * const messages = toAnthropicMessages(history, { onUnknownRole: "error" });
370
438
  */
371
439
  export function toAnthropicMessages(
372
440
  messages: ReadonlyArray<RenderedChatMessage>,
441
+ options: ConvertMessagesOptions = {},
373
442
  ): AnthropicMessage[] {
443
+ const mode = options.onUnknownRole ?? "drop";
374
444
  const out: AnthropicMessage[] = [];
375
- for (const message of messages) {
445
+ messages.forEach((message, index) => {
376
446
  if (message.role === "system" || message.role === "developer") {
377
- continue;
447
+ // Folded into the top-level `system` via toAnthropicSystem — not dropped.
448
+ return;
378
449
  }
450
+ if (skipUnknownRole(message.role, index, mode)) return;
379
451
  if (message.role === "tool") {
380
452
  out.push({
381
453
  role: "user",
@@ -387,7 +459,7 @@ export function toAnthropicMessages(
387
459
  },
388
460
  ],
389
461
  });
390
- continue;
462
+ return;
391
463
  }
392
464
  if (message.role === "assistant") {
393
465
  if (message.toolCalls != null && message.toolCalls.length > 0) {
@@ -407,10 +479,40 @@ export function toAnthropicMessages(
407
479
  } else {
408
480
  out.push({ role: "assistant", content: message.content ?? "" });
409
481
  }
410
- continue;
482
+ return;
411
483
  }
412
484
  // `user`
413
485
  out.push({ role: "user", content: message.content ?? "" });
414
- }
486
+ });
415
487
  return out;
416
488
  }
489
+
490
+ /**
491
+ * Fold the `system` and `developer` instruction turns into Anthropic's
492
+ * top-level `system` string. Anthropic has no `developer` role and takes system
493
+ * instructions as a separate request field, so pair this with
494
+ * {@link toAnthropicMessages} (which omits those turns). Returns `undefined`
495
+ * when there is no system/developer content.
496
+ *
497
+ * @example
498
+ * const res = await anthropic.messages.create(
499
+ * {
500
+ * ...prompt.config,
501
+ * system: toAnthropicSystem(prompt.messages),
502
+ * messages: toAnthropicMessages(prompt.messages),
503
+ * },
504
+ * { headers: to11.turn().headers(prompt) },
505
+ * );
506
+ */
507
+ export function toAnthropicSystem(
508
+ messages: ReadonlyArray<RenderedChatMessage>,
509
+ ): string | undefined {
510
+ const parts: string[] = [];
511
+ for (const message of messages) {
512
+ if (message.role !== "system" && message.role !== "developer") continue;
513
+ if (typeof message.content === "string" && message.content.length > 0) {
514
+ parts.push(message.content);
515
+ }
516
+ }
517
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
518
+ }