@to11ai/sdk 1.1.0 → 2.0.0-rc.10

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.
@@ -1,9 +1,16 @@
1
- import { z } from "zod";
2
- import { EnvNotFoundError, To11aiNetworkError } from "../errors";
1
+ import {
2
+ MissingLabelError,
3
+ To11aiApiError,
4
+ To11aiNetworkError,
5
+ } from "../errors";
3
6
  import { zResolvedPrompt } from "../generated/zod.gen";
4
7
  import type { ApiEnvelope, Transport } from "../transport";
5
- import type { CacheConfig } from "./cache";
6
- import { buildCacheKey, buildFetchCacheKey, PromptCache } from "./cache";
8
+ import type {
9
+ AnthropicRenderedPrompt,
10
+ OpenAiRenderedPrompt,
11
+ RenderResult,
12
+ } from "./shape";
13
+ import { shapePrompt } from "./shape";
7
14
  import type {
8
15
  ArchivePromptInput,
9
16
  ArchivePromptResult,
@@ -15,8 +22,6 @@ import type {
15
22
  CreatePromptVersionResult,
16
23
  DeleteProjectPromptLabelInput,
17
24
  DeleteProjectPromptLabelResult,
18
- FetchedPrompt,
19
- FetchPromptOptions,
20
25
  GetProjectPromptLabelInput,
21
26
  GetProjectPromptLabelResult,
22
27
  GetPromptInput,
@@ -40,10 +45,11 @@ import type {
40
45
  PaginatedResult,
41
46
  RenderedChatMessage,
42
47
  RenderedPrompt,
48
+ RenderFormat,
49
+ RenderOptions,
43
50
  ResolvedPrompt,
44
51
  ResolvedPromptEnvelope,
45
- ResolvePromptInput,
46
- ResolvePromptResult,
52
+ UnknownRoleMode,
47
53
  UpdateProjectPromptLabelInput,
48
54
  UpdateProjectPromptLabelResult,
49
55
  UpdatePromptInput,
@@ -51,13 +57,12 @@ import type {
51
57
  } from "./types";
52
58
  import { ENV_SLUG_REGEX } from "./types";
53
59
 
54
- // Re-export CacheConfig so callers can import from this module
55
- export type { CacheConfig } from "./cache";
56
60
  export type {
57
61
  ArchivePromptInput,
58
62
  ArchivePromptResult,
59
63
  BlockRenderRecord,
60
64
  ChatMessage,
65
+ ChatRole,
61
66
  CreateProjectPromptLabelInput,
62
67
  CreateProjectPromptLabelResult,
63
68
  CreatePromptInput,
@@ -66,8 +71,6 @@ export type {
66
71
  CreatePromptVersionResult,
67
72
  DeleteProjectPromptLabelInput,
68
73
  DeleteProjectPromptLabelResult,
69
- FetchedPrompt,
70
- FetchPromptOptions,
71
74
  GetProjectPromptLabelInput,
72
75
  GetProjectPromptLabelResult,
73
76
  GetPromptInput,
@@ -101,35 +104,21 @@ export type {
101
104
  PromptVersion,
102
105
  RenderedChatMessage,
103
106
  RenderedPrompt,
107
+ RenderFormat,
108
+ RenderOptions,
104
109
  ResolvedPrompt,
105
110
  ResolvedPromptEnvelope,
106
111
  ResolvedToolCall,
107
112
  ResolvedToolDefinition,
108
- ResolvePromptInput,
109
- ResolvePromptResult,
110
113
  RowError,
111
- TextChatMessage,
112
- TextChatRole,
114
+ UnknownRoleMode,
113
115
  UpdateProjectPromptLabelInput,
114
116
  UpdateProjectPromptLabelResult,
115
117
  UpdatePromptInput,
116
118
  UpdatePromptResult,
117
119
  } from "./types";
118
120
 
119
- const zResolvedPromptLegacy = zResolvedPrompt.pick({
120
- promptId: true,
121
- versionId: true,
122
- version: true,
123
- labels: true,
124
- contentHash: true,
125
- rendered: true,
126
- tools: true,
127
- toolChoice: true,
128
- });
129
-
130
- const zResolvedPromptEnvelope = zResolvedPrompt
131
- .partial({ releaseId: true })
132
- .extend({ strategy: z.enum(["single", "weighted"]).optional() });
121
+ const zResolvedPromptEnvelope = zResolvedPrompt.partial({ releaseId: true });
133
122
 
134
123
  function unwrap<T>(envelope: ApiEnvelope<T>): T {
135
124
  if (envelope.data === null || envelope.data === undefined) {
@@ -143,22 +132,28 @@ function getProjectPromptsPath(projectId: string): string {
143
132
  }
144
133
 
145
134
  /**
146
- * Project a server `rendered` payload into the `messages` array of the R5
147
- * `FetchedPrompt` (D5):
148
- * - `format: "chat"` → the message array directly.
149
- * - `format: "text"` → a single synthesized `{ role: "user", content }`.
135
+ * Project a server `rendered` payload into the enriched result's `messages`.
136
+ *
137
+ * Prompts resolve as chat blocks. The deprecated `text` format is no longer a
138
+ * first-class case: a non-chat payload throws rather than being silently
139
+ * coerced (a text prompt is sugar for a single-block chat and is being removed
140
+ * from the API).
150
141
  */
151
- function renderedToMessages(rendered: RenderedPrompt): RenderedChatMessage[] {
152
- if (rendered.format === "chat") {
153
- return rendered.messages;
142
+ function renderedToMessages(
143
+ rendered: ResolvedPrompt["rendered"],
144
+ ): RenderedChatMessage[] {
145
+ if (rendered.format !== "chat") {
146
+ throw new Error(
147
+ "[to11ai/sdk] Unsupported non-chat prompt format; author the prompt as chat blocks.",
148
+ );
154
149
  }
155
- return [{ role: "user", content: rendered.text }];
150
+ return rendered.messages;
156
151
  }
157
152
 
158
153
  /**
159
- * Normalize the server resolve envelope into the enriched R5 `FetchedPrompt`.
154
+ * Normalize the server resolve envelope into the enriched `RenderedPrompt`.
160
155
  *
161
- * - `messages` is synthesized from `rendered` via the D5 projection.
156
+ * - `messages` is derived from the `rendered` chat payload.
162
157
  * - `releaseId` is ALWAYS a key: a string when the server sends one, else
163
158
  * explicit `null` (R5 `string | null`).
164
159
  * - `variantName` is present IFF the server sent one (i.e. weighted release);
@@ -167,9 +162,10 @@ function renderedToMessages(rendered: RenderedPrompt): RenderedChatMessage[] {
167
162
  */
168
163
  function normalizeResolvedEnvelope(
169
164
  raw: ResolvedPrompt | ResolvedPromptEnvelope,
170
- ): FetchedPrompt {
165
+ slug: string,
166
+ ): RenderedPrompt {
171
167
  const provenance = raw as ResolvedPromptEnvelope;
172
- const result: FetchedPrompt = {
168
+ const result: RenderedPrompt = {
173
169
  messages: renderedToMessages(raw.rendered),
174
170
  version: raw.version,
175
171
  releaseId: provenance.releaseId ?? null,
@@ -177,8 +173,12 @@ function normalizeResolvedEnvelope(
177
173
  promptId: raw.promptId,
178
174
  versionId: raw.versionId,
179
175
  contentHash: raw.contentHash,
180
- labels: raw.labels,
181
- rendered: raw.rendered,
176
+ label: raw.label,
177
+ // The requested slug — the API resolve envelope does not echo it, so it
178
+ // is threaded from the `render()` input (TO11-2727). Surfaced as
179
+ // `x-to11-prompt-slug` for trace readability.
180
+ slug,
181
+ modelConfig: provenance.modelConfig,
182
182
  };
183
183
  // `tools` is present IFF the resolved version declares tool definitions; the
184
184
  // wire may send `null` (or omit it) — normalize both to absent so callers
@@ -208,20 +208,26 @@ function normalizeResolvedEnvelope(
208
208
  }
209
209
 
210
210
  /**
211
- * Lift a caller-supplied offline `fallback` into the enriched `FetchedPrompt`
212
- * shape (D7), so `fetch()` returns an identical shape on the online and
213
- * offline paths. An already-enriched `FetchedPrompt` is returned as-is; a
214
- * legacy `ResolvedPrompt` is lifted (messages synthesized; `releaseId` → its
215
- * own value or explicit `null`; `blockRenderRecord` → `{}`; `variantName`
216
- * stays absent).
211
+ * Lift a caller-supplied offline `fallback` into the returned shape (D7), so
212
+ * `render()` returns an identical shape on the online and offline paths. The
213
+ * fallback's own slug is preferred, falling back to the `render()` input when it
214
+ * carries none, so `x-to11-prompt-slug` is still emitted.
217
215
  */
218
- function liftFallbackToFetched(
219
- fallback: ResolvedPrompt | FetchedPrompt,
220
- ): FetchedPrompt {
221
- if ("messages" in fallback) {
222
- return structuredClone(fallback);
216
+ function liftFallbackToRendered(
217
+ fallback: RenderedPrompt,
218
+ slug: string,
219
+ ): RenderedPrompt {
220
+ // Defensive: `fallback` is typed `RenderedPrompt`, but an untyped JS caller,
221
+ // an `as` cast, or a persisted pre-2.0 `resolve()` result could pass the old
222
+ // `ResolvedPrompt` shape (no top-level `messages`). Fail loudly rather than
223
+ // return a malformed result with `undefined` messages.
224
+ if (!Array.isArray((fallback as { messages?: unknown }).messages)) {
225
+ throw new Error(
226
+ "[to11ai/sdk] `fallback` must be a RenderedPrompt (as returned by render()); the legacy resolve() shape is no longer supported.",
227
+ );
223
228
  }
224
- return normalizeResolvedEnvelope(fallback);
229
+ // `||` (not `??`) so an empty-string slug also falls through to the input.
230
+ return { ...structuredClone(fallback), slug: fallback.slug || slug };
225
231
  }
226
232
 
227
233
  function getProjectPromptPath(projectId: string, promptId: string): string {
@@ -261,40 +267,70 @@ function buildLabelEventsQuery(input: ListLabelEventsInput): string {
261
267
  }
262
268
 
263
269
  /**
264
- * Init-time config for the env-aware `fetch()` surface (TO11-980 / S9).
270
+ * Init-time config for the env-aware `render()` surface (TO11-980 / S9).
265
271
  *
266
272
  * `projectId` and `env` come from `createClient`. `projectId` is required for
267
- * `fetch()` (it builds the project-scoped resolve path); `env` is the default
268
- * serving environment inherited by every `fetch(slug)` call.
273
+ * `render()` (it builds the project-scoped resolve path); `env` is the default
274
+ * serving environment inherited by every `render(slug)` call.
269
275
  */
270
276
  export interface PromptResourceConfig {
271
- /** Project the client is bound to. Required for `fetch()`; validated at createClient. */
277
+ /** Project the client is bound to. Required for `render()`; validated at createClient. */
272
278
  projectId?: string;
273
- /** Default serving env for `fetch()`. Validated against ENV_SLUG_REGEX at createClient. */
279
+ /** Default serving env for `render()`. Validated against ENV_SLUG_REGEX at createClient. */
274
280
  env?: string;
281
+ /** Default provider shape for `render()` output. Per-call override allowed. */
282
+ format?: RenderFormat;
283
+ /**
284
+ * Default unknown-role policy for `render({ format })` output conversion.
285
+ * Per-call override allowed; defaults to `"drop"`.
286
+ */
287
+ onUnknownRole?: UnknownRoleMode;
275
288
  }
276
289
 
277
- export class PromptResource {
290
+ export class PromptResource<F extends RenderFormat | undefined = undefined> {
278
291
  private readonly transport: Transport;
279
- private readonly cache: PromptCache;
280
292
  private readonly projectId: string | undefined;
281
293
  private readonly env: string | undefined;
294
+ private readonly format: RenderFormat | undefined;
295
+ private readonly onUnknownRole: UnknownRoleMode | undefined;
282
296
 
283
297
  constructor(
284
298
  transport: Transport,
285
- cacheConfig?: CacheConfig,
286
- config?: PromptResourceConfig,
299
+ // `format` is bound to the class generic `F` (not the wider `RenderFormat`)
300
+ // so the constructed default shape cannot disagree with what `render()`'s
301
+ // return type (`RenderResult<F>`) promises.
302
+ config?: Omit<PromptResourceConfig, "format"> & { format?: F },
287
303
  ) {
288
304
  this.transport = transport;
289
- this.cache = new PromptCache(cacheConfig);
290
305
  this.projectId = config?.projectId;
291
306
  this.env = config?.env;
307
+ this.format = config?.format;
308
+ this.onUnknownRole = config?.onUnknownRole;
309
+ }
310
+
311
+ /**
312
+ * Resolve the project for a control-plane call: the per-call `projectId`
313
+ * wins, else the client-bound one from `createClient({ projectId })`. Throws
314
+ * if neither is set. Mirrors how `render()` resolves its project, so every
315
+ * method honors the bound `projectId` and callers need not repeat it.
316
+ */
317
+ private resolveProjectId(perCall?: string): string {
318
+ const projectId = perCall ?? this.projectId;
319
+ if (projectId === undefined || projectId.trim() === "") {
320
+ throw new Error(
321
+ "[to11ai/sdk] this call needs a `projectId` — pass it on the call or bind one via createClient({ projectId, ... }).",
322
+ );
323
+ }
324
+ // Trim so a whitespace-padded per-call id normalizes like the client-bound
325
+ // one (resolved via `pick()`), instead of reaching the wire untrimmed.
326
+ return projectId.trim();
292
327
  }
293
328
 
294
329
  // ─── Create ──────────────────────────────────────────────────────────────
295
330
 
296
331
  async create(input: CreatePromptInput): Promise<CreatePromptResult> {
297
- const { projectId, ...body } = input;
332
+ const { projectId: perCallProjectId, ...body } = input;
333
+ const projectId = this.resolveProjectId(perCallProjectId);
298
334
  const envelope = await this.transport.request<CreatePromptResult>(
299
335
  "POST",
300
336
  getProjectPromptsPath(projectId),
@@ -306,9 +342,10 @@ export class PromptResource {
306
342
  // ─── List ─────────────────────────────────────────────────────────────────
307
343
 
308
344
  async list(
309
- input: ListPromptsInput,
345
+ input: ListPromptsInput = {},
310
346
  ): Promise<PaginatedResult<ListPromptsResult>> {
311
- const { projectId, ...queryRest } = input;
347
+ const { projectId: perCallProjectId, ...queryRest } = input;
348
+ const projectId = this.resolveProjectId(perCallProjectId);
312
349
  const envelope = await this.transport.request<
313
350
  | ListPromptsResult[]
314
351
  | {
@@ -340,7 +377,8 @@ export class PromptResource {
340
377
  // ─── Get ──────────────────────────────────────────────────────────────────
341
378
 
342
379
  async get(input: GetPromptInput): Promise<GetPromptResult> {
343
- const { projectId, promptId } = input;
380
+ const { projectId: perCallProjectId, promptId } = input;
381
+ const projectId = this.resolveProjectId(perCallProjectId);
344
382
  const envelope = await this.transport.request<GetPromptResult>(
345
383
  "GET",
346
384
  getProjectPromptPath(projectId, promptId),
@@ -351,7 +389,8 @@ export class PromptResource {
351
389
  // ─── Update ───────────────────────────────────────────────────────────────
352
390
 
353
391
  async update(input: UpdatePromptInput): Promise<UpdatePromptResult> {
354
- const { projectId, promptId, ...body } = input;
392
+ const { projectId: perCallProjectId, promptId, ...body } = input;
393
+ const projectId = this.resolveProjectId(perCallProjectId);
355
394
  const envelope = await this.transport.request<UpdatePromptResult>(
356
395
  "PATCH",
357
396
  getProjectPromptPath(projectId, promptId),
@@ -363,7 +402,8 @@ export class PromptResource {
363
402
  // ─── Archive ──────────────────────────────────────────────────────────────
364
403
 
365
404
  async archive(input: ArchivePromptInput): Promise<ArchivePromptResult> {
366
- const { projectId, promptId } = input;
405
+ const { projectId: perCallProjectId, promptId } = input;
406
+ const projectId = this.resolveProjectId(perCallProjectId);
367
407
  const envelope = await this.transport.request<ArchivePromptResult>(
368
408
  "DELETE",
369
409
  getProjectPromptPath(projectId, promptId),
@@ -376,7 +416,8 @@ export class PromptResource {
376
416
  async createVersion(
377
417
  input: CreatePromptVersionInput,
378
418
  ): Promise<CreatePromptVersionResult> {
379
- const { projectId, promptId, ...body } = input;
419
+ const { projectId: perCallProjectId, promptId, ...body } = input;
420
+ const projectId = this.resolveProjectId(perCallProjectId);
380
421
  const envelope = await this.transport.request<CreatePromptVersionResult>(
381
422
  "POST",
382
423
  `${getProjectPromptPath(projectId, promptId)}/versions`,
@@ -390,7 +431,8 @@ export class PromptResource {
390
431
  async listVersions(
391
432
  input: ListPromptVersionsInput,
392
433
  ): Promise<ListPromptVersionsResult> {
393
- const { projectId, promptId } = input;
434
+ const { projectId: perCallProjectId, promptId } = input;
435
+ const projectId = this.resolveProjectId(perCallProjectId);
394
436
  const envelope = await this.transport.request<ListPromptVersionsResult>(
395
437
  "GET",
396
438
  `${getProjectPromptPath(projectId, promptId)}/versions`,
@@ -403,7 +445,8 @@ export class PromptResource {
403
445
  async getVersion(
404
446
  input: GetPromptVersionInput,
405
447
  ): Promise<GetPromptVersionResult> {
406
- const { projectId, promptId, versionNumber } = input;
448
+ const { projectId: perCallProjectId, promptId, versionNumber } = input;
449
+ const projectId = this.resolveProjectId(perCallProjectId);
407
450
  const envelope = await this.transport.request<GetPromptVersionResult>(
408
451
  "GET",
409
452
  `${getProjectPromptPath(projectId, promptId)}/versions/${encodeURIComponent(String(versionNumber))}`,
@@ -414,7 +457,8 @@ export class PromptResource {
414
457
  // ─── Move Label ───────────────────────────────────────────────────────────
415
458
 
416
459
  async moveLabel(input: MovePromptLabelInput): Promise<MovePromptLabelResult> {
417
- const { projectId, promptId, label, ...body } = input;
460
+ const { projectId: perCallProjectId, promptId, label, ...body } = input;
461
+ const projectId = this.resolveProjectId(perCallProjectId);
418
462
  const envelope = await this.transport.request<MovePromptLabelResult>(
419
463
  "PUT",
420
464
  `${getProjectPromptPath(projectId, promptId)}/labels/${encodeURIComponent(label)}`,
@@ -428,7 +472,8 @@ export class PromptResource {
428
472
  async listLabels(
429
473
  input: ListPromptLabelsInput,
430
474
  ): Promise<ListPromptLabelsResult> {
431
- const { projectId, promptId } = input;
475
+ const { projectId: perCallProjectId, promptId } = input;
476
+ const projectId = this.resolveProjectId(perCallProjectId);
432
477
  const envelope = await this.transport.request<ListPromptLabelsResult>(
433
478
  "GET",
434
479
  `${getProjectPromptPath(projectId, promptId)}/labels`,
@@ -436,90 +481,94 @@ export class PromptResource {
436
481
  return unwrap(envelope);
437
482
  }
438
483
 
439
- // ─── Fetch (env-aware resolution, TO11-980 / S9) ──────────────────────────
484
+ // ─── Render (env-aware resolution + format shaping, TO11-980 / TO11-2691) ──
440
485
 
441
486
  /**
442
- * Resolve a prompt for the client's configured `projectId` + `env`
443
- * (Style-A). The ergonomic slug-first surface: env is configured once at
444
- * `createClient` and inherited here, with an optional per-call override.
487
+ * Resolve and (optionally) shape a prompt for the client's configured
488
+ * `projectId` + `env`, in one control-plane request. With a `format` (per
489
+ * call or inherited from the client) the result is provider-shaped
490
+ * `{ messages, config, metadata }`; without one it is the neutral
491
+ * `RenderedPrompt`. Resolves by the client's configured `env`.
445
492
  *
446
- * - **env→label wire mapping (correction #3):** the resolve route is
447
- * label-based (`/v1/projects/:projectId/prompts:resolve`, body
448
- * `labels[]`); it does NOT accept a top-level `env` field. The resolved
449
- * env slug (lowercase-kebab — a valid label name) is therefore sent as
450
- * the single resolution label: body `{ slug, labels: [env], subject?,
451
- * variables? }`.
452
- * - **No silent fallback (R4):** if no env is resolvable (none at init AND
453
- * none per call) this throws `EnvNotFoundError` (`reason.source =
454
- * "missing"`) CLIENT-side, eagerly, before any transport call.
455
- * - **Cache (D8):** caches `single`-strategy releases and weighted releases
456
- * WITH a `subject`; BYPASSES the cache for weighted releases with NO
457
- * `subject` (a uniform-random assignment must not be pinned for the TTL).
493
+ * @example Render an OpenAI-shaped prompt and send it through the gateway
494
+ * const prompt = await to11.prompts.render("weather-concierge", {
495
+ * variables: { city: "NYC" },
496
+ * });
497
+ * const res = await openai.chat.completions.create(
498
+ * { ...prompt.config, messages: prompt.messages },
499
+ * { headers: to11.turn().headers(prompt) },
500
+ * );
501
+ */
502
+ async render(
503
+ slug: string,
504
+ options: RenderOptions & { format: "openai" },
505
+ ): Promise<OpenAiRenderedPrompt>;
506
+ async render(
507
+ slug: string,
508
+ options: RenderOptions & { format: "anthropic" },
509
+ ): Promise<AnthropicRenderedPrompt>;
510
+ async render(slug: string, options?: RenderOptions): Promise<RenderResult<F>>;
511
+ async render(
512
+ slug: string,
513
+ options: RenderOptions = {},
514
+ ): Promise<RenderedPrompt | OpenAiRenderedPrompt | AnthropicRenderedPrompt> {
515
+ const rendered = await this.resolveToRendered(slug, options);
516
+ return shapePrompt(rendered, options.format ?? this.format, {
517
+ onUnknownRole: options.onUnknownRole ?? this.onUnknownRole,
518
+ });
519
+ }
520
+
521
+ /**
522
+ * Env-aware resolution → neutral `RenderedPrompt`. Carries the resolve /
523
+ * offline-fallback logic; `render()` is the public shaping surface.
458
524
  *
459
- * Prefer this over the deprecated `resolve()`.
525
+ * - **wire mapping (TO11-2743):** posts to the path-addressed render route
526
+ * (`/v1/projects/:projectId/prompts/by-slug/:slug/labels/:label/render`),
527
+ * body `{ variables? }`. The resolved label is the single path label.
528
+ * - **No silent fallback (R4):** if no label is resolvable (none per call
529
+ * AND no client `env` fallback) this throws `MissingLabelError`
530
+ * CLIENT-side, eagerly, before any transport call.
460
531
  */
461
- async fetch(
532
+ private async resolveToRendered(
462
533
  slug: string,
463
- options: FetchPromptOptions = {},
464
- ): Promise<FetchedPrompt> {
534
+ options: RenderOptions = {},
535
+ ): Promise<RenderedPrompt> {
465
536
  if (this.projectId === undefined || this.projectId.trim() === "") {
466
537
  throw new Error(
467
- "[to11ai/sdk] `prompts.fetch()` requires a `projectId` on the client — pass it to createClient({ projectId, ... }).",
538
+ "[to11ai/sdk] `prompts.render()` requires a `projectId` on the client — pass it to createClient({ projectId, ... }).",
468
539
  );
469
540
  }
470
541
 
471
- // R2 precedence: per-call env > init-time env. No third source.
472
- const env = options.env ?? this.env;
542
+ // R2 precedence: per-call label > client `env` fallback. No third source.
543
+ const label = options.label ?? this.env;
473
544
 
474
- // R4 / AC4: no env resolvable → throw eagerly, ZERO transport calls.
475
- if (env === undefined) {
476
- throw EnvNotFoundError.missing();
545
+ // R4 / AC4: no label resolvable → throw eagerly, ZERO transport calls.
546
+ if (label === undefined) {
547
+ throw new MissingLabelError();
477
548
  }
478
549
 
479
- // Validate the RESOLVED env (per-call OR init). createClient validates the
480
- // init-time env, but PromptResource has a public constructor that can
481
- // bypass createClient, so re-validate here so a malformed env (e.g.
482
- // "Prod") never reaches the wire as a resolution label (CodeRabbit).
483
- if (!ENV_SLUG_REGEX.test(env)) {
550
+ // Validate the RESOLVED label (per-call OR the client `env` fallback).
551
+ // createClient validates the init-time env, but PromptResource has a
552
+ // public constructor that can bypass createClient, so re-validate here so
553
+ // a malformed label (e.g. "Prod") never reaches the wire (CodeRabbit).
554
+ if (!ENV_SLUG_REGEX.test(label)) {
484
555
  throw new Error(
485
- `[to11ai/sdk] \`env\` must match ${ENV_SLUG_REGEX} (lowercase-kebab, 1-63 chars). Received: ${JSON.stringify(env)}`,
556
+ `[to11ai/sdk] \`label\` must match ${ENV_SLUG_REGEX} (lowercase-kebab, 1-63 chars). Received: ${JSON.stringify(label)}`,
486
557
  );
487
558
  }
488
559
 
489
- const {
490
- subject,
491
- variables,
492
- cache: useCache = true,
493
- fallback,
494
- developerRole,
495
- } = options;
496
-
497
- const cacheKey = buildFetchCacheKey(
498
- this.projectId,
499
- slug,
500
- env,
501
- subject,
502
- variables,
503
- developerRole,
504
- );
505
-
506
- if (useCache) {
507
- const cached = this.cache.get(cacheKey);
508
- // Only enriched (fetch) entries are valid hits here; the fetch key
509
- // namespace ("fetch|…") never collides with the legacy resolve key.
510
- if (cached && "messages" in cached) return structuredClone(cached);
511
- }
560
+ const { variables, fallback } = options;
512
561
 
513
- // env→label mapping: the env slug is the single resolution label.
514
- const body: Record<string, unknown> = { slug, labels: [env] };
515
- if (subject !== undefined) body.subject = subject;
562
+ // TO11-2743: slug + the single resolved label live in the PATH; the body
563
+ // carries only `variables`. Weighted-release bucketing (`subject`) is not
564
+ // supported by the render endpoint yet, so it's dropped from the surface.
565
+ const body: Record<string, unknown> = {};
516
566
  if (variables !== undefined) body.variables = variables;
517
- if (developerRole !== undefined) body.developerRole = developerRole;
518
567
 
519
568
  try {
520
569
  const envelope = await this.transport.request<ResolvedPromptEnvelope>(
521
570
  "POST",
522
- `${getProjectPromptsPath(this.projectId)}:resolve`,
571
+ `${getProjectPromptsPath(this.projectId)}/by-slug/${encodeURIComponent(slug)}/labels/${encodeURIComponent(label)}/render`,
523
572
  {
524
573
  body,
525
574
  decode: (data) =>
@@ -527,88 +576,17 @@ export class PromptResource {
527
576
  },
528
577
  );
529
578
  const raw = unwrap(envelope);
530
- const result = normalizeResolvedEnvelope(raw);
531
-
532
- // D8: bypass the cache for a weighted release with NO subject —
533
- // caching a uniform-random assignment would pin one variant for the
534
- // whole TTL. Cache `single` releases and `subject`-keyed weighted
535
- // releases (deterministic, safe to cache). Weighted-ness is inferred
536
- // from `strategy === "weighted"` OR the presence of `variantName`
537
- // (which the server stamps iff weighted) — so a result that carries a
538
- // variant but omits `strategy` is still correctly bypassed rather than
539
- // pinning one random variant for the TTL (Cursor / Greptile).
540
- const isWeighted =
541
- raw.strategy === "weighted" || result.variantName !== undefined;
542
- const cacheable = !(isWeighted && subject === undefined);
543
- if (useCache && cacheable) {
544
- this.cache.set(cacheKey, structuredClone(result));
545
- }
546
-
547
- return result;
548
- } catch (err) {
549
- // D7: offline fallback is consulted ONLY on a network outage with an
550
- // empty cache. A server `ENV_NOT_FOUND` is a typed application error
551
- // (not a To11aiNetworkError), so it bypasses this branch. R4's
552
- // client-eager throw already happened above the try, so there is no
553
- // network error to trigger it on the env-missing path.
554
- if (err instanceof To11aiNetworkError && fallback !== undefined) {
555
- return liftFallbackToFetched(fallback);
556
- }
557
- throw err;
558
- }
559
- }
560
-
561
- // ─── Resolve ──────────────────────────────────────────────────────────────
562
-
563
- /**
564
- * @deprecated Use {@link PromptResource.fetch | `prompts.fetch(slug,
565
- * options?)`} instead. `resolve()` is env-agnostic (no `env` / `subject`,
566
- * no enriched release/variant provenance, no typed-env errors)
567
- * and is retained only for back-compat. It is NOT subject to the R4
568
- * no-silent-fallback env gate (it has no env concept). New code should
569
- * configure `projectId` + `env` at `createClient` and call `fetch()`.
570
- */
571
- async resolve(input: ResolvePromptInput): Promise<ResolvePromptResult> {
572
- const {
573
- projectId,
574
- slug,
575
- labels,
576
- variables,
577
- cache: useCache = true,
578
- fallback,
579
- } = input;
580
-
581
- const cacheKey = buildCacheKey(projectId, slug, labels, variables);
582
-
583
- if (useCache) {
584
- const cached = this.cache.get(cacheKey);
585
- // The legacy resolve key namespace only ever stores ResolvedPrompt
586
- // entries (the fetch namespace is "fetch|…"); the `messages` guard
587
- // keeps the static return type as ResolvedPrompt.
588
- if (cached && !("messages" in cached)) return structuredClone(cached);
589
- }
590
-
591
- try {
592
- const envelope = await this.transport.request<ResolvePromptResult>(
593
- "POST",
594
- `${getProjectPromptsPath(projectId)}:resolve`,
595
- {
596
- body: { slug, labels, variables },
597
- decode: (data) =>
598
- zResolvedPromptLegacy.parse(data) as ResolvePromptResult,
599
- },
600
- );
601
- const result = unwrap(envelope);
602
-
603
- if (useCache) {
604
- this.cache.set(cacheKey, structuredClone(result));
605
- }
606
-
607
- return result;
579
+ return normalizeResolvedEnvelope(raw, slug);
608
580
  } catch (err) {
609
- // If network error and we have a fallback, serve it
610
- if (err instanceof To11aiNetworkError && fallback !== undefined) {
611
- return fallback;
581
+ // Serve the offline fallback on a TRANSIENT outage — a network error
582
+ // or a 5xx from the control plane. A typed 4xx (not-found / policy /
583
+ // validation) is a real application error and is NOT masked. The
584
+ // client-eager MissingLabelError already threw above the try.
585
+ const transient =
586
+ err instanceof To11aiNetworkError ||
587
+ (err instanceof To11aiApiError && err.statusCode >= 500);
588
+ if (transient && fallback !== undefined) {
589
+ return liftFallbackToRendered(fallback, slug);
612
590
  }
613
591
  throw err;
614
592
  }
@@ -624,9 +602,10 @@ export class PromptResource {
624
602
  * affordances). Gated on `prompt:read` server-side.
625
603
  */
626
604
  async listProjectLabels(
627
- input: ListProjectPromptLabelsInput,
605
+ input: ListProjectPromptLabelsInput = {},
628
606
  ): Promise<ListProjectPromptLabelsResult> {
629
- const { projectId, includeArchived } = input;
607
+ const { projectId: perCallProjectId, includeArchived } = input;
608
+ const projectId = this.resolveProjectId(perCallProjectId);
630
609
  const envelope =
631
610
  await this.transport.request<ListProjectPromptLabelsResult>(
632
611
  "GET",
@@ -648,7 +627,8 @@ export class PromptResource {
648
627
  async createProjectLabel(
649
628
  input: CreateProjectPromptLabelInput,
650
629
  ): Promise<CreateProjectPromptLabelResult> {
651
- const { projectId, ...body } = input;
630
+ const { projectId: perCallProjectId, ...body } = input;
631
+ const projectId = this.resolveProjectId(perCallProjectId);
652
632
  const envelope =
653
633
  await this.transport.request<CreateProjectPromptLabelResult>(
654
634
  "POST",
@@ -661,9 +641,10 @@ export class PromptResource {
661
641
  async getProjectLabel(
662
642
  input: GetProjectPromptLabelInput,
663
643
  ): Promise<GetProjectPromptLabelResult> {
644
+ const projectId = this.resolveProjectId(input.projectId);
664
645
  const envelope = await this.transport.request<GetProjectPromptLabelResult>(
665
646
  "GET",
666
- getProjectPromptLabelPath(input.projectId, input.label),
647
+ getProjectPromptLabelPath(projectId, input.label),
667
648
  );
668
649
  return unwrap(envelope);
669
650
  }
@@ -679,7 +660,8 @@ export class PromptResource {
679
660
  async updateProjectLabel(
680
661
  input: UpdateProjectPromptLabelInput,
681
662
  ): Promise<UpdateProjectPromptLabelResult> {
682
- const { projectId, label, ...rest } = input;
663
+ const { projectId: perCallProjectId, label, ...rest } = input;
664
+ const projectId = this.resolveProjectId(perCallProjectId);
683
665
  const body: Record<string, unknown> = {};
684
666
  if (rest.isDefault !== undefined) body.isDefault = rest.isDefault;
685
667
  if (rest.description !== undefined) body.description = rest.description;
@@ -705,10 +687,11 @@ export class PromptResource {
705
687
  async deleteProjectLabel(
706
688
  input: DeleteProjectPromptLabelInput,
707
689
  ): Promise<DeleteProjectPromptLabelResult> {
690
+ const projectId = this.resolveProjectId(input.projectId);
708
691
  const envelope =
709
692
  await this.transport.request<DeleteProjectPromptLabelResult>(
710
693
  "DELETE",
711
- getProjectPromptLabelPath(input.projectId, input.label),
694
+ getProjectPromptLabelPath(projectId, input.label),
712
695
  );
713
696
  return unwrap(envelope);
714
697
  }
@@ -727,7 +710,7 @@ export class PromptResource {
727
710
  const envelope =
728
711
  await this.transport.request<ListProjectPromptLabelUsagesResult>(
729
712
  "GET",
730
- `${getProjectPromptLabelPath(input.projectId, input.label)}/usages`,
713
+ `${getProjectPromptLabelPath(this.resolveProjectId(input.projectId), input.label)}/usages`,
731
714
  );
732
715
  return unwrap(envelope);
733
716
  }
@@ -752,7 +735,9 @@ export class PromptResource {
752
735
  async listLabelEvents(
753
736
  input: ListLabelEventsInput,
754
737
  ): Promise<ListLabelEventsResult> {
755
- const projectSeg = encodeURIComponent(input.projectId);
738
+ const projectSeg = encodeURIComponent(
739
+ this.resolveProjectId(input.projectId),
740
+ );
756
741
  let path: string;
757
742
  switch (input.scope) {
758
743
  case "label":