@effect-uai/mistral 0.8.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 +21 -0
- package/README.md +76 -0
- package/dist/Mistral.d.mts +48 -0
- package/dist/Mistral.d.mts.map +1 -0
- package/dist/Mistral.mjs +88 -0
- package/dist/Mistral.mjs.map +1 -0
- package/dist/MistralRealtimeTranscriber.d.mts +15 -0
- package/dist/MistralRealtimeTranscriber.d.mts.map +1 -0
- package/dist/MistralRealtimeTranscriber.mjs +34 -0
- package/dist/MistralRealtimeTranscriber.mjs.map +1 -0
- package/dist/MistralSynthesizer.d.mts +43 -0
- package/dist/MistralSynthesizer.d.mts.map +1 -0
- package/dist/MistralSynthesizer.mjs +121 -0
- package/dist/MistralSynthesizer.mjs.map +1 -0
- package/dist/MistralTranscriber.d.mts +44 -0
- package/dist/MistralTranscriber.d.mts.map +1 -0
- package/dist/MistralTranscriber.mjs +111 -0
- package/dist/MistralTranscriber.mjs.map +1 -0
- package/dist/audioCodec.d.mts +27 -0
- package/dist/audioCodec.d.mts.map +1 -0
- package/dist/audioCodec.mjs +82 -0
- package/dist/audioCodec.mjs.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/codec.d.mts +100 -0
- package/dist/codec.d.mts.map +1 -0
- package/dist/codec.mjs +244 -0
- package/dist/codec.mjs.map +1 -0
- package/dist/codec.test.d.mts +1 -0
- package/dist/codec.test.mjs +13800 -0
- package/dist/codec.test.mjs.map +1 -0
- package/dist/http.d.mts +9 -0
- package/dist/http.d.mts.map +1 -0
- package/dist/http.mjs +41 -0
- package/dist/http.mjs.map +1 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/magic-string.es-PAZgLX4h.mjs +1013 -0
- package/dist/magic-string.es-PAZgLX4h.mjs.map +1 -0
- package/dist/models-DnDmylpj.d.mts +22 -0
- package/dist/models-DnDmylpj.d.mts.map +1 -0
- package/dist/models.d.mts +2 -0
- package/dist/models.mjs +1 -0
- package/dist/realtimeStt-ClXWXbXX.d.mts +21 -0
- package/dist/realtimeStt-ClXWXbXX.d.mts.map +1 -0
- package/dist/realtimeStt.d.mts +2 -0
- package/dist/realtimeStt.mjs +136 -0
- package/dist/realtimeStt.mjs.map +1 -0
- package/package.json +82 -0
- package/src/Mistral.ts +189 -0
- package/src/MistralRealtimeTranscriber.ts +60 -0
- package/src/MistralSynthesizer.ts +285 -0
- package/src/MistralTranscriber.ts +224 -0
- package/src/audioCodec.ts +123 -0
- package/src/codec.test.ts +121 -0
- package/src/codec.ts +338 -0
- package/src/http.ts +46 -0
- package/src/index.ts +6 -0
- package/src/models.ts +47 -0
- package/src/realtimeStt.ts +256 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Array as Arr, Context, Effect, Layer, Redacted, Schema, Stream } from "effect"
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
3
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
4
|
+
import * as Capabilities from "@effect-uai/core/Capabilities"
|
|
5
|
+
import { isAudioUrl } from "@effect-uai/core/Audio"
|
|
6
|
+
import type { TranscriptEvent, TranscriptResult, WordTimestamp } from "@effect-uai/core/Transcript"
|
|
7
|
+
import {
|
|
8
|
+
type CommonStreamTranscribeRequest,
|
|
9
|
+
type CommonTranscribeRequest,
|
|
10
|
+
Transcriber,
|
|
11
|
+
type TranscriberService,
|
|
12
|
+
} from "@effect-uai/core/Transcriber"
|
|
13
|
+
import { audioToBlob, defaultFileName } from "./audioCodec.js"
|
|
14
|
+
import { httpStatusError, transportFailure } from "./http.js"
|
|
15
|
+
import type { MistralTranscribeModel } from "./models.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Public types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mistral-typed transcribe request. `model` narrows to the typed
|
|
23
|
+
* `MistralTranscribeModel` union; `fileName` overrides the multipart upload's
|
|
24
|
+
* filename. Voxtral's batch endpoint supports diarization, word timestamps,
|
|
25
|
+
* and context biasing.
|
|
26
|
+
*/
|
|
27
|
+
export type MistralTranscribeRequest = Omit<CommonTranscribeRequest, "model"> & {
|
|
28
|
+
readonly model: MistralTranscribeModel
|
|
29
|
+
readonly temperature?: number
|
|
30
|
+
readonly fileName?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MistralTranscriberService = {
|
|
34
|
+
readonly transcribe: (
|
|
35
|
+
request: MistralTranscribeRequest,
|
|
36
|
+
) => Effect.Effect<TranscriptResult, AiError.AiError>
|
|
37
|
+
readonly streamTranscriptionFrom: <E, R>(
|
|
38
|
+
audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
39
|
+
request: CommonStreamTranscribeRequest,
|
|
40
|
+
) => Stream.Stream<TranscriptEvent, AiError.AiError | E, R>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class MistralTranscriber extends Context.Service<
|
|
44
|
+
MistralTranscriber,
|
|
45
|
+
MistralTranscriberService
|
|
46
|
+
>()("@betalyra/effect-uai/providers/mistral/MistralTranscriber") {}
|
|
47
|
+
|
|
48
|
+
export type Config = {
|
|
49
|
+
readonly apiKey: Redacted.Redacted
|
|
50
|
+
readonly baseUrl?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Codec — request → FormData
|
|
55
|
+
//
|
|
56
|
+
// Each field is described as a data `Part`, then the whole list is folded into
|
|
57
|
+
// one FormData. A `Part` is either a scalar field or a file field (with an
|
|
58
|
+
// upload filename).
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
type Part =
|
|
62
|
+
| readonly [name: string, value: string]
|
|
63
|
+
| readonly [name: string, file: Blob, filename: string]
|
|
64
|
+
|
|
65
|
+
const when = (condition: boolean, part: Part): ReadonlyArray<Part> => (condition ? [part] : [])
|
|
66
|
+
|
|
67
|
+
const appendPart = (fd: FormData, part: Part): FormData => {
|
|
68
|
+
if (part.length === 3) fd.append(part[0], part[1], part[2])
|
|
69
|
+
else fd.append(part[0], part[1])
|
|
70
|
+
return fd
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buildFormData = (
|
|
74
|
+
request: MistralTranscribeRequest,
|
|
75
|
+
): Effect.Effect<FormData, AiError.AiError> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
// Voxtral transcription has no Whisper-style prose `prompt` field.
|
|
78
|
+
yield* Capabilities.warnDroppedWhen(request.prompt, {
|
|
79
|
+
provider: "mistral",
|
|
80
|
+
capability: "prompt",
|
|
81
|
+
field: "prompt",
|
|
82
|
+
reason:
|
|
83
|
+
"Voxtral transcription has no prose prompt field; use `biasingTerms` for vocabulary hints.",
|
|
84
|
+
})
|
|
85
|
+
// URL audio rides the `file_url` field; inline bytes/base64 upload directly.
|
|
86
|
+
const filePart: Part = isAudioUrl(request.audio)
|
|
87
|
+
? ["file_url", request.audio.url]
|
|
88
|
+
: yield* audioToBlob(request.audio).pipe(
|
|
89
|
+
Effect.map(
|
|
90
|
+
(blob): Part => ["file", blob, request.fileName ?? defaultFileName(blob.type)],
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
const parts: ReadonlyArray<Part> = [
|
|
94
|
+
filePart,
|
|
95
|
+
["model", request.model],
|
|
96
|
+
...when(request.language !== undefined, ["language", request.language ?? ""]),
|
|
97
|
+
...when(request.diarization === true, ["diarize", "true"]),
|
|
98
|
+
...when(request.wordTimestamps === true, ["timestamp_granularities[]", "word"]),
|
|
99
|
+
...when(request.temperature !== undefined, ["temperature", String(request.temperature)]),
|
|
100
|
+
// Voxtral biases on a `context_bias` term list; one entry per term.
|
|
101
|
+
...(request.biasingTerms ?? []).map((term): Part => ["context_bias", term]),
|
|
102
|
+
]
|
|
103
|
+
return Arr.reduce(parts, new FormData(), appendPart)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Codec — response → TranscriptResult
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const WireWord = Schema.Struct({
|
|
111
|
+
text: Schema.optional(Schema.String),
|
|
112
|
+
word: Schema.optional(Schema.String),
|
|
113
|
+
start: Schema.Number,
|
|
114
|
+
end: Schema.Number,
|
|
115
|
+
speaker: Schema.optional(Schema.NullOr(Schema.String)),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const WireResponse = Schema.Struct({
|
|
119
|
+
text: Schema.String,
|
|
120
|
+
language: Schema.optional(Schema.NullOr(Schema.String)),
|
|
121
|
+
words: Schema.optional(Schema.NullOr(Schema.Array(WireWord))),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const decodeResponse = Schema.decodeUnknownEffect(WireResponse)
|
|
125
|
+
|
|
126
|
+
const wireWordToCommon = (w: typeof WireWord.Type): WordTimestamp => ({
|
|
127
|
+
text: w.text ?? w.word ?? "",
|
|
128
|
+
startSeconds: w.start,
|
|
129
|
+
endSeconds: w.end,
|
|
130
|
+
...(w.speaker != null && { speakerId: w.speaker }),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const toResult = (raw: unknown, decoded: typeof WireResponse.Type): TranscriptResult => ({
|
|
134
|
+
text: decoded.text,
|
|
135
|
+
...(decoded.language != null && { languageCode: decoded.language }),
|
|
136
|
+
...(decoded.words != null && { words: decoded.words.map(wireWordToCommon) }),
|
|
137
|
+
raw,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// HTTP plumbing
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** Exported for reuse by `MistralRealtimeTranscriber` (same sync path). */
|
|
145
|
+
export const transcribeImpl =
|
|
146
|
+
(cfg: Config) =>
|
|
147
|
+
(
|
|
148
|
+
request: MistralTranscribeRequest,
|
|
149
|
+
): Effect.Effect<TranscriptResult, AiError.AiError, HttpClient.HttpClient> =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const client = yield* HttpClient.HttpClient
|
|
152
|
+
const baseUrl = cfg.baseUrl ?? "https://api.mistral.ai"
|
|
153
|
+
const formData = yield* buildFormData(request)
|
|
154
|
+
const httpRequest = HttpClientRequest.post(`${baseUrl}/v1/audio/transcriptions`).pipe(
|
|
155
|
+
HttpClientRequest.bearerToken(cfg.apiKey),
|
|
156
|
+
HttpClientRequest.bodyFormData(formData),
|
|
157
|
+
)
|
|
158
|
+
const response = yield* client.execute(httpRequest).pipe(Effect.mapError(transportFailure))
|
|
159
|
+
if (response.status >= 400) {
|
|
160
|
+
const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
|
|
161
|
+
return yield* Effect.fail(httpStatusError(response.status, text))
|
|
162
|
+
}
|
|
163
|
+
const json = yield* response.json.pipe(Effect.mapError(transportFailure))
|
|
164
|
+
const decoded = yield* decodeResponse(json).pipe(Effect.mapError(transportFailure))
|
|
165
|
+
return toResult(json, decoded)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// streamTranscriptionFrom — Unsupported on the sync-only Layer
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Sync-only Layer's streaming impl. For live transcription import the
|
|
174
|
+
* `MistralRealtimeTranscriber` subpath, which registers `SttStreaming` and
|
|
175
|
+
* wires the Voxtral Realtime WebSocket endpoint.
|
|
176
|
+
*/
|
|
177
|
+
const streamUnsupported = <E, R>(
|
|
178
|
+
_audioIn: Stream.Stream<Uint8Array, E, R>,
|
|
179
|
+
_request: CommonStreamTranscribeRequest,
|
|
180
|
+
): Stream.Stream<TranscriptEvent, AiError.AiError | E, R> =>
|
|
181
|
+
Stream.fail(
|
|
182
|
+
new AiError.Unsupported({
|
|
183
|
+
provider: "mistral",
|
|
184
|
+
capability: "streamTranscriptionFrom",
|
|
185
|
+
reason:
|
|
186
|
+
"This Layer is sync-only. Use `@effect-uai/mistral/MistralRealtimeTranscriber` for live Voxtral transcription.",
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Constructors
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export const make = (
|
|
195
|
+
cfg: Config,
|
|
196
|
+
): Effect.Effect<MistralTranscriberService, never, HttpClient.HttpClient> =>
|
|
197
|
+
Effect.map(HttpClient.HttpClient.asEffect(), (client) => ({
|
|
198
|
+
transcribe: (request) =>
|
|
199
|
+
transcribeImpl(cfg)(request).pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
200
|
+
streamTranscriptionFrom: streamUnsupported,
|
|
201
|
+
}))
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sync-only Layer. Registers `MistralTranscriber` + the generic `Transcriber`
|
|
205
|
+
* tag. Does NOT register `SttStreaming` — for live transcription use
|
|
206
|
+
* `@effect-uai/mistral/MistralRealtimeTranscriber`.
|
|
207
|
+
*/
|
|
208
|
+
export const layer = (
|
|
209
|
+
cfg: Config,
|
|
210
|
+
): Layer.Layer<MistralTranscriber | Transcriber, never, HttpClient.HttpClient> =>
|
|
211
|
+
Layer.merge(
|
|
212
|
+
Layer.effect(MistralTranscriber, make(cfg)),
|
|
213
|
+
Layer.effect(
|
|
214
|
+
Transcriber,
|
|
215
|
+
Effect.map(
|
|
216
|
+
make(cfg),
|
|
217
|
+
(s): TranscriberService => ({
|
|
218
|
+
transcribe: (req: CommonTranscribeRequest) =>
|
|
219
|
+
s.transcribe(req as MistralTranscribeRequest),
|
|
220
|
+
streamTranscriptionFrom: s.streamTranscriptionFrom,
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Array as Arr, Effect, Encoding, Match, Option, Result } from "effect"
|
|
2
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
3
|
+
import type { AudioFormat, AudioSource } from "@effect-uai/core/Audio"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// AudioSource → Blob (for multipart upload to /audio/transcriptions)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const decodeBase64ToBytes = (b64: string): Effect.Effect<Uint8Array, AiError.AiError> =>
|
|
10
|
+
Result.match(Encoding.decodeBase64(b64), {
|
|
11
|
+
onSuccess: Effect.succeed,
|
|
12
|
+
onFailure: (cause) =>
|
|
13
|
+
Effect.fail(new AiError.InvalidRequest({ provider: "mistral", param: "audio", raw: cause })),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* TS 6's `Blob` constructor wants `Uint8Array<ArrayBuffer>`; our domain carries
|
|
18
|
+
* the broader `Uint8Array<ArrayBufferLike>`. The cast is sound: `Blob()` does
|
|
19
|
+
* not mutate the buffer and the runtime accepts both.
|
|
20
|
+
*/
|
|
21
|
+
const bytesToBlob = (bytes: Uint8Array, mimeType: string): Blob =>
|
|
22
|
+
new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mimeType })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build a `Blob` from any `AudioSource` variant. `url` variants pass through
|
|
26
|
+
* to the `file_url` field upstream, so this only handles inline bytes/base64.
|
|
27
|
+
*/
|
|
28
|
+
export const audioToBlob: (audio: AudioSource) => Effect.Effect<Blob, AiError.AiError> =
|
|
29
|
+
Match.type<AudioSource>().pipe(
|
|
30
|
+
Match.tag("bytes", (a) => Effect.succeed(bytesToBlob(a.bytes, a.mimeType))),
|
|
31
|
+
Match.tag("base64", (a) =>
|
|
32
|
+
decodeBase64ToBytes(a.base64).pipe(Effect.map((bytes) => bytesToBlob(bytes, a.mimeType))),
|
|
33
|
+
),
|
|
34
|
+
Match.tag("url", (a) =>
|
|
35
|
+
Effect.fail(
|
|
36
|
+
new AiError.InvalidRequest({
|
|
37
|
+
provider: "mistral",
|
|
38
|
+
param: "audio",
|
|
39
|
+
raw: `Pass URL audio via the request's url source; got ${a.url} where inline bytes were expected.`,
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
Match.exhaustive,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const EXTENSION_BY_MIME: ReadonlyArray<readonly [pattern: string, ext: string]> = [
|
|
47
|
+
["mpeg", "mp3"],
|
|
48
|
+
["mp3", "mp3"],
|
|
49
|
+
["wav", "wav"],
|
|
50
|
+
["ogg", "ogg"],
|
|
51
|
+
["opus", "ogg"],
|
|
52
|
+
["flac", "flac"],
|
|
53
|
+
["aac", "aac"],
|
|
54
|
+
["mp4", "m4a"],
|
|
55
|
+
["m4a", "m4a"],
|
|
56
|
+
["webm", "webm"],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
/** Derive a default upload filename from a MIME type, e.g. `audio/mpeg` -> `audio.mp3`. */
|
|
60
|
+
export const defaultFileName = (mimeType: string): string =>
|
|
61
|
+
Arr.findFirst(EXTENSION_BY_MIME, ([pattern]) => mimeType.includes(pattern)).pipe(
|
|
62
|
+
Option.match({ onNone: () => "audio", onSome: ([, ext]) => `audio.${ext}` }),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// AudioFormat → Voxtral TTS `response_format`
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export type MistralTtsFormat = "mp3" | "wav" | "pcm" | "flac" | "opus"
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Map an `AudioFormat.container` to Voxtral TTS's `response_format`. Voxtral
|
|
73
|
+
* produces mp3 / wav / pcm / flac / opus; aac / ogg / webm are unsupported.
|
|
74
|
+
*/
|
|
75
|
+
export const containerToTtsFormat: (
|
|
76
|
+
container: AudioFormat["container"],
|
|
77
|
+
) => Effect.Effect<MistralTtsFormat, AiError.AiError> = Match.type<AudioFormat["container"]>().pipe(
|
|
78
|
+
Match.when("mp3", () => Effect.succeed<MistralTtsFormat>("mp3")),
|
|
79
|
+
Match.when("wav", () => Effect.succeed<MistralTtsFormat>("wav")),
|
|
80
|
+
Match.when("raw", () => Effect.succeed<MistralTtsFormat>("pcm")),
|
|
81
|
+
Match.when("flac", () => Effect.succeed<MistralTtsFormat>("flac")),
|
|
82
|
+
Match.when("opus", () => Effect.succeed<MistralTtsFormat>("opus")),
|
|
83
|
+
Match.whenOr("aac", "ogg", "webm", (c) =>
|
|
84
|
+
Effect.fail(
|
|
85
|
+
new AiError.Unsupported({
|
|
86
|
+
provider: "mistral",
|
|
87
|
+
capability: "outputFormat",
|
|
88
|
+
reason: `Voxtral TTS does not produce ${c} output; supported: mp3 | wav | pcm (raw) | flac | opus.`,
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
Match.exhaustive,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The on-wire `AudioFormat` Voxtral actually returns for each
|
|
97
|
+
* `response_format`. Voxtral's `pcm` is float32 LE at 24 kHz; the caller's
|
|
98
|
+
* requested sample rate is not honoured for raw output.
|
|
99
|
+
*/
|
|
100
|
+
export const realizedTtsFormat: (rf: MistralTtsFormat) => AudioFormat =
|
|
101
|
+
Match.type<MistralTtsFormat>().pipe(
|
|
102
|
+
Match.when(
|
|
103
|
+
"mp3",
|
|
104
|
+
(): AudioFormat => ({ container: "mp3", encoding: "mp3", sampleRate: 24000 }),
|
|
105
|
+
),
|
|
106
|
+
Match.when(
|
|
107
|
+
"wav",
|
|
108
|
+
(): AudioFormat => ({ container: "wav", encoding: "pcm_s16le", sampleRate: 24000 }),
|
|
109
|
+
),
|
|
110
|
+
Match.when(
|
|
111
|
+
"pcm",
|
|
112
|
+
(): AudioFormat => ({ container: "raw", encoding: "pcm_f32le", sampleRate: 24000 }),
|
|
113
|
+
),
|
|
114
|
+
Match.when(
|
|
115
|
+
"flac",
|
|
116
|
+
(): AudioFormat => ({ container: "flac", encoding: "flac", sampleRate: 24000 }),
|
|
117
|
+
),
|
|
118
|
+
Match.when(
|
|
119
|
+
"opus",
|
|
120
|
+
(): AudioFormat => ({ container: "opus", encoding: "opus", sampleRate: 24000 }),
|
|
121
|
+
),
|
|
122
|
+
Match.exhaustive,
|
|
123
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { HistoryItem } from "@effect-uai/core/Items"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import {
|
|
4
|
+
type Accumulator,
|
|
5
|
+
accumulatorToTurn,
|
|
6
|
+
applyChunk,
|
|
7
|
+
emptyAccumulator,
|
|
8
|
+
itemsToMessages,
|
|
9
|
+
toolChoiceWire,
|
|
10
|
+
type WireChunk,
|
|
11
|
+
} from "./codec.js"
|
|
12
|
+
|
|
13
|
+
describe("itemsToMessages", () => {
|
|
14
|
+
// The interesting case: our flat history (assistant message, then separate
|
|
15
|
+
// function_call items) must fold into the OpenAI/Mistral shape where the
|
|
16
|
+
// tool calls ride the assistant message and each result is a `tool` message.
|
|
17
|
+
it("folds tool calls onto the assistant message and emits tool results", () => {
|
|
18
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
19
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "checking" }] },
|
|
20
|
+
{ type: "function_call", call_id: "c1", name: "a", arguments: "{}" },
|
|
21
|
+
{ type: "function_call", call_id: "c2", name: "b", arguments: "{}" },
|
|
22
|
+
{ type: "function_call_output", call_id: "c1", output: "ra" },
|
|
23
|
+
]
|
|
24
|
+
expect(itemsToMessages(history)).toEqual([
|
|
25
|
+
{
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: "checking",
|
|
28
|
+
tool_calls: [
|
|
29
|
+
{ id: "c1", type: "function", function: { name: "a", arguments: "{}" } },
|
|
30
|
+
{ id: "c2", type: "function", function: { name: "b", arguments: "{}" } },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{ role: "tool", tool_call_id: "c1", content: "ra" },
|
|
34
|
+
])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("encodes a message with an image as multimodal parts", () => {
|
|
38
|
+
const history: ReadonlyArray<HistoryItem> = [
|
|
39
|
+
{
|
|
40
|
+
type: "message",
|
|
41
|
+
role: "user",
|
|
42
|
+
content: [
|
|
43
|
+
{ type: "input_text", text: "what is this" },
|
|
44
|
+
{ type: "input_image", source: { _tag: "url", url: "https://x/y.png" } },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
expect(itemsToMessages(history)).toEqual([
|
|
49
|
+
{
|
|
50
|
+
role: "user",
|
|
51
|
+
content: [
|
|
52
|
+
{ type: "text", text: "what is this" },
|
|
53
|
+
{ type: "image_url", image_url: "https://x/y.png" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("toolChoiceWire maps 'required' to Mistral's 'any'", () => {
|
|
61
|
+
// Mistral diverges from OpenAI here; the rest pass through unchanged.
|
|
62
|
+
expect(toolChoiceWire("required")).toBe("any")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Run a sequence of chunks through the accumulator, collecting event tags.
|
|
66
|
+
const runChunks = (
|
|
67
|
+
chunks: ReadonlyArray<WireChunk>,
|
|
68
|
+
): { acc: Accumulator; tags: ReadonlyArray<string> } => {
|
|
69
|
+
let acc = emptyAccumulator
|
|
70
|
+
const tags: Array<string> = []
|
|
71
|
+
for (const chunk of chunks) {
|
|
72
|
+
const [next, events] = applyChunk(acc, chunk)
|
|
73
|
+
acc = next
|
|
74
|
+
for (const e of events) tags.push(e._tag)
|
|
75
|
+
}
|
|
76
|
+
return { acc, tags }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("streaming accumulation", () => {
|
|
80
|
+
it("accumulates text deltas + usage into an assistant turn", () => {
|
|
81
|
+
const { acc, tags } = runChunks([
|
|
82
|
+
{ choices: [{ delta: { content: "Hel" } }] },
|
|
83
|
+
{ choices: [{ delta: { content: "lo" } }] },
|
|
84
|
+
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
|
85
|
+
{ usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } },
|
|
86
|
+
])
|
|
87
|
+
expect(tags).toEqual(["TextDelta", "TextDelta", "UsageUpdate"])
|
|
88
|
+
const turn = accumulatorToTurn(acc)
|
|
89
|
+
expect(turn.items).toEqual([
|
|
90
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Hello" }] },
|
|
91
|
+
])
|
|
92
|
+
expect(turn.usage).toEqual({ input_tokens: 3, output_tokens: 2, total_tokens: 5 })
|
|
93
|
+
expect(turn.stop_reason).toBe("stop")
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("stitches a tool call split across chunks", () => {
|
|
97
|
+
const { acc, tags } = runChunks([
|
|
98
|
+
{
|
|
99
|
+
choices: [
|
|
100
|
+
{
|
|
101
|
+
delta: {
|
|
102
|
+
tool_calls: [
|
|
103
|
+
{ index: 0, id: "c1", function: { name: "get_weather", arguments: '{"ci' } },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: 'ty":"NYC"}' } }] } }],
|
|
111
|
+
},
|
|
112
|
+
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
|
113
|
+
])
|
|
114
|
+
expect(tags).toEqual(["ToolCallStart", "ToolCallArgsDelta", "ToolCallArgsDelta"])
|
|
115
|
+
const turn = accumulatorToTurn(acc)
|
|
116
|
+
expect(turn.items).toEqual([
|
|
117
|
+
{ type: "function_call", call_id: "c1", name: "get_weather", arguments: '{"city":"NYC"}' },
|
|
118
|
+
])
|
|
119
|
+
expect(turn.stop_reason).toBe("tool_calls")
|
|
120
|
+
})
|
|
121
|
+
})
|