@oxy.so/contracts 4.2.0 → 4.4.0

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.
Files changed (34) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +72 -4
  3. package/dist/cjs/inference/catalogue.js +105 -2
  4. package/dist/cjs/inference/money.js +23 -0
  5. package/dist/cjs/inference/realtime.js +826 -0
  6. package/dist/cjs/inference/request.js +34 -11
  7. package/dist/cjs/inference/streamEvents.js +7 -1
  8. package/dist/cjs/inference/version.js +1 -1
  9. package/dist/cjs/stickers.js +151 -0
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/index.js +6 -2
  12. package/dist/esm/inference/catalogue.js +104 -1
  13. package/dist/esm/inference/money.js +23 -0
  14. package/dist/esm/inference/realtime.js +822 -0
  15. package/dist/esm/inference/request.js +34 -11
  16. package/dist/esm/inference/streamEvents.js +7 -1
  17. package/dist/esm/inference/version.js +1 -1
  18. package/dist/esm/stickers.js +148 -0
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/index.d.ts +7 -4
  21. package/dist/types/inference/catalogue.d.ts +299 -10
  22. package/dist/types/inference/entitlement.d.ts +6 -6
  23. package/dist/types/inference/inbox.d.ts +2 -2
  24. package/dist/types/inference/modelDocumentation.d.ts +146 -2
  25. package/dist/types/inference/money.d.ts +28 -8
  26. package/dist/types/inference/priceVersion.d.ts +12 -12
  27. package/dist/types/inference/realtime.d.ts +8012 -0
  28. package/dist/types/inference/request.d.ts +51 -0
  29. package/dist/types/inference/routingPolicy.d.ts +7 -7
  30. package/dist/types/inference/streamEvents.d.ts +28 -16
  31. package/dist/types/inference/usage.d.ts +28 -28
  32. package/dist/types/inference/version.d.ts +1 -1
  33. package/dist/types/stickers.d.ts +1049 -0
  34. package/package.json +1 -1
