@kaana.ai/contracts 0.0.0-stage → 4.3.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 (79) hide show
  1. package/LICENSE +673 -0
  2. package/NOTICE +21 -0
  3. package/dist/index.d.ts +29 -0
  4. package/dist/index.js +29 -0
  5. package/dist/internal/attribution.d.ts +116 -0
  6. package/dist/internal/attribution.js +82 -0
  7. package/dist/internal/canonical.d.ts +31 -0
  8. package/dist/internal/canonical.js +57 -0
  9. package/dist/internal/catalogue.d.ts +1030 -0
  10. package/dist/internal/catalogue.js +349 -0
  11. package/dist/internal/credentialControl.d.ts +329 -0
  12. package/dist/internal/credentialControl.js +256 -0
  13. package/dist/internal/decisions.d.ts +245 -0
  14. package/dist/internal/decisions.js +69 -0
  15. package/dist/internal/economics.d.ts +27 -0
  16. package/dist/internal/economics.js +26 -0
  17. package/dist/internal/embeddings.d.ts +164 -0
  18. package/dist/internal/embeddings.js +59 -0
  19. package/dist/internal/errors.d.ts +185 -0
  20. package/dist/internal/errors.js +211 -0
  21. package/dist/internal/grant.d.ts +696 -0
  22. package/dist/internal/grant.js +437 -0
  23. package/dist/internal/identifiers.d.ts +63 -0
  24. package/dist/internal/identifiers.js +65 -0
  25. package/dist/internal/privateAutoExecution.d.ts +158 -0
  26. package/dist/internal/privateAutoExecution.js +188 -0
  27. package/dist/internal/realtime.d.ts +1005 -0
  28. package/dist/internal/realtime.js +187 -0
  29. package/dist/internal/request.d.ts +1079 -0
  30. package/dist/internal/request.js +404 -0
  31. package/dist/internal/routing.d.ts +90 -0
  32. package/dist/internal/routing.js +139 -0
  33. package/dist/internal/scopedExecution.d.ts +74 -0
  34. package/dist/internal/scopedExecution.js +61 -0
  35. package/dist/internal/settlement.d.ts +205 -0
  36. package/dist/internal/settlement.js +71 -0
  37. package/dist/internal/streamEvents.d.ts +422 -0
  38. package/dist/internal/streamEvents.js +171 -0
  39. package/dist/internal/usage.d.ts +127 -0
  40. package/dist/internal/usage.js +109 -0
  41. package/dist/internal.d.ts +30 -0
  42. package/dist/internal.js +30 -0
  43. package/dist/public/audio.d.ts +347 -0
  44. package/dist/public/audio.js +236 -0
  45. package/dist/public/audioFormats.d.ts +64 -0
  46. package/dist/public/audioFormats.js +60 -0
  47. package/dist/public/catalogue.d.ts +914 -0
  48. package/dist/public/catalogue.js +516 -0
  49. package/dist/public/chatCompletions.d.ts +381 -0
  50. package/dist/public/chatCompletions.js +321 -0
  51. package/dist/public/decisions.d.ts +182 -0
  52. package/dist/public/decisions.js +257 -0
  53. package/dist/public/embeddings.d.ts +34 -0
  54. package/dist/public/embeddings.js +66 -0
  55. package/dist/public/errors.d.ts +195 -0
  56. package/dist/public/errors.js +274 -0
  57. package/dist/public/generations.d.ts +330 -0
  58. package/dist/public/generations.js +121 -0
  59. package/dist/public/identifiers.d.ts +139 -0
  60. package/dist/public/identifiers.js +160 -0
  61. package/dist/public/images.d.ts +36 -0
  62. package/dist/public/images.js +36 -0
  63. package/dist/public/messages.d.ts +297 -0
  64. package/dist/public/messages.js +251 -0
  65. package/dist/public/money.d.ts +308 -0
  66. package/dist/public/money.js +237 -0
  67. package/dist/public/policy.d.ts +19 -0
  68. package/dist/public/policy.js +20 -0
  69. package/dist/public/realtime.d.ts +2006 -0
  70. package/dist/public/realtime.js +797 -0
  71. package/dist/public/responses.d.ts +253 -0
  72. package/dist/public/responses.js +95 -0
  73. package/dist/public/streamEvents.d.ts +372 -0
  74. package/dist/public/streamEvents.js +157 -0
  75. package/dist/public/version.d.ts +177 -0
  76. package/dist/public/version.js +177 -0
  77. package/openapi.json +6967 -0
  78. package/package.json +44 -4
  79. package/README.md +0 -3
