@kaana.ai/sdk 0.0.0-stage → 1.0.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.
- package/LICENSE +673 -0
- package/NOTICE +21 -0
- package/README.md +164 -2
- package/dist/audio.d.ts +39 -0
- package/dist/audio.js +250 -0
- package/dist/base64.d.ts +18 -0
- package/dist/base64.js +87 -0
- package/dist/chat.d.ts +11 -0
- package/dist/chat.js +149 -0
- package/dist/client.d.ts +52 -0
- package/dist/client.js +109 -0
- package/dist/errors.d.ts +82 -0
- package/dist/errors.js +150 -0
- package/dist/events.d.ts +11 -0
- package/dist/events.js +51 -0
- package/dist/http.d.ts +109 -0
- package/dist/http.js +338 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/realtime.d.ts +28 -0
- package/dist/realtime.js +322 -0
- package/dist/responses.d.ts +11 -0
- package/dist/responses.js +152 -0
- package/dist/sse.d.ts +14 -0
- package/dist/sse.js +94 -0
- package/dist/stream.d.ts +41 -0
- package/dist/stream.js +110 -0
- package/package.json +39 -4
package/NOTICE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
============================================================================
|
|
2
|
+
NOTICE
|
|
3
|
+
@kaana.ai/sdk
|
|
4
|
+
Copyright (c) 2026-present The Oxy Collective, Inc.
|
|
5
|
+
============================================================================
|
|
6
|
+
|
|
7
|
+
This product is licensed under the Breathe License 1.0, license identifier
|
|
8
|
+
LicenseRef-Breathe-1.0. See the LICENSE file in this directory. Commercial
|
|
9
|
+
terms are available at
|
|
10
|
+
https://github.com/OxyHQ/.github/blob/main/LICENSE-COMMERCIAL.md
|
|
11
|
+
|
|
12
|
+
Attribution under Section 3.1 of the Breathe License is required of
|
|
13
|
+
everyone, including paying commercial licensees, and cannot be waived.
|
|
14
|
+
|
|
15
|
+
----------------------------------------------------------------------------
|
|
16
|
+
THIRD PARTY COMPONENTS
|
|
17
|
+
----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
This package contains no third party source. Its dependencies are resolved
|
|
20
|
+
and installed by the package manager and remain governed by their own
|
|
21
|
+
licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kaana.ai/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The TypeScript client of Kaana's public API, `https://kaana.ai/v1`. It runs in
|
|
4
|
+
Node 20+, browsers and React Native, validates every response against
|
|
5
|
+
[`@kaana.ai/contracts`](https://www.npmjs.com/package/@kaana.ai/contracts) and
|
|
6
|
+
reports failures as typed errors.
|
|
7
|
+
|
|
8
|
+
Applications, API keys (`oxy_sk_…`), limits, budgets, usage and billing are
|
|
9
|
+
managed in [Oxy Console](https://console.oxy.so). Kaana has no console and
|
|
10
|
+
issues no keys of its own.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @kaana.ai/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Credentials
|
|
17
|
+
|
|
18
|
+
The credential is the value of `Authorization: Bearer`: a string, or a
|
|
19
|
+
function called on every request. The SDK never caches it; whoever supplies
|
|
20
|
+
the function already caches and renews.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Kaana } from '@kaana.ai/sdk';
|
|
24
|
+
|
|
25
|
+
new Kaana({ credential: process.env.KAANA_API_KEY! }); // oxy_sk_ from Console
|
|
26
|
+
new Kaana({ credential: () => oxyServer.serviceToken({ audience: 'kaana.ai' }) }); // an Oxy service
|
|
27
|
+
new Kaana({ credential: async () => (await oxy.getAccessToken())! }); // an Oxy app session
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Options: `baseURL` (only `https://kaana.ai/v1`, or a loopback origin in
|
|
31
|
+
development), `fetch`, `WebSocket` (for realtime where there is no global
|
|
32
|
+
one, e.g. `ws` in older Node), `timeoutMs` (default 120 000) and `maxRetries`
|
|
33
|
+
(default 2: only before the first byte, only for retryable codes, honouring
|
|
34
|
+
`retryAfterMs`).
|
|
35
|
+
|
|
36
|
+
Every method takes `RequestOptions`: `signal`, `idempotencyKey`
|
|
37
|
+
(`Idempotency-Key`, reused by every retry), `onBehalfOfUserId`
|
|
38
|
+
(`X-Oxy-User-Id`, honoured only with a service token) and `timeoutMs`.
|
|
39
|
+
|
|
40
|
+
## Methods
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const kaana = new Kaana({ credential: process.env.KAANA_API_KEY! });
|
|
44
|
+
|
|
45
|
+
await kaana.models.list();
|
|
46
|
+
await kaana.models.get('elevenlabs/eleven-multilingual@v2');
|
|
47
|
+
await kaana.powerLevels.list();
|
|
48
|
+
|
|
49
|
+
await kaana.responses.create({ model: 'auto', input: 'Hola' });
|
|
50
|
+
const stream = kaana.responses.stream({ model: 'high', input: 'Cuéntame un cuento' });
|
|
51
|
+
for await (const event of stream) if (event.type === 'delta') process.stdout.write(event.text);
|
|
52
|
+
const response = await stream.final; // the folded Response
|
|
53
|
+
|
|
54
|
+
await kaana.chat.completions.create({ model: 'instant', messages: [{ role: 'user', content: 'Hola' }] });
|
|
55
|
+
kaana.chat.completions.stream({ model: 'instant', messages: [{ role: 'user', content: 'Hola' }] });
|
|
56
|
+
|
|
57
|
+
await kaana.embeddings.create({ model: 'openai/text-embedding-3-small', input: ['hola'] });
|
|
58
|
+
await kaana.images.generate({ model: 'openai/gpt-image-1', prompt: 'A fox' });
|
|
59
|
+
await kaana.decisions.create({ model: 'openai/gpt-5@2026-06-01', state: '…', questions: [/* … */] });
|
|
60
|
+
await kaana.generations.get('gen_…');
|
|
61
|
+
await kaana.generations.getByIdempotencyKey('my-key');
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A stream is iterated once. Breaking out of the loop, or `stream.abort()`,
|
|
65
|
+
cancels the request upstream. Awaiting `final` alone drains the stream.
|
|
66
|
+
|
|
67
|
+
The API is also compatible with the OpenAI SDK for chat completions:
|
|
68
|
+
`new OpenAI({ baseURL: 'https://kaana.ai/v1', apiKey: 'oxy_sk_…' })`.
|
|
69
|
+
|
|
70
|
+
## Audio
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const speech = await kaana.audio.speech({
|
|
74
|
+
model: 'elevenlabs/eleven-multilingual',
|
|
75
|
+
input: 'Hola',
|
|
76
|
+
voice: 'rachel',
|
|
77
|
+
});
|
|
78
|
+
speech.audio; // Uint8Array, speech.mediaType, speech.model (the served revision), speech.usage
|
|
79
|
+
|
|
80
|
+
const live = kaana.audio.speechStream({ model: 'elevenlabs/eleven-multilingual', input: 'Hola', voice: 'rachel' });
|
|
81
|
+
for await (const chunk of live) play(chunk); // mp3 or pcm, in order, as produced
|
|
82
|
+
const { model, usage } = await live.done;
|
|
83
|
+
live.toReadableStream(); // the same audio as a ReadableStream
|
|
84
|
+
|
|
85
|
+
await kaana.audio.transcriptions.create({
|
|
86
|
+
model: 'openai/whisper-1',
|
|
87
|
+
file: bytesOrBlob, // a Blob is sent as multipart; a Uint8Array as JSON with base64
|
|
88
|
+
mediaType: 'audio/wav',
|
|
89
|
+
language: 'es-ES',
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Voices, languages, formats, the `speed` range and the character limit of each
|
|
94
|
+
speech model are in `models.get(...).audio`; the server checks them before
|
|
95
|
+
anything is reserved.
|
|
96
|
+
|
|
97
|
+
## Realtime
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const session = await kaana.realtime.connect({
|
|
101
|
+
model: 'openai/gpt-realtime',
|
|
102
|
+
type: 'session.open',
|
|
103
|
+
kind: 'conversation',
|
|
104
|
+
config: {
|
|
105
|
+
outputModalities: ['audio'],
|
|
106
|
+
voice: 'marin',
|
|
107
|
+
inputAudioFormat: 'pcm16_24khz',
|
|
108
|
+
outputAudioFormat: 'pcm16_24khz',
|
|
109
|
+
turnDetection: { type: 'server_vad', createResponse: true, interruptResponse: true },
|
|
110
|
+
tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object' } }],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
session.appendAudio(pcm16); // split into frames of at most 49 152 bytes
|
|
115
|
+
session.send({ type: 'response.create' }); // schemaVersion, requestId and commandId are filled in
|
|
116
|
+
for await (const event of session) {
|
|
117
|
+
if (event.type === 'tool_call' && event.complete) {
|
|
118
|
+
session.send({
|
|
119
|
+
type: 'conversation.item.create',
|
|
120
|
+
item: { type: 'function_call_output', callId: event.toolCallId, output: '{}' },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const usage = await session.close(); // settles exactly once
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The credential travels as a WebSocket subprotocol (`kaana.bearer.…`), so
|
|
128
|
+
realtime works where a WebSocket cannot set headers (browsers, React Native).
|
|
129
|
+
If the connection drops, iteration throws a `KaanaConnectionError`; call
|
|
130
|
+
`session.resume()` within the session's resume window and iterate again:
|
|
131
|
+
what was missed is replayed once, in order.
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
| Class | Codes |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `KaanaAuthenticationError` | `authentication_failed` (keys are managed in [Oxy Console](https://console.oxy.so)) |
|
|
138
|
+
| `KaanaPermissionError` | `permission_denied`, `insufficient_scope`, `commercial_permission_denied`, `policy_violation` |
|
|
139
|
+
| `KaanaInvalidRequestError` | `invalid_request`, `model_not_found`, `unsupported_modality`, `context_length_exceeded`, `request_too_large`, `output_limit_exceeded`, `idempotency_conflict`, `byok_credential_invalid` |
|
|
140
|
+
| `KaanaBillingError` | `insufficient_balance`, `spending_limit_exceeded`, `quota_exceeded` |
|
|
141
|
+
| `KaanaRateLimitError` | `rate_limited` |
|
|
142
|
+
| `KaanaUnavailableError` | `no_route_available`, `service_unavailable`, `provider_error`, `provider_timeout`, `provider_overloaded` |
|
|
143
|
+
| `KaanaContentFilteredError` | `upstream_content_filtered` |
|
|
144
|
+
| `KaanaCancelledError` | `cancelled`, including a request you aborted |
|
|
145
|
+
| `KaanaInternalError` | `internal_error` |
|
|
146
|
+
|
|
147
|
+
Each `KaanaError` has `code`, `status` (`null` when it arrived as a terminal
|
|
148
|
+
stream event), `requestId`, `retryable`, `retryAfterMs`, `param` and
|
|
149
|
+
`afterOutput` (output reached you before the failure). `KaanaProtocolError` is a
|
|
150
|
+
response outside the contract and is never retried; `KaanaConnectionError` is
|
|
151
|
+
the network or a timeout (`timedOut`) before a response.
|
|
152
|
+
|
|
153
|
+
## React Native
|
|
154
|
+
|
|
155
|
+
Non-streaming methods work with React Native's built-in `fetch`. The streaming
|
|
156
|
+
methods (`responses.stream`, `chat.completions.stream`, `audio.speechStream`)
|
|
157
|
+
need a `fetch` whose response body is a `ReadableStream`; install a streaming
|
|
158
|
+
fetch polyfill and pass it as `fetch`. Without one they fail with a clear
|
|
159
|
+
`KaanaConnectionError` instead of buffering silently. Transcription of bytes
|
|
160
|
+
uses JSON with base64, so no `Blob` support is required. React Native's
|
|
161
|
+
`WebSocket` carries realtime sessions as is.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
Breathe License 1.0 — see `LICENSE` and `NOTICE`.
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** `POST /v1/audio/speech` (whole and streamed) and `POST /v1/audio/transcriptions`. */
|
|
2
|
+
import { type AudioInputMediaType, type AudioMediaType, type ModelReference, type SpeechRequest, type Transcription, type Usage } from '@kaana.ai/contracts';
|
|
3
|
+
import { type RequestOptions, type Transport } from './http.js';
|
|
4
|
+
export interface SpeechResult {
|
|
5
|
+
audio: Uint8Array;
|
|
6
|
+
/** 'audio/mpeg' | 'audio/wav' | 'audio/ogg' | 'audio/aac' | 'audio/flac' | 'audio/pcm' */
|
|
7
|
+
mediaType: AudioMediaType;
|
|
8
|
+
/** The revision that actually served the request. */
|
|
9
|
+
model: ModelReference;
|
|
10
|
+
requestId: string;
|
|
11
|
+
usage: Usage;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Audio in order, as soon as it is produced (`stream_format: 'sse'`, so a
|
|
15
|
+
* failure mid-stream arrives typed).
|
|
16
|
+
*/
|
|
17
|
+
export interface SpeechStream extends AsyncIterable<Uint8Array> {
|
|
18
|
+
readonly mediaType: Promise<AudioMediaType>;
|
|
19
|
+
readonly requestId: Promise<string>;
|
|
20
|
+
toReadableStream(): ReadableStream<Uint8Array>;
|
|
21
|
+
/** Model, usage and requestId at the end; rejects with KaanaError. */
|
|
22
|
+
readonly done: Promise<Omit<SpeechResult, 'audio'>>;
|
|
23
|
+
abort(reason?: unknown): void;
|
|
24
|
+
}
|
|
25
|
+
export interface TranscriptionRequest {
|
|
26
|
+
model: ModelReference;
|
|
27
|
+
/** A Blob is uploaded as multipart; bytes are sent as JSON with inline base64. */
|
|
28
|
+
file: Blob | Uint8Array;
|
|
29
|
+
mediaType: AudioInputMediaType;
|
|
30
|
+
/** BCP 47, validated against the catalogue. */
|
|
31
|
+
language?: string;
|
|
32
|
+
prompt?: string;
|
|
33
|
+
timestamps?: 'none' | 'segment' | 'word';
|
|
34
|
+
}
|
|
35
|
+
export declare function speech(transport: Transport, request: SpeechRequest, options: RequestOptions): Promise<SpeechResult>;
|
|
36
|
+
export declare function speechStream(transport: Transport, request: SpeechRequest, options: RequestOptions): SpeechStream;
|
|
37
|
+
/** `unit=quantity,unit=quantity`, each unit once. */
|
|
38
|
+
export declare function parseUsageHeader(header: string | null, requestId: string): Usage;
|
|
39
|
+
export declare function transcribe(transport: Transport, request: TranscriptionRequest, options: RequestOptions): Promise<Transcription>;
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/** `POST /v1/audio/speech` (whole and streamed) and `POST /v1/audio/transcriptions`. */
|
|
2
|
+
import { audioInputMediaTypeSchema, KAANA_MODEL_HEADER, KAANA_USAGE_HEADER, MAX_INFERENCE_AUDIO_BYTES, MAX_TRANSCRIPTION_FILE_BYTES, modelReferenceSchema, STREAMABLE_SPEECH_FORMATS, speechMediaType, speechRequestSchema, speechStreamEventSchema, transcriptionJsonRequestSchema, transcriptionRequestSchema, transcriptionSchema, usageSchema, } from '@kaana.ai/contracts';
|
|
3
|
+
import { concatBytes, decodeBase64, encodeBase64 } from './base64.js';
|
|
4
|
+
import { clientRefusal, fromPublicError, KaanaProtocolError } from './errors.js';
|
|
5
|
+
import { cutOff, frameJson, readFrames, streamingBody } from './events.js';
|
|
6
|
+
import { bodyReadFailure, mediaTypeOf, validate, } from './http.js';
|
|
7
|
+
import { deferred, StreamHandle } from './stream.js';
|
|
8
|
+
/* -------------------------------------------------------------------------- */
|
|
9
|
+
/* Speech */
|
|
10
|
+
/* -------------------------------------------------------------------------- */
|
|
11
|
+
export async function speech(transport, request, options) {
|
|
12
|
+
if (request.stream === true || request.stream_format !== undefined) {
|
|
13
|
+
throw clientRefusal('stream', 'audio.speech returns whole audio; use audio.speechStream');
|
|
14
|
+
}
|
|
15
|
+
const body = checked(request);
|
|
16
|
+
const sent = await transport.send({
|
|
17
|
+
method: 'POST',
|
|
18
|
+
path: '/audio/speech',
|
|
19
|
+
body,
|
|
20
|
+
accept: 'audio/*',
|
|
21
|
+
options,
|
|
22
|
+
});
|
|
23
|
+
const requestId = sent.requestId;
|
|
24
|
+
const mediaType = speechMediaType(request.response_format ?? 'mp3');
|
|
25
|
+
try {
|
|
26
|
+
if (mediaTypeOf(sent.response) !== mediaType) {
|
|
27
|
+
throw new KaanaProtocolError(`Speech in ${request.response_format ?? 'mp3'} was answered with ${mediaTypeOf(sent.response) ?? 'no Content-Type'}.`, requestId);
|
|
28
|
+
}
|
|
29
|
+
const model = validate(sent.response.headers.get(KAANA_MODEL_HEADER), modelReferenceSchema, requestId, `${KAANA_MODEL_HEADER} header`);
|
|
30
|
+
const usage = parseUsageHeader(sent.response.headers.get(KAANA_USAGE_HEADER), requestId);
|
|
31
|
+
const audio = await readAudio(sent);
|
|
32
|
+
if (audio.byteLength === 0) {
|
|
33
|
+
throw new KaanaProtocolError('The speech response carries no audio.', requestId);
|
|
34
|
+
}
|
|
35
|
+
return { audio, mediaType, model, requestId, usage };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
sent.abort();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
sent.release();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function speechStream(transport, request, options) {
|
|
46
|
+
const format = request.response_format ?? 'mp3';
|
|
47
|
+
if (!STREAMABLE_SPEECH_FORMATS.includes(format)) {
|
|
48
|
+
throw clientRefusal('response_format', `streamed speech is ${STREAMABLE_SPEECH_FORMATS.join(' or ')}; ${format} exists only whole`);
|
|
49
|
+
}
|
|
50
|
+
const body = checked({ ...request, stream: true, stream_format: 'sse' });
|
|
51
|
+
const mediaType = speechMediaType(format);
|
|
52
|
+
const handle = new StreamHandle(async function* (context) {
|
|
53
|
+
const sent = await transport.send({
|
|
54
|
+
method: 'POST',
|
|
55
|
+
path: '/audio/speech',
|
|
56
|
+
body,
|
|
57
|
+
accept: 'text/event-stream',
|
|
58
|
+
options: { ...options, signal: context.signal },
|
|
59
|
+
stream: true,
|
|
60
|
+
});
|
|
61
|
+
const requestId = sent.requestId;
|
|
62
|
+
context.setRequestId(requestId);
|
|
63
|
+
let output = false;
|
|
64
|
+
for await (const frame of readFrames(sent, () => output)) {
|
|
65
|
+
const event = validate(frameJson(frame, requestId), speechStreamEventSchema, requestId, 'speech event');
|
|
66
|
+
if (frame.event !== event.type) {
|
|
67
|
+
throw new KaanaProtocolError(`The stream framed a ${event.type} event as ${frame.event || 'unnamed'}.`, requestId);
|
|
68
|
+
}
|
|
69
|
+
switch (event.type) {
|
|
70
|
+
case 'speech.audio.delta':
|
|
71
|
+
output = true;
|
|
72
|
+
context.markOutput();
|
|
73
|
+
yield decodeBase64(event.audio);
|
|
74
|
+
break;
|
|
75
|
+
case 'speech.audio.done':
|
|
76
|
+
if (event.requestId !== requestId) {
|
|
77
|
+
throw new KaanaProtocolError('The stream ended for another request.', requestId);
|
|
78
|
+
}
|
|
79
|
+
return { mediaType, model: event.model, requestId, usage: event.usage };
|
|
80
|
+
case 'error':
|
|
81
|
+
throw fromPublicError(event.error, null, output);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw cutOff(requestId);
|
|
85
|
+
}, options.signal);
|
|
86
|
+
const mediaTypePromise = deferred();
|
|
87
|
+
mediaTypePromise.resolve(mediaType);
|
|
88
|
+
return {
|
|
89
|
+
mediaType: mediaTypePromise.promise,
|
|
90
|
+
get requestId() {
|
|
91
|
+
return handle.requestId;
|
|
92
|
+
},
|
|
93
|
+
get done() {
|
|
94
|
+
return handle.final;
|
|
95
|
+
},
|
|
96
|
+
abort: (reason) => handle.abort(reason),
|
|
97
|
+
[Symbol.asyncIterator]: () => handle[Symbol.asyncIterator](),
|
|
98
|
+
toReadableStream() {
|
|
99
|
+
const iterator = handle[Symbol.asyncIterator]();
|
|
100
|
+
return new ReadableStream({
|
|
101
|
+
async pull(controller) {
|
|
102
|
+
try {
|
|
103
|
+
const next = await iterator.next();
|
|
104
|
+
if (next.done)
|
|
105
|
+
controller.close();
|
|
106
|
+
else
|
|
107
|
+
controller.enqueue(next.value);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
controller.error(error);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
async cancel() {
|
|
114
|
+
await iterator.return?.();
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function checked(request) {
|
|
121
|
+
const parsed = speechRequestSchema.safeParse(request);
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
const issue = parsed.error.issues[0];
|
|
124
|
+
throw clientRefusal(String(issue?.path[0] ?? 'request'), issue?.message ?? 'invalid request');
|
|
125
|
+
}
|
|
126
|
+
return JSON.stringify(request);
|
|
127
|
+
}
|
|
128
|
+
/** `unit=quantity,unit=quantity`, each unit once. */
|
|
129
|
+
export function parseUsageHeader(header, requestId) {
|
|
130
|
+
if (header === null) {
|
|
131
|
+
throw new KaanaProtocolError(`The response carries no ${KAANA_USAGE_HEADER}.`, requestId);
|
|
132
|
+
}
|
|
133
|
+
const units = header.trim().length === 0
|
|
134
|
+
? []
|
|
135
|
+
: header.split(',').map((pair) => {
|
|
136
|
+
const [unit, quantity, extra] = pair.trim().split('=');
|
|
137
|
+
if (extra !== undefined || quantity === undefined || !/^\d+$/.test(quantity)) {
|
|
138
|
+
throw new KaanaProtocolError(`${KAANA_USAGE_HEADER} is malformed.`, requestId);
|
|
139
|
+
}
|
|
140
|
+
return { unit, quantity: Number(quantity) };
|
|
141
|
+
});
|
|
142
|
+
return validate(units, usageSchema, requestId, `${KAANA_USAGE_HEADER} header`);
|
|
143
|
+
}
|
|
144
|
+
async function readAudio(sent) {
|
|
145
|
+
const body = sent.response.body;
|
|
146
|
+
if (body === null || typeof body.getReader !== 'function') {
|
|
147
|
+
// Runtimes without a streaming body still give the whole buffer.
|
|
148
|
+
let buffer;
|
|
149
|
+
try {
|
|
150
|
+
buffer = await sent.response.arrayBuffer();
|
|
151
|
+
}
|
|
152
|
+
catch (cause) {
|
|
153
|
+
throw bodyReadFailure(sent, cause);
|
|
154
|
+
}
|
|
155
|
+
if (buffer.byteLength > MAX_INFERENCE_AUDIO_BYTES) {
|
|
156
|
+
throw new KaanaProtocolError('The speech audio exceeds its size limit.', sent.requestId);
|
|
157
|
+
}
|
|
158
|
+
return new Uint8Array(buffer);
|
|
159
|
+
}
|
|
160
|
+
const reader = streamingBody(sent).getReader();
|
|
161
|
+
const chunks = [];
|
|
162
|
+
let size = 0;
|
|
163
|
+
let completed = false;
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
let next;
|
|
167
|
+
try {
|
|
168
|
+
next = await reader.read();
|
|
169
|
+
}
|
|
170
|
+
catch (cause) {
|
|
171
|
+
throw bodyReadFailure(sent, cause);
|
|
172
|
+
}
|
|
173
|
+
if (next.done) {
|
|
174
|
+
completed = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
size += next.value.byteLength;
|
|
178
|
+
if (size > MAX_INFERENCE_AUDIO_BYTES) {
|
|
179
|
+
throw new KaanaProtocolError('The speech audio exceeds its size limit.', sent.requestId);
|
|
180
|
+
}
|
|
181
|
+
chunks.push(next.value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
if (!completed)
|
|
186
|
+
await reader.cancel().catch(() => undefined);
|
|
187
|
+
reader.releaseLock();
|
|
188
|
+
}
|
|
189
|
+
return concatBytes(chunks);
|
|
190
|
+
}
|
|
191
|
+
/* -------------------------------------------------------------------------- */
|
|
192
|
+
/* Transcription */
|
|
193
|
+
/* -------------------------------------------------------------------------- */
|
|
194
|
+
const EXTENSIONS = {
|
|
195
|
+
'audio/mpeg': 'mp3',
|
|
196
|
+
'audio/mp4': 'm4a',
|
|
197
|
+
'audio/wav': 'wav',
|
|
198
|
+
'audio/ogg': 'ogg',
|
|
199
|
+
'audio/webm': 'webm',
|
|
200
|
+
'audio/flac': 'flac',
|
|
201
|
+
'audio/aac': 'aac',
|
|
202
|
+
'audio/pcm': 'pcm',
|
|
203
|
+
};
|
|
204
|
+
export async function transcribe(transport, request, options) {
|
|
205
|
+
const fields = {
|
|
206
|
+
model: request.model,
|
|
207
|
+
...(request.language === undefined ? {} : { language: request.language }),
|
|
208
|
+
...(request.prompt === undefined ? {} : { prompt: request.prompt }),
|
|
209
|
+
...(request.timestamps === undefined ? {} : { timestamps: request.timestamps }),
|
|
210
|
+
};
|
|
211
|
+
const parsedFields = transcriptionRequestSchema.safeParse(fields);
|
|
212
|
+
if (!parsedFields.success) {
|
|
213
|
+
const issue = parsedFields.error.issues[0];
|
|
214
|
+
throw clientRefusal(String(issue?.path[0] ?? 'request'), issue?.message ?? 'invalid request');
|
|
215
|
+
}
|
|
216
|
+
if (!audioInputMediaTypeSchema.safeParse(request.mediaType).success) {
|
|
217
|
+
throw clientRefusal('mediaType', `${String(request.mediaType)} is not an accepted audio type`);
|
|
218
|
+
}
|
|
219
|
+
const isBlob = typeof Blob !== 'undefined' && request.file instanceof Blob;
|
|
220
|
+
const size = isBlob ? request.file.size : request.file.byteLength;
|
|
221
|
+
if (size === 0)
|
|
222
|
+
throw clientRefusal('file', 'the audio file is empty');
|
|
223
|
+
if (size > MAX_TRANSCRIPTION_FILE_BYTES) {
|
|
224
|
+
throw clientRefusal('file', `the audio file exceeds ${MAX_TRANSCRIPTION_FILE_BYTES} bytes`);
|
|
225
|
+
}
|
|
226
|
+
let body;
|
|
227
|
+
if (isBlob) {
|
|
228
|
+
const blob = request.file;
|
|
229
|
+
const part = blob.type === request.mediaType ? blob : new Blob([blob], { type: request.mediaType });
|
|
230
|
+
const form = new FormData();
|
|
231
|
+
form.append('file', part, `audio.${EXTENSIONS[request.mediaType]}`);
|
|
232
|
+
for (const [name, value] of Object.entries(fields))
|
|
233
|
+
form.append(name, value);
|
|
234
|
+
body = form;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
const json = {
|
|
238
|
+
...fields,
|
|
239
|
+
audio: { mediaType: request.mediaType, data: encodeBase64(request.file) },
|
|
240
|
+
};
|
|
241
|
+
const parsedJson = transcriptionJsonRequestSchema.safeParse(json);
|
|
242
|
+
if (!parsedJson.success) {
|
|
243
|
+
const issue = parsedJson.error.issues[0];
|
|
244
|
+
throw clientRefusal(issue?.path.map(String).join('.') || 'file', issue?.message ?? 'invalid');
|
|
245
|
+
}
|
|
246
|
+
body = JSON.stringify(json);
|
|
247
|
+
}
|
|
248
|
+
const { value } = await transport.json({ method: 'POST', path: '/audio/transcriptions', body, accept: 'application/json', options }, transcriptionSchema);
|
|
249
|
+
return value;
|
|
250
|
+
}
|
package/dist/base64.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 without a platform module: no `Buffer`, no `btoa`/`atob` (which are
|
|
3
|
+
* Latin-1 string APIs and absent from some React Native runtimes). The SDK
|
|
4
|
+
* encodes audio frames and credentials with these and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
/** Standard padded base64 (RFC 4648 §4). */
|
|
7
|
+
export declare function encodeBase64(bytes: Uint8Array): string;
|
|
8
|
+
/** Unpadded base64url (RFC 4648 §5), the form a WebSocket subprotocol token can carry. */
|
|
9
|
+
export declare function encodeBase64Url(bytes: Uint8Array): string;
|
|
10
|
+
/**
|
|
11
|
+
* Decode standard or url-safe base64, padded or not. Throws on any character
|
|
12
|
+
* outside the alphabet or an impossible length, never silently skipping.
|
|
13
|
+
*/
|
|
14
|
+
export declare function decodeBase64(text: string): Uint8Array;
|
|
15
|
+
/** UTF-8 bytes of a string. */
|
|
16
|
+
export declare function utf8(text: string): Uint8Array;
|
|
17
|
+
/** Concatenate byte chunks. */
|
|
18
|
+
export declare function concatBytes(chunks: readonly Uint8Array[]): Uint8Array;
|
package/dist/base64.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 without a platform module: no `Buffer`, no `btoa`/`atob` (which are
|
|
3
|
+
* Latin-1 string APIs and absent from some React Native runtimes). The SDK
|
|
4
|
+
* encodes audio frames and credentials with these and nothing else.
|
|
5
|
+
*/
|
|
6
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
7
|
+
const DECODE = (() => {
|
|
8
|
+
const table = new Int16Array(128).fill(-1);
|
|
9
|
+
for (let index = 0; index < ALPHABET.length; index += 1) {
|
|
10
|
+
table[ALPHABET.charCodeAt(index)] = index;
|
|
11
|
+
}
|
|
12
|
+
// base64url spellings decode to the same values.
|
|
13
|
+
table['-'.charCodeAt(0)] = 62;
|
|
14
|
+
table['_'.charCodeAt(0)] = 63;
|
|
15
|
+
return table;
|
|
16
|
+
})();
|
|
17
|
+
/** Standard padded base64 (RFC 4648 §4). */
|
|
18
|
+
export function encodeBase64(bytes) {
|
|
19
|
+
let out = '';
|
|
20
|
+
let index = 0;
|
|
21
|
+
for (; index + 2 < bytes.length; index += 3) {
|
|
22
|
+
const chunk = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
|
|
23
|
+
out +=
|
|
24
|
+
ALPHABET[(chunk >> 18) & 63] +
|
|
25
|
+
ALPHABET[(chunk >> 12) & 63] +
|
|
26
|
+
ALPHABET[(chunk >> 6) & 63] +
|
|
27
|
+
ALPHABET[chunk & 63];
|
|
28
|
+
}
|
|
29
|
+
const rest = bytes.length - index;
|
|
30
|
+
if (rest === 1) {
|
|
31
|
+
const chunk = bytes[index] << 16;
|
|
32
|
+
out += `${ALPHABET[(chunk >> 18) & 63]}${ALPHABET[(chunk >> 12) & 63]}==`;
|
|
33
|
+
}
|
|
34
|
+
else if (rest === 2) {
|
|
35
|
+
const chunk = (bytes[index] << 16) | (bytes[index + 1] << 8);
|
|
36
|
+
out += `${ALPHABET[(chunk >> 18) & 63]}${ALPHABET[(chunk >> 12) & 63]}${ALPHABET[(chunk >> 6) & 63]}=`;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/** Unpadded base64url (RFC 4648 §5), the form a WebSocket subprotocol token can carry. */
|
|
41
|
+
export function encodeBase64Url(bytes) {
|
|
42
|
+
return encodeBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decode standard or url-safe base64, padded or not. Throws on any character
|
|
46
|
+
* outside the alphabet or an impossible length, never silently skipping.
|
|
47
|
+
*/
|
|
48
|
+
export function decodeBase64(text) {
|
|
49
|
+
const trimmed = text.replace(/=+$/, '');
|
|
50
|
+
if (trimmed.length % 4 === 1)
|
|
51
|
+
throw new Error('base64 has an impossible length');
|
|
52
|
+
const out = new Uint8Array(Math.floor((trimmed.length * 3) / 4));
|
|
53
|
+
let buffer = 0;
|
|
54
|
+
let bits = 0;
|
|
55
|
+
let position = 0;
|
|
56
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
57
|
+
const code = trimmed.charCodeAt(index);
|
|
58
|
+
const value = code < 128 ? DECODE[code] : -1;
|
|
59
|
+
if (value < 0)
|
|
60
|
+
throw new Error('base64 contains a character outside its alphabet');
|
|
61
|
+
buffer = (buffer << 6) | value;
|
|
62
|
+
bits += 6;
|
|
63
|
+
if (bits >= 8) {
|
|
64
|
+
bits -= 8;
|
|
65
|
+
out[position] = (buffer >> bits) & 0xff;
|
|
66
|
+
position += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** UTF-8 bytes of a string. */
|
|
72
|
+
export function utf8(text) {
|
|
73
|
+
return new TextEncoder().encode(text);
|
|
74
|
+
}
|
|
75
|
+
/** Concatenate byte chunks. */
|
|
76
|
+
export function concatBytes(chunks) {
|
|
77
|
+
let size = 0;
|
|
78
|
+
for (const chunk of chunks)
|
|
79
|
+
size += chunk.byteLength;
|
|
80
|
+
const out = new Uint8Array(size);
|
|
81
|
+
let offset = 0;
|
|
82
|
+
for (const chunk of chunks) {
|
|
83
|
+
out.set(chunk, offset);
|
|
84
|
+
offset += chunk.byteLength;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `POST /v1/chat/completions`, the OpenAI dialect: whole and streamed. */
|
|
2
|
+
import { type ChatCompletion, type ChatCompletionChunk } from '@kaana.ai/contracts';
|
|
3
|
+
import { type RequestOptions, type Transport } from './http.js';
|
|
4
|
+
import { type KaanaStream } from './stream.js';
|
|
5
|
+
export declare function chatStream(transport: Transport, body: string, options: RequestOptions): KaanaStream<ChatCompletionChunk, ChatCompletion>;
|
|
6
|
+
/** Folds the chunks of one completion into the `ChatCompletion` it describes. */
|
|
7
|
+
export declare class ChatFold {
|
|
8
|
+
#private;
|
|
9
|
+
add(chunk: ChatCompletionChunk): void;
|
|
10
|
+
completion(requestId: string): ChatCompletion;
|
|
11
|
+
}
|