@@ -0,0 +1,822 @@
1
+ /**
2
+ * Realtime sessions — the one inference family that is not a request.
3
+ *
4
+ * Everything else in this contract is one-shot: an envelope goes in, a stream of
5
+ * events comes out, one terminal event ends it. A realtime session (OpenAI's
6
+ * Realtime and Live families, and any provider like them) is a long-lived,
7
+ * two-way conversation over a WebSocket: the client keeps appending audio,
8
+ * editing the conversation and asking for responses, and the server keeps
9
+ * answering, detecting speech, transcribing and being interrupted, for up to an
10
+ * hour. Forcing that through {@link inferenceRequestSchema} would make the
11
+ * request envelope mean two things, so it is its own family: a signed session
12
+ * request, a closed set of client COMMANDS and a closed set of server EVENTS.
13
+ *
14
+ * ## Identity, metering and settlement
15
+ *
16
+ * A session IS one request for attribution, metering and settlement:
17
+ * `attribution.requestId` names it, every command and event carries that
18
+ * `requestId`, and the session settles exactly once, through one
19
+ * `normalizedUsageReportSchema` for that request id, delivered by the transport
20
+ * after `session.closed` exactly as a one-shot stream delivers its report.
21
+ * `response.done` and `session.closed` carry units as MEASUREMENT EVIDENCE, the
22
+ * same standing `inferenceStreamUsageEventSchema` has: settleable units, never a
23
+ * settleable record. No upstream cost appears anywhere in this module.
24
+ *
25
+ * ## Routing
26
+ *
27
+ * A session names a model, never a routing profile, and is never substituted:
28
+ * every authorized route is `same_model`. The data plane may try the routes in
29
+ * order until one OPENS; once `session.created` has been sent the deployment is
30
+ * fixed for the session's life. There is no mid-session failover, because the
31
+ * conversation state lives upstream and a second provider does not have it.
32
+ *
33
+ * ## Framing
34
+ *
35
+ * Every WebSocket frame is ONE JSON text message: a command from the client, an
36
+ * event from the server. Binary frames are refused. Audio travels only as
37
+ * padded base64 inside `input_audio.append`, an `input_audio` content part or
38
+ * `output_audio.delta`, each bounded by {@link MAX_REALTIME_AUDIO_FRAME_BASE64_LENGTH}
39
+ * characters (48 KiB of audio, about one second of 24 kHz PCM16), and a
40
+ * session's total audio in each direction is bounded by its signed
41
+ * `limits`. Audio is never a text delta: a transcript is `transcript.delta`,
42
+ * spoken audio is `output_audio.delta`, and neither is `text.delta`.
43
+ *
44
+ * ## Commands are at-most-once
45
+ *
46
+ * Every command carries a client-chosen `commandId`, unique within the session,
47
+ * and the server answers each with `command.accepted` BEFORE applying it. So:
48
+ *
49
+ * - A command whose acknowledgement arrived was applied, once.
50
+ * - A command sent again with a `commandId` the server has already accepted is
51
+ * NOT applied again: the server answers `command.accepted` with
52
+ * `duplicate: true`. A client may therefore retry a command it is unsure of by
53
+ * resending the SAME `commandId`, and only that way.
54
+ * - A command whose write was AMBIGUOUS — the connection dropped before its
55
+ * acknowledgement — is never replayed automatically by any hop. Resending it
56
+ * under a NEW `commandId` is a new command, and appending the same audio twice
57
+ * is exactly the double-metered input this rule exists to prevent.
58
+ * - The data plane never replays a command upstream. If ITS upstream connection
59
+ * drops, the upstream conversation is gone with it and the session ends with
60
+ * `session.closed` (`upstream_closed` or `upstream_error`).
61
+ *
62
+ * ## Reconnect
63
+ *
64
+ * A dropped client connection does not end the session at once. For
65
+ * `resumeWindowMs` (declared by `session.created`, at most
66
+ * {@link MAX_REALTIME_RESUME_WINDOW_MS}) the client may open a new connection
67
+ * whose FIRST frame is a signed `session.resume` naming the last server
68
+ * `sequence` it processed. The server then replays every event after it, in
69
+ * order, emits `session.resumed`, and continues. Events are numbered from 0 by a
70
+ * single monotonic `sequence` per session, so a replayed event is recognisable
71
+ * as a duplicate rather than as new output. Past the window, or when the named
72
+ * sequence is no longer buffered, the resume is refused and the session closes
73
+ * (`resume_expired`): nothing is guessed. Commands are not part of the replay —
74
+ * see above.
75
+ *
76
+ * ## Authentication
77
+ *
78
+ * The session request and a `session.resume` are the first frame of their
79
+ * connection and are signed with the same edge signature the one-shot envelope
80
+ * carries, over that frame's exact bytes; the signature headers ride on the
81
+ * WebSocket upgrade. Every later frame is covered by the connection that
82
+ * signature opened.
83
+ *
84
+ * Decided in: OxyHQ/Kaana#90.
85
+ */
86
+ import { z } from "zod";
87
+ import { inferenceAttributionSchema } from "./attribution.js";
88
+ import { realtimeSessionKindSchema, realtimeSessionTransportSchema, } from "./catalogue.js";
89
+ import { inferenceErrorSchema } from "./errors.js";
90
+ import { deploymentIdSchema, inferenceProviderSlugSchema, inferenceTimestampSchema, modelReferenceSchema, PADDED_BASE64_PATTERN, requestIdSchema, } from "./identifiers.js";
91
+ import { usageQuantitySchema, usageSourceSchema } from "./money.js";
92
+ import { toolChoiceSchema, toolDefinitionSchema } from "./request.js";
93
+ import { authorizedRouteSchema, routingPolicyReferenceSchema, } from "./routingPolicy.js";
94
+ import { inferenceFinishReasonSchema } from "./streamEvents.js";
95
+ /* -------------------------------------------------------------------------- */
96
+ /* Bounds */
97
+ /* -------------------------------------------------------------------------- */
98
+ /**
99
+ * The most base64 characters one audio frame may carry: 49 152 bytes of audio,
100
+ * about one second of 24 kHz mono PCM16. Bounded so no frame is large enough to
101
+ * stall a connection, and so a byte cap can be enforced as audio arrives rather
102
+ * than after it has been buffered.
103
+ */
104
+ export const MAX_REALTIME_AUDIO_FRAME_BASE64_LENGTH = 65536;
105
+ /** The longest a session may be signed for: one hour. */
106
+ export const MAX_REALTIME_SESSION_DURATION_MS = 3600000;
107
+ /**
108
+ * The most audio a session may be signed for in EACH direction: one hour of
109
+ * 24 kHz mono PCM16 (48 000 bytes per second), the densest format a session
110
+ * carries.
111
+ */
112
+ export const MAX_REALTIME_SESSION_AUDIO_BYTES = 172800000;
113
+ /** The longest a dropped connection may be resumed after: one minute. */
114
+ export const MAX_REALTIME_RESUME_WINDOW_MS = 60000;
115
+ /* -------------------------------------------------------------------------- */
116
+ /* Vocabulary */
117
+ /* -------------------------------------------------------------------------- */
118
+ /** A client-chosen command identity, unique within one session. */
119
+ export const realtimeCommandIdSchema = z.string().min(1).max(128);
120
+ /** A conversation item's identity within one session. */
121
+ export const realtimeItemIdSchema = z.string().min(1).max(128);
122
+ /** A response's identity within one session. */
123
+ export const realtimeResponseIdSchema = z.string().min(1).max(128);
124
+ /**
125
+ * The audio encodings a session carries, in both directions. Named by what they
126
+ * ARE (sample format, rate) because a realtime stream has no container to say so.
127
+ */
128
+ export const realtimeAudioFormatSchema = z.enum([
129
+ "pcm16_24khz",
130
+ "g711_ulaw",
131
+ "g711_alaw",
132
+ ]);
133
+ /** What a session's responses produce. */
134
+ export const realtimeOutputModalitySchema = z.enum(["text", "audio"]);
135
+ const audioFrameSchema = z
136
+ .string()
137
+ .min(4)
138
+ .max(MAX_REALTIME_AUDIO_FRAME_BASE64_LENGTH)
139
+ .regex(PADDED_BASE64_PATTERN);
140
+ const sequenceSchema = z.number().int().nonnegative().safe();
141
+ const milliseconds = z.number().int().nonnegative().safe();
142
+ /* -------------------------------------------------------------------------- */
143
+ /* Session configuration */
144
+ /* -------------------------------------------------------------------------- */
145
+ /**
146
+ * How the end of a user's turn is detected.
147
+ *
148
+ * `none` means the client commits input audio itself. The two VAD forms name
149
+ * `createResponse` and `interruptResponse` explicitly rather than defaulting
150
+ * them: whether the model answers on its own, and whether new speech cuts it
151
+ * off, are the two behaviours a caller most needs to have chosen, and a default
152
+ * would choose them silently — and differently per provider.
153
+ */
154
+ export const realtimeTurnDetectionSchema = z.discriminatedUnion("type", [
155
+ z.object({ type: z.literal("none") }).strict(),
156
+ z
157
+ .object({
158
+ type: z.literal("server_vad"),
159
+ threshold: z.number().min(0).max(1).optional(),
160
+ prefixPaddingMs: z.number().int().min(0).max(5000).optional(),
161
+ silenceDurationMs: z.number().int().min(0).max(10000).optional(),
162
+ createResponse: z.boolean(),
163
+ interruptResponse: z.boolean(),
164
+ })
165
+ .strict(),
166
+ z
167
+ .object({
168
+ type: z.literal("semantic_vad"),
169
+ eagerness: z.enum(["low", "medium", "high", "auto"]),
170
+ createResponse: z.boolean(),
171
+ interruptResponse: z.boolean(),
172
+ })
173
+ .strict(),
174
+ ]);
175
+ /**
176
+ * Transcription of the caller's input audio. Presence enables it; the
177
+ * transcription model is the deployment's own, declared where the deployment
178
+ * is, never chosen by a string here.
179
+ */
180
+ export const realtimeInputTranscriptionSchema = z
181
+ .object({
182
+ /** A BCP 47 tag, when the caller knows the language. */
183
+ language: z.string().min(2).max(35).optional(),
184
+ prompt: z.string().max(4096).optional(),
185
+ })
186
+ .strict();
187
+ /** The target of a `translation` session. */
188
+ export const realtimeTranslationSchema = z
189
+ .object({
190
+ /** A BCP 47 tag. */
191
+ targetLanguage: z.string().min(2).max(35),
192
+ })
193
+ .strict();
194
+ /**
195
+ * A session's full configuration, as signed at open and as echoed back by
196
+ * `session.created` and `session.updated`.
197
+ *
198
+ * `inputAudioFormat` and `turnDetection` are required because every session
199
+ * kind consumes audio and has to know when a turn ends; nothing else is,
200
+ * because each kind needs a different subset — see the refinement on
201
+ * {@link realtimeSessionRequestSchema}.
202
+ */
203
+ export const realtimeSessionConfigSchema = z
204
+ .object({
205
+ instructions: z.string().max(32768).optional(),
206
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
207
+ voice: z.string().min(1).max(64).optional(),
208
+ inputAudioFormat: realtimeAudioFormatSchema,
209
+ outputAudioFormat: realtimeAudioFormatSchema.optional(),
210
+ turnDetection: realtimeTurnDetectionSchema,
211
+ inputAudioTranscription: realtimeInputTranscriptionSchema.optional(),
212
+ translation: realtimeTranslationSchema.optional(),
213
+ tools: z.array(toolDefinitionSchema).max(128).optional(),
214
+ toolChoice: toolChoiceSchema.optional(),
215
+ temperature: z.number().min(0).max(2).optional(),
216
+ /** Per response, not per session: the session's ceiling is `limits`. */
217
+ maxOutputTokens: z.number().int().positive().safe().optional(),
218
+ })
219
+ .strict();
220
+ /**
221
+ * What `session.update` may change mid-session.
222
+ *
223
+ * The session's kind, its audio formats and its voice are fixed at open —
224
+ * providers refuse to change a voice once it has spoken, and a format change
225
+ * mid-stream would make every buffered frame ambiguous — so they are not fields
226
+ * here, and the strict parse refuses them rather than ignoring them.
227
+ */
228
+ export const realtimeSessionConfigUpdateSchema = z
229
+ .object({
230
+ instructions: z.string().max(32768).optional(),
231
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
232
+ turnDetection: realtimeTurnDetectionSchema.optional(),
233
+ inputAudioTranscription: realtimeInputTranscriptionSchema.optional(),
234
+ tools: z.array(toolDefinitionSchema).max(128).optional(),
235
+ toolChoice: toolChoiceSchema.optional(),
236
+ temperature: z.number().min(0).max(2).optional(),
237
+ maxOutputTokens: z.number().int().positive().safe().optional(),
238
+ })
239
+ .strict()
240
+ .superRefine((update, ctx) => {
241
+ if (Object.keys(update).length === 0) {
242
+ ctx.addIssue({
243
+ code: z.ZodIssueCode.custom,
244
+ message: "a session update changes at least one field",
245
+ });
246
+ }
247
+ });
248
+ /**
249
+ * The ceilings Oxy signed the session for. The data plane enforces each one and
250
+ * closes the session (`limit_exceeded`, `max_duration`, `idle_timeout`) rather
251
+ * than exceeding it: these bound what the session can cost, which is what Oxy's
252
+ * spend reservation was sized against.
253
+ */
254
+ export const realtimeSessionLimitsSchema = z
255
+ .object({
256
+ maxDurationMs: z.number().int().min(1000).max(MAX_REALTIME_SESSION_DURATION_MS),
257
+ idleTimeoutMs: z.number().int().min(1000).max(MAX_REALTIME_SESSION_DURATION_MS),
258
+ maxInputAudioBytes: z.number().int().positive().max(MAX_REALTIME_SESSION_AUDIO_BYTES),
259
+ maxOutputAudioBytes: z.number().int().positive().max(MAX_REALTIME_SESSION_AUDIO_BYTES),
260
+ maxResponses: z.number().int().positive().max(10000),
261
+ })
262
+ .strict();
263
+ /**
264
+ * What the edge records about the session CALL. Strict for the reason
265
+ * `clientRequestMetadataSchema` is: this object is where an IP would be added.
266
+ */
267
+ export const realtimeClientMetadataSchema = z
268
+ .object({
269
+ /** The public path, e.g. `/v1/realtime`. */
270
+ endpoint: z.string().min(1).max(256),
271
+ clientSessionId: z.string().min(1).max(128).optional(),
272
+ receivedAt: inferenceTimestampSchema,
273
+ labels: z.record(z.string().max(256)).optional(),
274
+ })
275
+ .strict();
276
+ /* -------------------------------------------------------------------------- */
277
+ /* The session request */
278
+ /* -------------------------------------------------------------------------- */
279
+ const modelLineOf = (reference) => {
280
+ const at = reference.indexOf("@");
281
+ return at === -1 ? reference : reference.slice(0, at);
282
+ };
283
+ /**
284
+ * The signed request that opens a session: the first frame of its connection.
285
+ */
286
+ export const realtimeSessionRequestSchema = z
287
+ .object({
288
+ schemaVersion: z.literal(1),
289
+ attribution: inferenceAttributionSchema,
290
+ /** The model the caller named, pinned or not. Never a routing profile. */
291
+ modelReference: modelReferenceSchema,
292
+ kind: realtimeSessionKindSchema,
293
+ transport: realtimeSessionTransportSchema,
294
+ config: realtimeSessionConfigSchema,
295
+ limits: realtimeSessionLimitsSchema,
296
+ client: realtimeClientMetadataSchema,
297
+ routingPolicy: routingPolicyReferenceSchema,
298
+ /** In preference order, tried only until one opens. All `same_model`. */
299
+ authorizedRoutes: z.array(authorizedRouteSchema).min(1),
300
+ })
301
+ .superRefine((request, ctx) => {
302
+ const { config } = request;
303
+ const issue = (path, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path, message });
304
+ if (request.limits.idleTimeoutMs > request.limits.maxDurationMs) {
305
+ issue(["limits", "idleTimeoutMs"], "an idle timeout cannot outlast the session");
306
+ }
307
+ const producesAudio = config.outputModalities?.includes("audio") === true;
308
+ const hasTools = config.tools !== undefined && config.tools.length > 0;
309
+ switch (request.kind) {
310
+ case "conversation":
311
+ if (config.outputModalities === undefined) {
312
+ issue(["config", "outputModalities"], "a conversation names what its responses produce");
313
+ }
314
+ if (producesAudio && (config.voice === undefined || config.outputAudioFormat === undefined)) {
315
+ issue(["config"], "spoken responses need a voice and an output audio format");
316
+ }
317
+ if (config.translation !== undefined) {
318
+ issue(["config", "translation"], "only a translation session translates");
319
+ }
320
+ break;
321
+ case "transcription":
322
+ if (config.inputAudioTranscription === undefined) {
323
+ issue(["config", "inputAudioTranscription"], "a transcription session transcribes its input");
324
+ }
325
+ for (const field of ["outputModalities", "voice", "outputAudioFormat", "translation", "tools", "toolChoice", "maxOutputTokens"]) {
326
+ if (config[field] !== undefined) {
327
+ issue(["config", field], "a transcription session never responds");
328
+ }
329
+ }
330
+ if (config.turnDetection.type !== "none" && (config.turnDetection.createResponse || config.turnDetection.interruptResponse)) {
331
+ issue(["config", "turnDetection"], "a transcription session has no response to create or interrupt");
332
+ }
333
+ break;
334
+ case "translation":
335
+ if (config.translation === undefined || config.outputAudioFormat === undefined) {
336
+ issue(["config"], "a translation session names its target language and output audio format");
337
+ }
338
+ if (config.tools !== undefined || config.toolChoice !== undefined) {
339
+ issue(["config", "tools"], "a translation session calls no tools");
340
+ }
341
+ break;
342
+ }
343
+ if (config.toolChoice !== undefined && !hasTools) {
344
+ issue(["config", "toolChoice"], "a tool choice requires at least one tool definition");
345
+ }
346
+ if (config.tools !== undefined) {
347
+ const names = config.tools.map((tool) => tool.name);
348
+ if (new Set(names).size !== names.length) {
349
+ issue(["config", "tools"], "tool names must be unique within one session");
350
+ }
351
+ }
352
+ if (config.outputModalities !== undefined && new Set(config.outputModalities).size !== config.outputModalities.length) {
353
+ issue(["config", "outputModalities"], "each output modality is named at most once");
354
+ }
355
+ // A session is never substituted: the conversation it holds belongs to one
356
+ // model, so every route serves the line the caller named, and a pinned
357
+ // request is served on exactly the revision it pinned.
358
+ const line = modelLineOf(request.modelReference);
359
+ const pinned = request.modelReference.includes("@");
360
+ for (const [index, route] of request.authorizedRoutes.entries()) {
361
+ if (route.substitution !== "same_model" || modelLineOf(route.modelReference) !== line) {
362
+ issue(["authorizedRoutes", index], "every route of a session serves the model it named");
363
+ }
364
+ if (pinned && route.modelReference !== request.modelReference) {
365
+ issue(["authorizedRoutes", index, "modelReference"], "a pinned session is served on exactly the revision it pinned");
366
+ }
367
+ }
368
+ const deployments = request.authorizedRoutes.map((route) => route.deploymentId);
369
+ if (new Set(deployments).size !== deployments.length) {
370
+ issue(["authorizedRoutes"], "each deployment appears at most once in the authorized route list");
371
+ }
372
+ });
373
+ /* -------------------------------------------------------------------------- */
374
+ /* Conversation items */
375
+ /* -------------------------------------------------------------------------- */
376
+ /**
377
+ * One part of a conversation message.
378
+ *
379
+ * `input_audio` carries its audio inline (one bounded frame); `output_audio`
380
+ * never does — spoken output arrives as `output_audio.delta` events, and an
381
+ * item echoing it carries only the format and, once known, the transcript.
382
+ */
383
+ export const realtimeContentPartSchema = z.discriminatedUnion("type", [
384
+ z.object({ type: z.literal("input_text"), text: z.string().max(1048576) }).strict(),
385
+ z
386
+ .object({
387
+ type: z.literal("input_audio"),
388
+ format: realtimeAudioFormatSchema,
389
+ data: audioFrameSchema.optional(),
390
+ transcript: z.string().optional(),
391
+ })
392
+ .strict(),
393
+ z.object({ type: z.literal("output_text"), text: z.string() }).strict(),
394
+ z
395
+ .object({
396
+ type: z.literal("output_audio"),
397
+ format: realtimeAudioFormatSchema,
398
+ transcript: z.string().optional(),
399
+ })
400
+ .strict(),
401
+ ]);
402
+ const INPUT_PARTS = new Set(["input_text", "input_audio"]);
403
+ /**
404
+ * One item of the conversation a session holds.
405
+ *
406
+ * `itemId` is optional on an item a client CREATES (the server assigns one) and
407
+ * is always stated by the server on the events that carry an item.
408
+ */
409
+ export const realtimeConversationItemSchema = z
410
+ .discriminatedUnion("type", [
411
+ z
412
+ .object({
413
+ type: z.literal("message"),
414
+ itemId: realtimeItemIdSchema.optional(),
415
+ role: z.enum(["system", "user", "assistant"]),
416
+ content: z.array(realtimeContentPartSchema).min(1).max(64),
417
+ })
418
+ .strict(),
419
+ z
420
+ .object({
421
+ type: z.literal("function_call"),
422
+ itemId: realtimeItemIdSchema.optional(),
423
+ callId: z.string().min(1).max(128),
424
+ name: z.string().min(1).max(128),
425
+ arguments: z.string(),
426
+ })
427
+ .strict(),
428
+ z
429
+ .object({
430
+ type: z.literal("function_call_output"),
431
+ itemId: realtimeItemIdSchema.optional(),
432
+ callId: z.string().min(1).max(128),
433
+ output: z.string().max(1048576),
434
+ })
435
+ .strict(),
436
+ ])
437
+ .superRefine((item, ctx) => {
438
+ if (item.type !== "message")
439
+ return;
440
+ for (const [index, part] of item.content.entries()) {
441
+ const isInput = INPUT_PARTS.has(part.type);
442
+ // A user or system turn is input; an assistant turn is output. A part on
443
+ // the wrong side is one every provider would silently ignore.
444
+ if (item.role === "assistant" ? isInput : !isInput) {
445
+ ctx.addIssue({
446
+ code: z.ZodIssueCode.custom,
447
+ path: ["content", index, "type"],
448
+ message: `a ${item.role} message cannot carry ${part.type}`,
449
+ });
450
+ }
451
+ if (item.role === "system" && part.type !== "input_text") {
452
+ ctx.addIssue({
453
+ code: z.ZodIssueCode.custom,
454
+ path: ["content", index, "type"],
455
+ message: "a system message is text",
456
+ });
457
+ }
458
+ }
459
+ });
460
+ /** Per-response overrides for `response.create`. Absent fields keep the session's. */
461
+ export const realtimeResponseParametersSchema = z
462
+ .object({
463
+ instructions: z.string().max(32768).optional(),
464
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
465
+ maxOutputTokens: z.number().int().positive().safe().optional(),
466
+ toolChoice: toolChoiceSchema.optional(),
467
+ })
468
+ .strict();
469
+ /* -------------------------------------------------------------------------- */
470
+ /* Client commands */
471
+ /* -------------------------------------------------------------------------- */
472
+ const commandBase = {
473
+ /** See `version.ts`: each command is a whole message on the wire. */
474
+ schemaVersion: z.literal(1),
475
+ requestId: requestIdSchema,
476
+ commandId: realtimeCommandIdSchema,
477
+ };
478
+ export const realtimeSessionUpdateCommandSchema = z.object({
479
+ ...commandBase,
480
+ type: z.literal("session.update"),
481
+ config: realtimeSessionConfigUpdateSchema,
482
+ });
483
+ export const realtimeItemCreateCommandSchema = z.object({
484
+ ...commandBase,
485
+ type: z.literal("conversation.item.create"),
486
+ /** Insert after this item; absent appends. */
487
+ previousItemId: realtimeItemIdSchema.optional(),
488
+ item: realtimeConversationItemSchema,
489
+ });
490
+ export const realtimeItemDeleteCommandSchema = z.object({
491
+ ...commandBase,
492
+ type: z.literal("conversation.item.delete"),
493
+ itemId: realtimeItemIdSchema,
494
+ });
495
+ /**
496
+ * Cut an assistant's audio item at the point the listener actually heard, after
497
+ * an interruption, so the conversation the model remembers is the one that was
498
+ * spoken rather than the one that was generated.
499
+ */
500
+ export const realtimeItemTruncateCommandSchema = z.object({
501
+ ...commandBase,
502
+ type: z.literal("conversation.item.truncate"),
503
+ itemId: realtimeItemIdSchema,
504
+ contentIndex: z.number().int().nonnegative().safe(),
505
+ audioEndMs: milliseconds,
506
+ });
507
+ export const realtimeInputAudioAppendCommandSchema = z.object({
508
+ ...commandBase,
509
+ type: z.literal("input_audio.append"),
510
+ /** One bounded frame in the session's `inputAudioFormat`. */
511
+ data: audioFrameSchema,
512
+ });
513
+ export const realtimeInputAudioCommitCommandSchema = z.object({
514
+ ...commandBase,
515
+ type: z.literal("input_audio.commit"),
516
+ });
517
+ export const realtimeInputAudioClearCommandSchema = z.object({
518
+ ...commandBase,
519
+ type: z.literal("input_audio.clear"),
520
+ });
521
+ export const realtimeResponseCreateCommandSchema = z.object({
522
+ ...commandBase,
523
+ type: z.literal("response.create"),
524
+ response: realtimeResponseParametersSchema.optional(),
525
+ });
526
+ export const realtimeResponseCancelCommandSchema = z.object({
527
+ ...commandBase,
528
+ type: z.literal("response.cancel"),
529
+ /** Absent cancels the response in progress, if any. */
530
+ responseId: realtimeResponseIdSchema.optional(),
531
+ });
532
+ /**
533
+ * The first frame of a reconnection: replay every event after `afterSequence`.
534
+ * `-1` asks for every buffered event from the start.
535
+ */
536
+ export const realtimeSessionResumeCommandSchema = z.object({
537
+ ...commandBase,
538
+ type: z.literal("session.resume"),
539
+ afterSequence: z.number().int().min(-1).safe(),
540
+ });
541
+ export const realtimeSessionCloseCommandSchema = z.object({
542
+ ...commandBase,
543
+ type: z.literal("session.close"),
544
+ });
545
+ /** Every command a client can send. */
546
+ export const realtimeClientCommandSchema = z.discriminatedUnion("type", [
547
+ realtimeSessionUpdateCommandSchema,
548
+ realtimeItemCreateCommandSchema,
549
+ realtimeItemDeleteCommandSchema,
550
+ realtimeItemTruncateCommandSchema,
551
+ realtimeInputAudioAppendCommandSchema,
552
+ realtimeInputAudioCommitCommandSchema,
553
+ realtimeInputAudioClearCommandSchema,
554
+ realtimeResponseCreateCommandSchema,
555
+ realtimeResponseCancelCommandSchema,
556
+ realtimeSessionResumeCommandSchema,
557
+ realtimeSessionCloseCommandSchema,
558
+ ]);
559
+ /* -------------------------------------------------------------------------- */
560
+ /* Server events */
561
+ /* -------------------------------------------------------------------------- */
562
+ const eventBase = {
563
+ /** See `version.ts`: each event is a whole message on the wire. */
564
+ schemaVersion: z.literal(1),
565
+ requestId: requestIdSchema,
566
+ /** One monotonic sequence per session, from 0; what makes a replay detectable. */
567
+ sequence: sequenceSchema,
568
+ /** The command this event answers, when it answers one. */
569
+ commandId: realtimeCommandIdSchema.optional(),
570
+ };
571
+ /**
572
+ * The session opened. Names the revision-pinned model, the serving provider and
573
+ * the exact deployment — fixed for the rest of the session — and the effective
574
+ * configuration and limits. `deploymentId` is for settlement: like the usage
575
+ * event's, it is not rendered to the customer.
576
+ */
577
+ export const realtimeSessionCreatedEventSchema = z.object({
578
+ ...eventBase,
579
+ type: z.literal("session.created"),
580
+ resolvedModelReference: modelReferenceSchema,
581
+ servingProvider: inferenceProviderSlugSchema,
582
+ deploymentId: deploymentIdSchema,
583
+ kind: realtimeSessionKindSchema,
584
+ config: realtimeSessionConfigSchema,
585
+ limits: realtimeSessionLimitsSchema,
586
+ resumeWindowMs: z.number().int().nonnegative().max(MAX_REALTIME_RESUME_WINDOW_MS),
587
+ startedAt: inferenceTimestampSchema,
588
+ expiresAt: inferenceTimestampSchema,
589
+ });
590
+ export const realtimeSessionUpdatedEventSchema = z.object({
591
+ ...eventBase,
592
+ type: z.literal("session.updated"),
593
+ config: realtimeSessionConfigSchema,
594
+ });
595
+ export const realtimeSessionResumedEventSchema = z.object({
596
+ ...eventBase,
597
+ type: z.literal("session.resumed"),
598
+ afterSequence: z.number().int().min(-1).safe(),
599
+ });
600
+ /** Sent before a command is applied. `duplicate` means it was not applied again. */
601
+ export const realtimeCommandAcceptedEventSchema = z.object({
602
+ ...eventBase,
603
+ type: z.literal("command.accepted"),
604
+ commandId: realtimeCommandIdSchema,
605
+ duplicate: z.boolean(),
606
+ });
607
+ export const realtimeItemAddedEventSchema = z.object({
608
+ ...eventBase,
609
+ type: z.literal("conversation.item.added"),
610
+ itemId: realtimeItemIdSchema,
611
+ previousItemId: realtimeItemIdSchema.optional(),
612
+ item: realtimeConversationItemSchema,
613
+ });
614
+ export const realtimeItemDoneEventSchema = z.object({
615
+ ...eventBase,
616
+ type: z.literal("conversation.item.done"),
617
+ itemId: realtimeItemIdSchema,
618
+ item: realtimeConversationItemSchema,
619
+ });
620
+ export const realtimeItemDeletedEventSchema = z.object({
621
+ ...eventBase,
622
+ type: z.literal("conversation.item.deleted"),
623
+ itemId: realtimeItemIdSchema,
624
+ });
625
+ export const realtimeItemTruncatedEventSchema = z.object({
626
+ ...eventBase,
627
+ type: z.literal("conversation.item.truncated"),
628
+ itemId: realtimeItemIdSchema,
629
+ contentIndex: z.number().int().nonnegative().safe(),
630
+ audioEndMs: milliseconds,
631
+ });
632
+ /**
633
+ * Voice activity began. With `interruptResponse`, a response in progress is
634
+ * cancelled by it: this event is the interruption, and the client should stop
635
+ * playback and truncate what was not heard.
636
+ */
637
+ export const realtimeSpeechStartedEventSchema = z.object({
638
+ ...eventBase,
639
+ type: z.literal("input_audio.speech_started"),
640
+ itemId: realtimeItemIdSchema,
641
+ audioStartMs: milliseconds,
642
+ });
643
+ export const realtimeSpeechStoppedEventSchema = z.object({
644
+ ...eventBase,
645
+ type: z.literal("input_audio.speech_stopped"),
646
+ itemId: realtimeItemIdSchema,
647
+ audioEndMs: milliseconds,
648
+ });
649
+ export const realtimeInputAudioCommittedEventSchema = z.object({
650
+ ...eventBase,
651
+ type: z.literal("input_audio.committed"),
652
+ itemId: realtimeItemIdSchema,
653
+ previousItemId: realtimeItemIdSchema.optional(),
654
+ });
655
+ export const realtimeInputAudioClearedEventSchema = z.object({
656
+ ...eventBase,
657
+ type: z.literal("input_audio.cleared"),
658
+ });
659
+ export const realtimeResponseCreatedEventSchema = z.object({
660
+ ...eventBase,
661
+ type: z.literal("response.created"),
662
+ responseId: realtimeResponseIdSchema,
663
+ });
664
+ /** One bounded frame of spoken output, in the session's `outputAudioFormat`. */
665
+ export const realtimeOutputAudioDeltaEventSchema = z.object({
666
+ ...eventBase,
667
+ type: z.literal("output_audio.delta"),
668
+ responseId: realtimeResponseIdSchema,
669
+ itemId: realtimeItemIdSchema,
670
+ contentIndex: z.number().int().nonnegative().safe(),
671
+ format: realtimeAudioFormatSchema,
672
+ data: audioFrameSchema,
673
+ });
674
+ export const realtimeOutputAudioDoneEventSchema = z.object({
675
+ ...eventBase,
676
+ type: z.literal("output_audio.done"),
677
+ responseId: realtimeResponseIdSchema,
678
+ itemId: realtimeItemIdSchema,
679
+ contentIndex: z.number().int().nonnegative().safe(),
680
+ });
681
+ /**
682
+ * Which audio a transcript describes: the caller's (`input_audio`) or the
683
+ * model's own speech (`output_audio`). Neither is answer text.
684
+ */
685
+ export const realtimeTranscriptSourceSchema = z.enum(["input_audio", "output_audio"]);
686
+ export const realtimeTranscriptDeltaEventSchema = z.object({
687
+ ...eventBase,
688
+ type: z.literal("transcript.delta"),
689
+ source: realtimeTranscriptSourceSchema,
690
+ itemId: realtimeItemIdSchema,
691
+ contentIndex: z.number().int().nonnegative().safe(),
692
+ /** Present for `output_audio`: the response whose speech this is. */
693
+ responseId: realtimeResponseIdSchema.optional(),
694
+ text: z.string(),
695
+ });
696
+ export const realtimeTranscriptDoneEventSchema = z.object({
697
+ ...eventBase,
698
+ type: z.literal("transcript.done"),
699
+ source: realtimeTranscriptSourceSchema,
700
+ itemId: realtimeItemIdSchema,
701
+ contentIndex: z.number().int().nonnegative().safe(),
702
+ responseId: realtimeResponseIdSchema.optional(),
703
+ transcript: z.string(),
704
+ });
705
+ /** Written output of a response (`outputModalities` including `text`). */
706
+ export const realtimeTextDeltaEventSchema = z.object({
707
+ ...eventBase,
708
+ type: z.literal("text.delta"),
709
+ responseId: realtimeResponseIdSchema,
710
+ itemId: realtimeItemIdSchema,
711
+ contentIndex: z.number().int().nonnegative().safe(),
712
+ text: z.string(),
713
+ });
714
+ /** A tool call being streamed; same accumulation rules as the one-shot event. */
715
+ export const realtimeToolCallEventSchema = z.object({
716
+ ...eventBase,
717
+ type: z.literal("tool_call"),
718
+ responseId: realtimeResponseIdSchema,
719
+ itemId: realtimeItemIdSchema,
720
+ toolCallId: z.string().min(1).max(128),
721
+ name: z.string().min(1).max(128).optional(),
722
+ argumentsDelta: z.string().optional(),
723
+ complete: z.boolean(),
724
+ });
725
+ /** How a response ended. */
726
+ export const realtimeResponseStatusSchema = z.enum([
727
+ "completed",
728
+ "cancelled",
729
+ "incomplete",
730
+ "failed",
731
+ ]);
732
+ /**
733
+ * A response ended, with the units it consumed — measurement evidence, as on
734
+ * the one-shot usage event. A cancelled or failed response still consumed what
735
+ * it consumed, so its units are reported too.
736
+ */
737
+ export const realtimeResponseDoneEventSchema = z.object({
738
+ ...eventBase,
739
+ type: z.literal("response.done"),
740
+ responseId: realtimeResponseIdSchema,
741
+ status: realtimeResponseStatusSchema,
742
+ finishReason: inferenceFinishReasonSchema.optional(),
743
+ deploymentId: deploymentIdSchema,
744
+ units: z.array(usageQuantitySchema),
745
+ usageSource: usageSourceSchema,
746
+ });
747
+ /**
748
+ * An error. `fatal: false` refuses one command and the session continues;
749
+ * `fatal: true` is followed by `session.closed` and nothing else.
750
+ */
751
+ export const realtimeErrorEventSchema = z.object({
752
+ ...eventBase,
753
+ type: z.literal("error"),
754
+ fatal: z.boolean(),
755
+ error: inferenceErrorSchema,
756
+ });
757
+ /** Why a session ended. */
758
+ export const realtimeSessionCloseReasonSchema = z.enum([
759
+ "client_closed",
760
+ "max_duration",
761
+ "idle_timeout",
762
+ "limit_exceeded",
763
+ "resume_expired",
764
+ "upstream_closed",
765
+ "upstream_error",
766
+ "no_route_available",
767
+ "server_shutdown",
768
+ ]);
769
+ /**
770
+ * The terminal event. Carries the session's total units; `deploymentId` is
771
+ * absent only when no route ever opened, in which case nothing was consumed.
772
+ */
773
+ export const realtimeSessionClosedEventSchema = z.object({
774
+ ...eventBase,
775
+ type: z.literal("session.closed"),
776
+ reason: realtimeSessionCloseReasonSchema,
777
+ deploymentId: deploymentIdSchema.optional(),
778
+ units: z.array(usageQuantitySchema),
779
+ usageSource: usageSourceSchema,
780
+ closedAt: inferenceTimestampSchema,
781
+ });
782
+ /** Every event a session can emit. */
783
+ export const realtimeServerEventSchema = z
784
+ .discriminatedUnion("type", [
785
+ realtimeSessionCreatedEventSchema,
786
+ realtimeSessionUpdatedEventSchema,
787
+ realtimeSessionResumedEventSchema,
788
+ realtimeCommandAcceptedEventSchema,
789
+ realtimeItemAddedEventSchema,
790
+ realtimeItemDoneEventSchema,
791
+ realtimeItemDeletedEventSchema,
792
+ realtimeItemTruncatedEventSchema,
793
+ realtimeSpeechStartedEventSchema,
794
+ realtimeSpeechStoppedEventSchema,
795
+ realtimeInputAudioCommittedEventSchema,
796
+ realtimeInputAudioClearedEventSchema,
797
+ realtimeResponseCreatedEventSchema,
798
+ realtimeOutputAudioDeltaEventSchema,
799
+ realtimeOutputAudioDoneEventSchema,
800
+ realtimeTranscriptDeltaEventSchema,
801
+ realtimeTranscriptDoneEventSchema,
802
+ realtimeTextDeltaEventSchema,
803
+ realtimeToolCallEventSchema,
804
+ realtimeResponseDoneEventSchema,
805
+ realtimeErrorEventSchema,
806
+ realtimeSessionClosedEventSchema,
807
+ ])
808
+ .superRefine((event, ctx) => {
809
+ // Each unit is reported once, as a total — the rule every usage record in
810
+ // this contract holds. Checked on the union because a discriminated union's
811
+ // options must stay plain objects.
812
+ if (event.type !== "response.done" && event.type !== "session.closed")
813
+ return;
814
+ const units = event.units.map((quantity) => quantity.unit);
815
+ if (new Set(units).size !== units.length) {
816
+ ctx.addIssue({
817
+ code: z.ZodIssueCode.custom,
818
+ path: ["units"],
819
+ message: "each unit is reported at most once",
820
+ });
821
+ }
822
+ });