@@ -0,0 +1,797 @@
1
+ /**
2
+ * Realtime sessions as a public client speaks them (`GET /v1/realtime`).
3
+ *
4
+ * Moved from `@oxy.so/contracts` 4.12.0 (`src/inference/realtime.ts`) by Kaana ADR 0002.
5
+ */
6
+ import { z } from 'zod';
7
+ import { realtimeAudioFormatSchema } from './audioFormats.js';
8
+ import { realtimeSessionKindSchema, realtimeSessionTransportSchema, } from './catalogue.js';
9
+ import { inferenceTimestampSchema, modelReferenceSchema, PADDED_BASE64_PATTERN, requestIdSchema, } from './identifiers.js';
10
+ import { publicErrorSchema } from './errors.js';
11
+ import { requestLabelsSchema, toolChoiceSchema, toolDefinitionSchema } from './messages.js';
12
+ import { usageQuantitySchema, usageSourceSchema } from './money.js';
13
+ import { inferenceFinishReasonSchema } from './streamEvents.js';
14
+ /**
15
+ * The most base64 characters one audio frame may carry: 49 152 bytes of audio,
16
+ * about one second of 24 kHz mono PCM16. Bounded so no frame is large enough to
17
+ * stall a connection, and so a byte cap can be enforced as audio arrives rather
18
+ * than after it has been buffered.
19
+ */
20
+ export const MAX_REALTIME_AUDIO_FRAME_BASE64_LENGTH = 65_536;
21
+ /** The longest a session may be signed for: one hour. */
22
+ export const MAX_REALTIME_SESSION_DURATION_MS = 3_600_000;
23
+ /**
24
+ * The most audio a session may be signed for in EACH direction: one hour of
25
+ * 24 kHz mono PCM16 (48 000 bytes per second), the densest format a session
26
+ * carries.
27
+ */
28
+ export const MAX_REALTIME_SESSION_AUDIO_BYTES = 172_800_000;
29
+ /** The longest a dropped connection may be resumed after: one minute. */
30
+ export const MAX_REALTIME_RESUME_WINDOW_MS = 60_000;
31
+ /** A client-chosen command identity, unique within one session. */
32
+ export const realtimeCommandIdSchema = z.string().min(1).max(128);
33
+ /** A conversation item's identity within one session. */
34
+ export const realtimeItemIdSchema = z.string().min(1).max(128);
35
+ /** A response's identity within one session. */
36
+ export const realtimeResponseIdSchema = z.string().min(1).max(128);
37
+ /** What a session's responses produce. */
38
+ export const realtimeOutputModalitySchema = z.enum(['text', 'audio']);
39
+ const audioFrameSchema = z
40
+ .string()
41
+ .min(4)
42
+ .max(MAX_REALTIME_AUDIO_FRAME_BASE64_LENGTH)
43
+ .regex(PADDED_BASE64_PATTERN);
44
+ const sequenceSchema = z.number().int().nonnegative().safe();
45
+ const milliseconds = z.number().int().nonnegative().safe();
46
+ /**
47
+ * How the end of a user's turn is detected.
48
+ *
49
+ * `none` means the client commits input audio itself. The two VAD forms name
50
+ * `createResponse` and `interruptResponse` explicitly rather than defaulting
51
+ * them: whether the model answers on its own, and whether new speech cuts it
52
+ * off, are the two behaviours a caller most needs to have chosen, and a default
53
+ * would choose them silently — and differently per provider.
54
+ */
55
+ export const realtimeTurnDetectionSchema = z.discriminatedUnion('type', [
56
+ z.object({ type: z.literal('none') }).strict(),
57
+ z
58
+ .object({
59
+ type: z.literal('server_vad'),
60
+ threshold: z.number().min(0).max(1).optional(),
61
+ prefixPaddingMs: z.number().int().min(0).max(5_000).optional(),
62
+ silenceDurationMs: z.number().int().min(0).max(10_000).optional(),
63
+ createResponse: z.boolean(),
64
+ interruptResponse: z.boolean(),
65
+ })
66
+ .strict(),
67
+ z
68
+ .object({
69
+ type: z.literal('semantic_vad'),
70
+ eagerness: z.enum(['low', 'medium', 'high', 'auto']),
71
+ createResponse: z.boolean(),
72
+ interruptResponse: z.boolean(),
73
+ })
74
+ .strict(),
75
+ ]);
76
+ /**
77
+ * Transcription of the caller's input audio. Presence enables it; the
78
+ * transcription model is the deployment's own, declared where the deployment
79
+ * is, never chosen by a string here.
80
+ *
81
+ * In a `conversation` it is served only by a model whose catalogue entry says
82
+ * so (`capabilities.realtime.inputAudioTranscription`, contract set 4.1.0),
83
+ * and each transcribed turn arrives as `transcript.delta` / `transcript.done`
84
+ * with `source: 'input_audio'`. What it costs is settled with the session:
85
+ * where the provider bills the transcription apart from the session model, as
86
+ * `input_transcription_milliseconds` (see `USAGE_UNITS`); where the session's
87
+ * own price includes it, as nothing more.
88
+ *
89
+ * `language`, when given, is one of the model's published `audio.languages`.
90
+ */
91
+ export const realtimeInputTranscriptionSchema = z
92
+ .object({
93
+ /** A BCP 47 tag, when the caller knows the language. */
94
+ language: z.string().min(2).max(35).optional(),
95
+ prompt: z.string().max(4_096).optional(),
96
+ })
97
+ .strict();
98
+ /** The target of a `translation` session. */
99
+ export const realtimeTranslationSchema = z
100
+ .object({
101
+ /** A BCP 47 tag. */
102
+ targetLanguage: z.string().min(2).max(35),
103
+ })
104
+ .strict();
105
+ /**
106
+ * A session's full configuration, as signed at open and as echoed back by
107
+ * `session.created` and `session.updated`.
108
+ *
109
+ * `inputAudioFormat` and `turnDetection` are required because every session
110
+ * kind consumes audio and has to know when a turn ends; nothing else is,
111
+ * because each kind needs a different subset — see the refinement on
112
+ * {@link realtimeSessionRequestSchema}.
113
+ */
114
+ export const realtimeSessionConfigSchema = z
115
+ .object({
116
+ instructions: z.string().max(32_768).optional(),
117
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
118
+ voice: z.string().min(1).max(64).optional(),
119
+ inputAudioFormat: realtimeAudioFormatSchema,
120
+ outputAudioFormat: realtimeAudioFormatSchema.optional(),
121
+ turnDetection: realtimeTurnDetectionSchema,
122
+ inputAudioTranscription: realtimeInputTranscriptionSchema.optional(),
123
+ translation: realtimeTranslationSchema.optional(),
124
+ tools: z.array(toolDefinitionSchema).max(128).optional(),
125
+ toolChoice: toolChoiceSchema.optional(),
126
+ temperature: z.number().min(0).max(2).optional(),
127
+ /** Per response, not per session: the session's ceiling is `limits`. */
128
+ maxOutputTokens: z.number().int().positive().safe().optional(),
129
+ })
130
+ .strict();
131
+ /**
132
+ * What `session.update` may change mid-session.
133
+ *
134
+ * The session's kind, its audio formats and its voice are fixed at open —
135
+ * providers refuse to change a voice once it has spoken, and a format change
136
+ * mid-stream would make every buffered frame ambiguous — so they are not fields
137
+ * here, and the strict parse refuses them rather than ignoring them.
138
+ */
139
+ export const realtimeSessionConfigUpdateSchema = z
140
+ .object({
141
+ instructions: z.string().max(32_768).optional(),
142
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
143
+ turnDetection: realtimeTurnDetectionSchema.optional(),
144
+ inputAudioTranscription: realtimeInputTranscriptionSchema.optional(),
145
+ tools: z.array(toolDefinitionSchema).max(128).optional(),
146
+ toolChoice: toolChoiceSchema.optional(),
147
+ temperature: z.number().min(0).max(2).optional(),
148
+ maxOutputTokens: z.number().int().positive().safe().optional(),
149
+ })
150
+ .strict()
151
+ .superRefine((update, ctx) => {
152
+ if (Object.keys(update).length === 0) {
153
+ ctx.addIssue({
154
+ code: 'custom',
155
+ message: 'a session update changes at least one field',
156
+ });
157
+ }
158
+ });
159
+ /**
160
+ * The ceilings Oxy signed the session for. The data plane enforces each one and
161
+ * closes the session (`limit_exceeded`, `max_duration`, `idle_timeout`) rather
162
+ * than exceeding it: these bound what the session can cost, which is what Oxy's
163
+ * spend reservation was sized against.
164
+ */
165
+ export const realtimeSessionLimitsSchema = z
166
+ .object({
167
+ maxDurationMs: z.number().int().min(1_000).max(MAX_REALTIME_SESSION_DURATION_MS),
168
+ idleTimeoutMs: z.number().int().min(1_000).max(MAX_REALTIME_SESSION_DURATION_MS),
169
+ maxInputAudioBytes: z.number().int().positive().max(MAX_REALTIME_SESSION_AUDIO_BYTES),
170
+ maxOutputAudioBytes: z.number().int().positive().max(MAX_REALTIME_SESSION_AUDIO_BYTES),
171
+ maxResponses: z.number().int().positive().max(10_000),
172
+ })
173
+ .strict();
174
+ /**
175
+ * One part of a conversation message.
176
+ *
177
+ * `input_audio` carries its audio inline (one bounded frame); `output_audio`
178
+ * never does — spoken output arrives as `output_audio.delta` events, and an
179
+ * item echoing it carries only the format and, once known, the transcript.
180
+ */
181
+ export const realtimeContentPartSchema = z.discriminatedUnion('type', [
182
+ z.object({ type: z.literal('input_text'), text: z.string().max(1_048_576) }).strict(),
183
+ z
184
+ .object({
185
+ type: z.literal('input_audio'),
186
+ format: realtimeAudioFormatSchema,
187
+ data: audioFrameSchema.optional(),
188
+ transcript: z.string().optional(),
189
+ })
190
+ .strict(),
191
+ z.object({ type: z.literal('output_text'), text: z.string() }).strict(),
192
+ z
193
+ .object({
194
+ type: z.literal('output_audio'),
195
+ format: realtimeAudioFormatSchema,
196
+ transcript: z.string().optional(),
197
+ })
198
+ .strict(),
199
+ ]);
200
+ const INPUT_PARTS = new Set(['input_text', 'input_audio']);
201
+ /**
202
+ * One item of the conversation a session holds.
203
+ *
204
+ * `itemId` is optional on an item a client CREATES (the server assigns one) and
205
+ * is always stated by the server on the events that carry an item.
206
+ */
207
+ export const realtimeConversationItemSchema = z
208
+ .discriminatedUnion('type', [
209
+ z
210
+ .object({
211
+ type: z.literal('message'),
212
+ itemId: realtimeItemIdSchema.optional(),
213
+ role: z.enum(['system', 'user', 'assistant']),
214
+ content: z.array(realtimeContentPartSchema).min(1).max(64),
215
+ })
216
+ .strict(),
217
+ z
218
+ .object({
219
+ type: z.literal('function_call'),
220
+ itemId: realtimeItemIdSchema.optional(),
221
+ callId: z.string().min(1).max(128),
222
+ name: z.string().min(1).max(128),
223
+ arguments: z.string(),
224
+ })
225
+ .strict(),
226
+ z
227
+ .object({
228
+ type: z.literal('function_call_output'),
229
+ itemId: realtimeItemIdSchema.optional(),
230
+ callId: z.string().min(1).max(128),
231
+ output: z.string().max(1_048_576),
232
+ })
233
+ .strict(),
234
+ ])
235
+ .superRefine((item, ctx) => {
236
+ if (item.type !== 'message')
237
+ return;
238
+ for (const [index, part] of item.content.entries()) {
239
+ const isInput = INPUT_PARTS.has(part.type);
240
+ // A user or system turn is input; an assistant turn is output. A part on
241
+ // the wrong side is one every provider would silently ignore.
242
+ if (item.role === 'assistant' ? isInput : !isInput) {
243
+ ctx.addIssue({
244
+ code: 'custom',
245
+ path: ['content', index, 'type'],
246
+ message: `a ${item.role} message cannot carry ${part.type}`,
247
+ });
248
+ }
249
+ if (item.role === 'system' && part.type !== 'input_text') {
250
+ ctx.addIssue({
251
+ code: 'custom',
252
+ path: ['content', index, 'type'],
253
+ message: 'a system message is text',
254
+ });
255
+ }
256
+ }
257
+ });
258
+ /** Per-response overrides for `response.create`. Absent fields keep the session's. */
259
+ export const realtimeResponseParametersSchema = z
260
+ .object({
261
+ instructions: z.string().max(32_768).optional(),
262
+ outputModalities: z.array(realtimeOutputModalitySchema).min(1).max(2).optional(),
263
+ maxOutputTokens: z.number().int().positive().safe().optional(),
264
+ toolChoice: toolChoiceSchema.optional(),
265
+ })
266
+ .strict();
267
+ const commandBase = {
268
+ /** See `version.ts`: each command is a whole message on the wire. */
269
+ schemaVersion: z.literal(1),
270
+ requestId: requestIdSchema,
271
+ commandId: realtimeCommandIdSchema,
272
+ };
273
+ export const realtimeSessionUpdateCommandSchema = z.object({
274
+ ...commandBase,
275
+ type: z.literal('session.update'),
276
+ config: realtimeSessionConfigUpdateSchema,
277
+ });
278
+ export const realtimeItemCreateCommandSchema = z.object({
279
+ ...commandBase,
280
+ type: z.literal('conversation.item.create'),
281
+ /** Insert after this item; absent appends. */
282
+ previousItemId: realtimeItemIdSchema.optional(),
283
+ item: realtimeConversationItemSchema,
284
+ });
285
+ export const realtimeItemDeleteCommandSchema = z.object({
286
+ ...commandBase,
287
+ type: z.literal('conversation.item.delete'),
288
+ itemId: realtimeItemIdSchema,
289
+ });
290
+ /**
291
+ * Cut an assistant's audio item at the point the listener actually heard, after
292
+ * an interruption, so the conversation the model remembers is the one that was
293
+ * spoken rather than the one that was generated.
294
+ */
295
+ export const realtimeItemTruncateCommandSchema = z.object({
296
+ ...commandBase,
297
+ type: z.literal('conversation.item.truncate'),
298
+ itemId: realtimeItemIdSchema,
299
+ contentIndex: z.number().int().nonnegative().safe(),
300
+ audioEndMs: milliseconds,
301
+ });
302
+ export const realtimeInputAudioAppendCommandSchema = z.object({
303
+ ...commandBase,
304
+ type: z.literal('input_audio.append'),
305
+ /** One bounded frame in the session's `inputAudioFormat`. */
306
+ data: audioFrameSchema,
307
+ });
308
+ export const realtimeInputAudioCommitCommandSchema = z.object({
309
+ ...commandBase,
310
+ type: z.literal('input_audio.commit'),
311
+ });
312
+ export const realtimeInputAudioClearCommandSchema = z.object({
313
+ ...commandBase,
314
+ type: z.literal('input_audio.clear'),
315
+ });
316
+ export const realtimeResponseCreateCommandSchema = z.object({
317
+ ...commandBase,
318
+ type: z.literal('response.create'),
319
+ response: realtimeResponseParametersSchema.optional(),
320
+ });
321
+ export const realtimeResponseCancelCommandSchema = z.object({
322
+ ...commandBase,
323
+ type: z.literal('response.cancel'),
324
+ /** Absent cancels the response in progress, if any. */
325
+ responseId: realtimeResponseIdSchema.optional(),
326
+ });
327
+ /**
328
+ * The first frame of a reconnection: replay every event after `afterSequence`.
329
+ * `-1` asks for every buffered event from the start.
330
+ */
331
+ export const realtimeSessionResumeCommandSchema = z.object({
332
+ ...commandBase,
333
+ type: z.literal('session.resume'),
334
+ afterSequence: z.number().int().min(-1).safe(),
335
+ });
336
+ export const realtimeSessionCloseCommandSchema = z.object({
337
+ ...commandBase,
338
+ type: z.literal('session.close'),
339
+ });
340
+ /** Every command a client can send. */
341
+ export const realtimeClientCommandSchema = z.discriminatedUnion('type', [
342
+ realtimeSessionUpdateCommandSchema,
343
+ realtimeItemCreateCommandSchema,
344
+ realtimeItemDeleteCommandSchema,
345
+ realtimeItemTruncateCommandSchema,
346
+ realtimeInputAudioAppendCommandSchema,
347
+ realtimeInputAudioCommitCommandSchema,
348
+ realtimeInputAudioClearCommandSchema,
349
+ realtimeResponseCreateCommandSchema,
350
+ realtimeResponseCancelCommandSchema,
351
+ realtimeSessionResumeCommandSchema,
352
+ realtimeSessionCloseCommandSchema,
353
+ ]);
354
+ const eventBase = {
355
+ /** See `version.ts`: each event is a whole message on the wire. */
356
+ schemaVersion: z.literal(1),
357
+ requestId: requestIdSchema,
358
+ /** One monotonic sequence per session, from 0; what makes a replay detectable. */
359
+ sequence: sequenceSchema,
360
+ /** The command this event answers, when it answers one. */
361
+ commandId: realtimeCommandIdSchema.optional(),
362
+ };
363
+ export const realtimeSessionUpdatedEventSchema = z.object({
364
+ ...eventBase,
365
+ type: z.literal('session.updated'),
366
+ config: realtimeSessionConfigSchema,
367
+ });
368
+ export const realtimeSessionResumedEventSchema = z.object({
369
+ ...eventBase,
370
+ type: z.literal('session.resumed'),
371
+ afterSequence: z.number().int().min(-1).safe(),
372
+ });
373
+ /** Sent before a command is applied. `duplicate` means it was not applied again. */
374
+ export const realtimeCommandAcceptedEventSchema = z.object({
375
+ ...eventBase,
376
+ type: z.literal('command.accepted'),
377
+ commandId: realtimeCommandIdSchema,
378
+ duplicate: z.boolean(),
379
+ });
380
+ export const realtimeItemAddedEventSchema = z.object({
381
+ ...eventBase,
382
+ type: z.literal('conversation.item.added'),
383
+ itemId: realtimeItemIdSchema,
384
+ previousItemId: realtimeItemIdSchema.optional(),
385
+ item: realtimeConversationItemSchema,
386
+ });
387
+ export const realtimeItemDoneEventSchema = z.object({
388
+ ...eventBase,
389
+ type: z.literal('conversation.item.done'),
390
+ itemId: realtimeItemIdSchema,
391
+ item: realtimeConversationItemSchema,
392
+ });
393
+ export const realtimeItemDeletedEventSchema = z.object({
394
+ ...eventBase,
395
+ type: z.literal('conversation.item.deleted'),
396
+ itemId: realtimeItemIdSchema,
397
+ });
398
+ export const realtimeItemTruncatedEventSchema = z.object({
399
+ ...eventBase,
400
+ type: z.literal('conversation.item.truncated'),
401
+ itemId: realtimeItemIdSchema,
402
+ contentIndex: z.number().int().nonnegative().safe(),
403
+ audioEndMs: milliseconds,
404
+ });
405
+ /**
406
+ * Voice activity began. With `interruptResponse`, a response in progress is
407
+ * cancelled by it: this event is the interruption, and the client should stop
408
+ * playback and truncate what was not heard.
409
+ */
410
+ export const realtimeSpeechStartedEventSchema = z.object({
411
+ ...eventBase,
412
+ type: z.literal('input_audio.speech_started'),
413
+ itemId: realtimeItemIdSchema,
414
+ audioStartMs: milliseconds,
415
+ });
416
+ export const realtimeSpeechStoppedEventSchema = z.object({
417
+ ...eventBase,
418
+ type: z.literal('input_audio.speech_stopped'),
419
+ itemId: realtimeItemIdSchema,
420
+ audioEndMs: milliseconds,
421
+ });
422
+ export const realtimeInputAudioCommittedEventSchema = z.object({
423
+ ...eventBase,
424
+ type: z.literal('input_audio.committed'),
425
+ itemId: realtimeItemIdSchema,
426
+ previousItemId: realtimeItemIdSchema.optional(),
427
+ });
428
+ export const realtimeInputAudioClearedEventSchema = z.object({
429
+ ...eventBase,
430
+ type: z.literal('input_audio.cleared'),
431
+ });
432
+ export const realtimeResponseCreatedEventSchema = z.object({
433
+ ...eventBase,
434
+ type: z.literal('response.created'),
435
+ responseId: realtimeResponseIdSchema,
436
+ });
437
+ /** One bounded frame of spoken output, in the session's `outputAudioFormat`. */
438
+ export const realtimeOutputAudioDeltaEventSchema = z.object({
439
+ ...eventBase,
440
+ type: z.literal('output_audio.delta'),
441
+ responseId: realtimeResponseIdSchema,
442
+ itemId: realtimeItemIdSchema,
443
+ contentIndex: z.number().int().nonnegative().safe(),
444
+ format: realtimeAudioFormatSchema,
445
+ data: audioFrameSchema,
446
+ });
447
+ export const realtimeOutputAudioDoneEventSchema = z.object({
448
+ ...eventBase,
449
+ type: z.literal('output_audio.done'),
450
+ responseId: realtimeResponseIdSchema,
451
+ itemId: realtimeItemIdSchema,
452
+ contentIndex: z.number().int().nonnegative().safe(),
453
+ });
454
+ /**
455
+ * Which audio a transcript describes: the caller's (`input_audio`) or the
456
+ * model's own speech (`output_audio`). Neither is answer text.
457
+ */
458
+ export const realtimeTranscriptSourceSchema = z.enum(['input_audio', 'output_audio']);
459
+ export const realtimeTranscriptDeltaEventSchema = z.object({
460
+ ...eventBase,
461
+ type: z.literal('transcript.delta'),
462
+ source: realtimeTranscriptSourceSchema,
463
+ itemId: realtimeItemIdSchema,
464
+ contentIndex: z.number().int().nonnegative().safe(),
465
+ /** Present for `output_audio`: the response whose speech this is. */
466
+ responseId: realtimeResponseIdSchema.optional(),
467
+ text: z.string(),
468
+ });
469
+ export const realtimeTranscriptDoneEventSchema = z.object({
470
+ ...eventBase,
471
+ type: z.literal('transcript.done'),
472
+ source: realtimeTranscriptSourceSchema,
473
+ itemId: realtimeItemIdSchema,
474
+ contentIndex: z.number().int().nonnegative().safe(),
475
+ responseId: realtimeResponseIdSchema.optional(),
476
+ transcript: z.string(),
477
+ });
478
+ /** Written output of a response (`outputModalities` including `text`). */
479
+ export const realtimeTextDeltaEventSchema = z.object({
480
+ ...eventBase,
481
+ type: z.literal('text.delta'),
482
+ responseId: realtimeResponseIdSchema,
483
+ itemId: realtimeItemIdSchema,
484
+ contentIndex: z.number().int().nonnegative().safe(),
485
+ text: z.string(),
486
+ });
487
+ /** A tool call being streamed; same accumulation rules as the one-shot event. */
488
+ export const realtimeToolCallEventSchema = z.object({
489
+ ...eventBase,
490
+ type: z.literal('tool_call'),
491
+ responseId: realtimeResponseIdSchema,
492
+ itemId: realtimeItemIdSchema,
493
+ toolCallId: z.string().min(1).max(128),
494
+ name: z.string().min(1).max(128).optional(),
495
+ argumentsDelta: z.string().optional(),
496
+ complete: z.boolean(),
497
+ });
498
+ /** How a response ended. */
499
+ export const realtimeResponseStatusSchema = z.enum([
500
+ 'completed',
501
+ 'cancelled',
502
+ 'incomplete',
503
+ 'failed',
504
+ ]);
505
+ /** Why a session ended. */
506
+ export const realtimeSessionCloseReasonSchema = z.enum([
507
+ 'client_closed',
508
+ 'max_duration',
509
+ 'idle_timeout',
510
+ 'limit_exceeded',
511
+ 'resume_expired',
512
+ 'upstream_closed',
513
+ 'upstream_error',
514
+ 'no_route_available',
515
+ 'server_shutdown',
516
+ ]);
517
+ /**
518
+ * What a session kind requires of its configuration. Each kind needs a
519
+ * different subset of the config, so these rules are cross-field and live
520
+ * here once: the public `session.open` frame and the signed session request
521
+ * apply the same ones, and a session a client may open is one Kaana may run.
522
+ */
523
+ export function realtimeConfigIssues(kind, config) {
524
+ const issues = [];
525
+ const found = (path, message) => issues.push({ path, message });
526
+ const producesAudio = config.outputModalities?.includes('audio') === true;
527
+ const hasTools = config.tools !== undefined && config.tools.length > 0;
528
+ switch (kind) {
529
+ case 'conversation':
530
+ if (config.outputModalities === undefined) {
531
+ found(['outputModalities'], 'a conversation names what its responses produce');
532
+ }
533
+ if (producesAudio && (config.voice === undefined || config.outputAudioFormat === undefined)) {
534
+ found([], 'spoken responses need a voice and an output audio format');
535
+ }
536
+ if (config.translation !== undefined) {
537
+ found(['translation'], 'only a translation session translates');
538
+ }
539
+ break;
540
+ case 'transcription':
541
+ if (config.inputAudioTranscription === undefined) {
542
+ found(['inputAudioTranscription'], 'a transcription session transcribes its input');
543
+ }
544
+ for (const field of [
545
+ 'outputModalities',
546
+ 'voice',
547
+ 'outputAudioFormat',
548
+ 'translation',
549
+ 'tools',
550
+ 'toolChoice',
551
+ 'maxOutputTokens',
552
+ ]) {
553
+ if (config[field] !== undefined) {
554
+ found([field], 'a transcription session never responds');
555
+ }
556
+ }
557
+ if (config.turnDetection.type !== 'none' &&
558
+ (config.turnDetection.createResponse || config.turnDetection.interruptResponse)) {
559
+ found(['turnDetection'], 'a transcription session has no response to create or interrupt');
560
+ }
561
+ break;
562
+ case 'translation':
563
+ if (config.translation === undefined || config.outputAudioFormat === undefined) {
564
+ found([], 'a translation session names its target language and output audio format');
565
+ }
566
+ if (config.tools !== undefined || config.toolChoice !== undefined) {
567
+ found(['tools'], 'a translation session calls no tools');
568
+ }
569
+ break;
570
+ }
571
+ if (config.toolChoice !== undefined && !hasTools) {
572
+ found(['toolChoice'], 'a tool choice requires at least one tool definition');
573
+ }
574
+ if (config.tools !== undefined) {
575
+ const names = config.tools.map((tool) => tool.name);
576
+ if (new Set(names).size !== names.length) {
577
+ found(['tools'], 'tool names must be unique within one session');
578
+ }
579
+ }
580
+ if (config.outputModalities !== undefined &&
581
+ new Set(config.outputModalities).size !== config.outputModalities.length) {
582
+ found(['outputModalities'], 'each output modality is named at most once');
583
+ }
584
+ return issues;
585
+ }
586
+ /* -------------------------------------------------------------------------- */
587
+ /* Opening a session (`GET /v1/realtime`) */
588
+ /* -------------------------------------------------------------------------- */
589
+ /**
590
+ * The most text `conversation.item.create` commands a session may be opened
591
+ * for: the bound a duration-priced route's hold is sized from.
592
+ */
593
+ export const MAX_REALTIME_TEXT_ITEMS = 10_000;
594
+ /**
595
+ * The customer's partial limits request: every signed limit, each filled from
596
+ * Kaana's defaults when omitted, plus `maxTextItems`, the most billable text
597
+ * items the session may send (absent, it defaults to `maxResponses`).
598
+ */
599
+ export const realtimeOpenLimitsSchema = realtimeSessionLimitsSchema
600
+ .partial()
601
+ .extend({
602
+ maxTextItems: z.number().int().nonnegative().max(MAX_REALTIME_TEXT_ITEMS).optional(),
603
+ })
604
+ .strict();
605
+ /**
606
+ * The first frame a client sends on `GET /v1/realtime?model=…` to OPEN a
607
+ * session (a reconnection instead sends `session.resume`). One grant covers the
608
+ * whole session and it is settled exactly once, when the session closes.
609
+ *
610
+ * Moved from the Oxy edge (`realtimeOpenFrameSchema`) by Kaana ADR 0002.
611
+ */
612
+ export const realtimeSessionOpenSchema = z
613
+ .object({
614
+ type: z.literal('session.open'),
615
+ kind: realtimeSessionKindSchema,
616
+ /** Absent means `websocket`, the only transport there is. */
617
+ transport: realtimeSessionTransportSchema.optional(),
618
+ config: realtimeSessionConfigSchema,
619
+ limits: realtimeOpenLimitsSchema.optional(),
620
+ /** The caller's own correlation id. */
621
+ clientSessionId: z.string().min(1).max(128).optional(),
622
+ labels: requestLabelsSchema.optional(),
623
+ })
624
+ .strict()
625
+ .superRefine((open, ctx) => {
626
+ for (const found of realtimeConfigIssues(open.kind, open.config)) {
627
+ ctx.addIssue({ code: 'custom', path: ['config', ...found.path], message: found.message });
628
+ }
629
+ const limits = open.limits;
630
+ if (limits?.idleTimeoutMs !== undefined &&
631
+ limits.maxDurationMs !== undefined &&
632
+ limits.idleTimeoutMs > limits.maxDurationMs) {
633
+ ctx.addIssue({
634
+ code: 'custom',
635
+ path: ['limits', 'idleTimeoutMs'],
636
+ message: 'an idle timeout cannot outlast the session',
637
+ });
638
+ }
639
+ });
640
+ /**
641
+ * Check a `session.open` against the model's published catalogue entry — its
642
+ * `capabilities.realtime` and its `audio` — the realtime counterpart of
643
+ * {@link checkSpeechRequest} (Kaana ADR 0002 decision 9; Alia ADR 0017 §5).
644
+ * Kaana refuses an open with `invalid_request` and the first issue's `param`
645
+ * BEFORE asking Oxy for a grant; a client may run the same check to choose a
646
+ * model.
647
+ *
648
+ * A field the catalogue leaves undeclared (an older producer) is not checked:
649
+ * absent is unknown, never "everything" and never "nothing". The one
650
+ * exception is input transcription in a conversation, which a model must
651
+ * declare to be asked for, because asking for it starts a charge.
652
+ *
653
+ * Returns every issue, in field order; an empty list means the model accepts
654
+ * the open.
655
+ */
656
+ export function checkRealtimeSessionOpen(open, model) {
657
+ const realtime = model.capabilities.realtime;
658
+ if (realtime === undefined) {
659
+ return [{ param: 'kind', message: 'this model holds no realtime session' }];
660
+ }
661
+ const issues = [];
662
+ if (!realtime.sessionKinds.includes(open.kind)) {
663
+ issues.push({ param: 'kind', message: `this model holds no ${open.kind} session` });
664
+ }
665
+ const transport = open.transport ?? 'websocket';
666
+ if (!realtime.transports.includes(transport)) {
667
+ issues.push({ param: 'transport', message: `this model is not served over ${transport}` });
668
+ }
669
+ const { config } = open;
670
+ const formats = realtime.audioFormats;
671
+ if (formats !== undefined && !formats.input.includes(config.inputAudioFormat)) {
672
+ issues.push({
673
+ param: 'config.inputAudioFormat',
674
+ message: `this model does not take ${config.inputAudioFormat} input`,
675
+ });
676
+ }
677
+ if (formats !== undefined &&
678
+ config.outputAudioFormat !== undefined &&
679
+ !formats.output.includes(config.outputAudioFormat)) {
680
+ issues.push({
681
+ param: 'config.outputAudioFormat',
682
+ message: `this model does not produce ${config.outputAudioFormat}`,
683
+ });
684
+ }
685
+ const audio = model.audio;
686
+ if (audio !== undefined &&
687
+ config.voice !== undefined &&
688
+ !audio.voices.some((voice) => voice.id === config.voice)) {
689
+ issues.push({ param: 'config.voice', message: `this model has no voice ${config.voice}` });
690
+ }
691
+ const transcription = config.inputAudioTranscription;
692
+ if (transcription !== undefined &&
693
+ open.kind === 'conversation' &&
694
+ realtime.inputAudioTranscription !== true) {
695
+ issues.push({
696
+ param: 'config.inputAudioTranscription',
697
+ message: 'this model does not transcribe its input inside a conversation',
698
+ });
699
+ }
700
+ if (transcription?.language !== undefined &&
701
+ audio !== undefined &&
702
+ !audio.languages.includes(transcription.language)) {
703
+ issues.push({
704
+ param: 'config.inputAudioTranscription.language',
705
+ message: `this model does not declare the language ${transcription.language}`,
706
+ });
707
+ }
708
+ return issues;
709
+ }
710
+ /* -------------------------------------------------------------------------- */
711
+ /* Server events a public client reads */
712
+ /* -------------------------------------------------------------------------- */
713
+ /**
714
+ * The session opened: the revision-pinned model, fixed for the rest of the
715
+ * session, and the effective configuration and limits. The signed lane's
716
+ * `realtimeSessionCreatedEventSchema` also names the provider and deployment;
717
+ * a public client is never told either.
718
+ */
719
+ export const realtimePublicSessionCreatedEventSchema = z.object({
720
+ ...eventBase,
721
+ type: z.literal('session.created'),
722
+ /** The revision serving the session. */
723
+ model: modelReferenceSchema,
724
+ kind: realtimeSessionKindSchema,
725
+ config: realtimeSessionConfigSchema,
726
+ limits: realtimeSessionLimitsSchema,
727
+ resumeWindowMs: z.number().int().nonnegative().max(MAX_REALTIME_RESUME_WINDOW_MS),
728
+ startedAt: inferenceTimestampSchema,
729
+ expiresAt: inferenceTimestampSchema,
730
+ });
731
+ /** A response ended, with the units it consumed. */
732
+ export const realtimePublicResponseDoneEventSchema = z.object({
733
+ ...eventBase,
734
+ type: z.literal('response.done'),
735
+ responseId: realtimeResponseIdSchema,
736
+ status: realtimeResponseStatusSchema,
737
+ finishReason: inferenceFinishReasonSchema.optional(),
738
+ units: z.array(usageQuantitySchema),
739
+ usageSource: usageSourceSchema,
740
+ });
741
+ /**
742
+ * An error in the public vocabulary. `fatal: false` refuses one command and
743
+ * the session continues; `fatal: true` is followed by `session.closed`.
744
+ */
745
+ export const realtimePublicErrorEventSchema = z.object({
746
+ ...eventBase,
747
+ type: z.literal('error'),
748
+ fatal: z.boolean(),
749
+ error: publicErrorSchema,
750
+ });
751
+ /** The terminal event, with the session's total units. */
752
+ export const realtimePublicSessionClosedEventSchema = z.object({
753
+ ...eventBase,
754
+ type: z.literal('session.closed'),
755
+ reason: realtimeSessionCloseReasonSchema,
756
+ units: z.array(usageQuantitySchema),
757
+ usageSource: usageSourceSchema,
758
+ closedAt: inferenceTimestampSchema,
759
+ });
760
+ /** Every event a public session can emit. */
761
+ export const realtimePublicServerEventSchema = z
762
+ .discriminatedUnion('type', [
763
+ realtimePublicSessionCreatedEventSchema,
764
+ realtimeSessionUpdatedEventSchema,
765
+ realtimeSessionResumedEventSchema,
766
+ realtimeCommandAcceptedEventSchema,
767
+ realtimeItemAddedEventSchema,
768
+ realtimeItemDoneEventSchema,
769
+ realtimeItemDeletedEventSchema,
770
+ realtimeItemTruncatedEventSchema,
771
+ realtimeSpeechStartedEventSchema,
772
+ realtimeSpeechStoppedEventSchema,
773
+ realtimeInputAudioCommittedEventSchema,
774
+ realtimeInputAudioClearedEventSchema,
775
+ realtimeResponseCreatedEventSchema,
776
+ realtimeOutputAudioDeltaEventSchema,
777
+ realtimeOutputAudioDoneEventSchema,
778
+ realtimeTranscriptDeltaEventSchema,
779
+ realtimeTranscriptDoneEventSchema,
780
+ realtimeTextDeltaEventSchema,
781
+ realtimeToolCallEventSchema,
782
+ realtimePublicResponseDoneEventSchema,
783
+ realtimePublicErrorEventSchema,
784
+ realtimePublicSessionClosedEventSchema,
785
+ ])
786
+ .superRefine((event, ctx) => {
787
+ if (event.type !== 'response.done' && event.type !== 'session.closed')
788
+ return;
789
+ const units = event.units.map((quantity) => quantity.unit);
790
+ if (new Set(units).size !== units.length) {
791
+ ctx.addIssue({
792
+ code: 'custom',
793
+ path: ['units'],
794
+ message: 'each unit is reported at most once',
795
+ });
796
+ }
797
+ });