@opencode/ai 0.0.0-dev-20048 → 0.0.0-dev-20050
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/README.md +65 -1
- package/dist/generation.d.ts +13 -9
- package/dist/generation.js +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/promise.d.ts +164 -3
- package/dist/promise.js +11 -1
- package/dist/protocols/assemblyai-transcription.d.ts +40 -0
- package/dist/protocols/assemblyai-transcription.js +138 -0
- package/dist/protocols/deepgram-speech.js +17 -15
- package/dist/protocols/deepgram-transcription.d.ts +25 -0
- package/dist/protocols/deepgram-transcription.js +129 -0
- package/dist/protocols/gemini.d.ts +2 -2
- package/dist/protocols/gemini.js +2 -6
- package/dist/protocols/google-images.js +2 -14
- package/dist/protocols/google-speech.d.ts +2 -32
- package/dist/protocols/google-speech.js +22 -60
- package/dist/protocols/google-transcription.d.ts +173 -0
- package/dist/protocols/google-transcription.js +138 -0
- package/dist/protocols/openai-images.js +4 -7
- package/dist/protocols/openai-transcription.d.ts +207 -0
- package/dist/protocols/openai-transcription.js +190 -0
- package/dist/protocols/utils/gemini-generate-content.d.ts +65 -0
- package/dist/protocols/utils/gemini-generate-content.js +65 -0
- package/dist/protocols/utils/media-input.d.ts +4 -0
- package/dist/protocols/utils/media-input.js +14 -0
- package/dist/providers/assemblyai.d.ts +25 -0
- package/dist/providers/assemblyai.js +29 -0
- package/dist/providers/deepgram.d.ts +5 -0
- package/dist/providers/deepgram.js +5 -2
- package/dist/providers/google.d.ts +5 -0
- package/dist/providers/google.js +3 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/openai.d.ts +5 -0
- package/dist/providers/openai.js +4 -0
- package/dist/route/media-protocol.d.ts +15 -3
- package/dist/route/media-protocol.js +6 -0
- package/dist/route/media.d.ts +5 -2
- package/dist/route/media.js +62 -40
- package/dist/transcription-client.d.ts +28 -0
- package/dist/transcription-client.js +69 -0
- package/dist/transcription.d.ts +1504 -0
- package/dist/transcription.js +140 -0
- package/dist/utils/media-type.d.ts +1 -0
- package/dist/utils/media-type.js +14 -0
- package/dist/video-client.d.ts +1 -1
- package/dist/video-client.js +3 -6
- package/dist/video.d.ts +1 -15
- package/dist/video.js +2 -14
- package/package.json +3 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Effect, Schema, SchemaGetter } from "effect";
|
|
2
|
+
import { MediaProtocol } from "../route/media-protocol.js";
|
|
3
|
+
import { MediaRoute } from "../route/media.js";
|
|
4
|
+
import { ProviderID, mergeJsonRecords } from "../schema/index.js";
|
|
5
|
+
import { TranscriptionFinishEvent, TranscriptionModel, TranscriptionSegmentEvent, TranscriptionTextDeltaEvent, } from "../transcription.js";
|
|
6
|
+
import { ProviderShared } from "./shared.js";
|
|
7
|
+
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js";
|
|
8
|
+
const ADAPTER = "google-transcription";
|
|
9
|
+
const NAME = "Google Transcription";
|
|
10
|
+
const PROVIDER = ProviderID.make("google");
|
|
11
|
+
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// 3. Streaming event schema
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const Seconds = Schema.String.check(Schema.isPattern(/^\d+(\.\d+)?s$/)).pipe(Schema.decodeTo(Schema.Number, {
|
|
16
|
+
decode: SchemaGetter.transform((value) => Number.parseFloat(value)),
|
|
17
|
+
encode: SchemaGetter.transform((value) => `${value}s`),
|
|
18
|
+
}));
|
|
19
|
+
const AudioTranscription = Schema.Struct({
|
|
20
|
+
text: Schema.String,
|
|
21
|
+
speakerLabel: Schema.optional(Schema.String),
|
|
22
|
+
words: Schema.optional(Schema.Array(Schema.Struct({
|
|
23
|
+
word: Schema.String,
|
|
24
|
+
startOffset: Seconds,
|
|
25
|
+
endOffset: Seconds,
|
|
26
|
+
speakerLabel: Schema.optional(Schema.String),
|
|
27
|
+
}))),
|
|
28
|
+
});
|
|
29
|
+
const decodeChunk = MediaProtocol.decodeFrame(ADAPTER, NAME, GeminiGenerateContent.chunk(Schema.Struct({ audioTranscription: Schema.optional(AudioTranscription) })));
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 5. Request body construction
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const fromRequest = Effect.fn("GoogleTranscription.fromRequest")(function* (request) {
|
|
34
|
+
// General Gemini models ignore `audioTranscriptionConfig` and answer the audio conversationally.
|
|
35
|
+
if (!request.model.id.includes("transcribe"))
|
|
36
|
+
return yield* ProviderShared.unsupportedOperation({
|
|
37
|
+
operation: "transcription.model",
|
|
38
|
+
provider: PROVIDER,
|
|
39
|
+
route: ADAPTER,
|
|
40
|
+
message: `${request.model.id} is not a transcription model; use a transcribe model such as gemini-3.5-transcribe`,
|
|
41
|
+
});
|
|
42
|
+
return MediaProtocol.json(mergeJsonRecords({
|
|
43
|
+
contents: [{ role: "user", parts: [yield* GeminiGenerateContent.mediaPart(ADAPTER, request.audio)] }],
|
|
44
|
+
generationConfig: mergeJsonRecords({
|
|
45
|
+
audioTranscriptionConfig: {
|
|
46
|
+
languageCodes: request.language === undefined ? undefined : [request.language],
|
|
47
|
+
// Parts carry no offsets of their own, so segment times come from word offsets.
|
|
48
|
+
wordTimestamp: request.diarize === true || request.timestamps === "word" || request.timestamps === "segment"
|
|
49
|
+
? true
|
|
50
|
+
: undefined,
|
|
51
|
+
diarization: request.diarize === true ? true : undefined,
|
|
52
|
+
},
|
|
53
|
+
}, request.providerOptions),
|
|
54
|
+
}, request.http?.body) ?? {});
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// 6. Stream parsing
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
const turn = (part) => {
|
|
60
|
+
const words = (part.words ?? []).map((word) => ({
|
|
61
|
+
text: word.word,
|
|
62
|
+
startSeconds: word.startOffset,
|
|
63
|
+
endSeconds: word.endOffset,
|
|
64
|
+
speaker: word.speakerLabel ?? part.speakerLabel,
|
|
65
|
+
}));
|
|
66
|
+
const first = words[0];
|
|
67
|
+
const last = words.at(-1);
|
|
68
|
+
return {
|
|
69
|
+
text: part.text,
|
|
70
|
+
words,
|
|
71
|
+
segment: first === undefined || last === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: {
|
|
74
|
+
text: part.text,
|
|
75
|
+
startSeconds: first.startSeconds,
|
|
76
|
+
endSeconds: last.endSeconds,
|
|
77
|
+
speaker: part.speakerLabel,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const step = Effect.fn("GoogleTranscription.step")(function* (state, frame) {
|
|
82
|
+
const chunk = yield* decodeChunk(frame);
|
|
83
|
+
const blocked = GeminiGenerateContent.blocked(NAME, chunk, frame);
|
|
84
|
+
if (blocked !== undefined)
|
|
85
|
+
return yield* blocked;
|
|
86
|
+
const turns = (chunk.candidates?.[0]?.content?.parts ?? []).flatMap((part) => part.audioTranscription === undefined ? [] : [turn(part.audioTranscription)]);
|
|
87
|
+
const segments = turns.flatMap((item) => (item.segment === undefined ? [] : [item.segment]));
|
|
88
|
+
state.words.push(...turns.flatMap((item) => item.words));
|
|
89
|
+
state.segments.push(...segments);
|
|
90
|
+
// Each part is one whole speaker turn without surrounding whitespace, so turns join with a space.
|
|
91
|
+
const text = turns
|
|
92
|
+
.map((item) => item.text)
|
|
93
|
+
.filter((item) => item.length > 0)
|
|
94
|
+
.join(" ");
|
|
95
|
+
const delta = text.length === 0 || state.text.length === 0 ? text : ` ${text}`;
|
|
96
|
+
const events = [
|
|
97
|
+
...(delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta })]),
|
|
98
|
+
...segments.map((segment) => TranscriptionSegmentEvent.make({ segment })),
|
|
99
|
+
];
|
|
100
|
+
return [{ ...GeminiGenerateContent.track(state, chunk), text: state.text + delta }, events];
|
|
101
|
+
});
|
|
102
|
+
const finish = (state) => {
|
|
103
|
+
if (state.finishReason === undefined)
|
|
104
|
+
return Effect.fail(MediaProtocol.incomplete(ADAPTER));
|
|
105
|
+
return Effect.succeed([
|
|
106
|
+
TranscriptionFinishEvent.make({
|
|
107
|
+
text: state.text,
|
|
108
|
+
segments: state.segments.length === 0 ? undefined : state.segments,
|
|
109
|
+
words: state.words.length === 0 ? undefined : state.words,
|
|
110
|
+
usage: GeminiGenerateContent.usage(state.usage),
|
|
111
|
+
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
};
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// 7. Protocol and route
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
export const protocol = MediaProtocol.stream({
|
|
119
|
+
id: ADAPTER,
|
|
120
|
+
name: NAME,
|
|
121
|
+
unsupported: ["prompt", "speakers"],
|
|
122
|
+
body: { from: fromRequest },
|
|
123
|
+
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
|
|
124
|
+
initial: () => ({ text: "", segments: [], words: [] }),
|
|
125
|
+
step,
|
|
126
|
+
finish,
|
|
127
|
+
});
|
|
128
|
+
export const model = (input) => TranscriptionModel.fromRoute({
|
|
129
|
+
id: ADAPTER,
|
|
130
|
+
provider: PROVIDER,
|
|
131
|
+
protocol,
|
|
132
|
+
baseURL: DEFAULT_BASE_URL,
|
|
133
|
+
path: ({ request }) => GeminiGenerateContent.path(request.model.id, request.mode),
|
|
134
|
+
}, input);
|
|
135
|
+
export const GoogleTranscription = {
|
|
136
|
+
protocol,
|
|
137
|
+
model,
|
|
138
|
+
};
|
|
@@ -43,11 +43,6 @@ const nativeOptions = (options) => {
|
|
|
43
43
|
};
|
|
44
44
|
const isEdit = (request) => (request.images?.length ?? 0) > 0;
|
|
45
45
|
const isInline = (asset) => asset.inline() !== undefined;
|
|
46
|
-
const blob = (data, mediaType) => {
|
|
47
|
-
const buffer = new ArrayBuffer(data.byteLength);
|
|
48
|
-
new Uint8Array(buffer).set(data);
|
|
49
|
-
return new Blob([buffer], { type: mediaType });
|
|
50
|
-
};
|
|
51
46
|
const reference = (asset) => ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })));
|
|
52
47
|
const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request) {
|
|
53
48
|
const images = request.images ?? [];
|
|
@@ -66,9 +61,9 @@ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request) {
|
|
|
66
61
|
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value));
|
|
67
62
|
});
|
|
68
63
|
const uploads = yield* Effect.forEach(images, (image) => MediaInput.inlineBytes(ADAPTER, image));
|
|
69
|
-
uploads.forEach((data, index) => form.append("image[]", blob(data, images[index].mediaType), `image-${index}`));
|
|
64
|
+
uploads.forEach((data, index) => form.append("image[]", MediaInput.blob(data, images[index].mediaType), `image-${index}`));
|
|
70
65
|
if (mask !== undefined)
|
|
71
|
-
form.append("mask", blob(yield* MediaInput.inlineBytes(ADAPTER, mask), mask.mediaType), "mask");
|
|
66
|
+
form.append("mask", MediaInput.blob(yield* MediaInput.inlineBytes(ADAPTER, mask), mask.mediaType), "mask");
|
|
72
67
|
return MediaProtocol.multipart(form);
|
|
73
68
|
}
|
|
74
69
|
const references = yield* Effect.forEach(images, reference);
|
|
@@ -84,6 +79,8 @@ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request) {
|
|
|
84
79
|
// 6. Response decoding
|
|
85
80
|
// ---------------------------------------------------------------------------
|
|
86
81
|
const requestedFormat = (body) => {
|
|
82
|
+
if (body.type === "binary")
|
|
83
|
+
return undefined;
|
|
87
84
|
const value = body.type === "json" ? body.value.output_format : body.value.get("output_format");
|
|
88
85
|
return typeof value === "string" ? value : undefined;
|
|
89
86
|
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { MediaProtocol } from "../route/media-protocol.js";
|
|
3
|
+
import { MediaRoute } from "../route/media.js";
|
|
4
|
+
import { TranscriptionModel, type TranscriptionRequestFor, type TranscriptionSegment } from "../transcription.js";
|
|
5
|
+
export declare const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
6
|
+
export declare const PATH = "/audio/transcriptions";
|
|
7
|
+
export type OpenAITranscriptionOptions = {
|
|
8
|
+
readonly chunking_strategy?: "auto" | {
|
|
9
|
+
readonly type: "server_vad";
|
|
10
|
+
readonly prefix_padding_ms?: number;
|
|
11
|
+
readonly silence_duration_ms?: number;
|
|
12
|
+
readonly threshold?: number;
|
|
13
|
+
};
|
|
14
|
+
readonly include?: ReadonlyArray<"logprobs">;
|
|
15
|
+
readonly keywords?: ReadonlyArray<string>;
|
|
16
|
+
readonly known_speaker_names?: ReadonlyArray<string>;
|
|
17
|
+
readonly known_speaker_references?: ReadonlyArray<string>;
|
|
18
|
+
readonly temperature?: number;
|
|
19
|
+
} & Record<string, unknown>;
|
|
20
|
+
export type Request = TranscriptionRequestFor<OpenAITranscriptionOptions>;
|
|
21
|
+
declare const Transcript: Schema.Struct<{
|
|
22
|
+
text: Schema.String;
|
|
23
|
+
language: Schema.optional<Schema.String>;
|
|
24
|
+
languages: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
25
|
+
readonly code: Schema.String;
|
|
26
|
+
}>>>;
|
|
27
|
+
duration: Schema.optional<Schema.Number>;
|
|
28
|
+
segments: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
29
|
+
readonly text: Schema.String;
|
|
30
|
+
readonly start: Schema.Number;
|
|
31
|
+
readonly end: Schema.Number;
|
|
32
|
+
readonly speaker: Schema.optional<Schema.String>;
|
|
33
|
+
}>>>;
|
|
34
|
+
words: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
35
|
+
readonly word: Schema.String;
|
|
36
|
+
readonly start: Schema.Number;
|
|
37
|
+
readonly end: Schema.Number;
|
|
38
|
+
}>>>;
|
|
39
|
+
usage: Schema.optional<Schema.Union<readonly [Schema.Struct<{
|
|
40
|
+
readonly type: Schema.Literal<"tokens">;
|
|
41
|
+
readonly input_tokens: Schema.optional<Schema.Number>;
|
|
42
|
+
readonly output_tokens: Schema.optional<Schema.Number>;
|
|
43
|
+
readonly total_tokens: Schema.optional<Schema.Number>;
|
|
44
|
+
}>, Schema.Struct<{
|
|
45
|
+
readonly type: Schema.Literal<"duration">;
|
|
46
|
+
readonly seconds: Schema.Number;
|
|
47
|
+
}>]>>;
|
|
48
|
+
}>;
|
|
49
|
+
type Transcript = Schema.Schema.Type<typeof Transcript>;
|
|
50
|
+
type Frame = string | {
|
|
51
|
+
readonly document: string;
|
|
52
|
+
};
|
|
53
|
+
interface State {
|
|
54
|
+
readonly segments: Array<TranscriptionSegment>;
|
|
55
|
+
readonly transcript?: Transcript;
|
|
56
|
+
}
|
|
57
|
+
export declare const protocol: MediaProtocol.Streamed<Request, {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly type: "generation-queued";
|
|
60
|
+
readonly position?: number | undefined;
|
|
61
|
+
} | {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly type: "generation-progress";
|
|
64
|
+
readonly progress?: number | undefined;
|
|
65
|
+
} | {
|
|
66
|
+
readonly type: "text-delta";
|
|
67
|
+
readonly delta: string;
|
|
68
|
+
} | {
|
|
69
|
+
readonly type: "segment";
|
|
70
|
+
readonly segment: {
|
|
71
|
+
readonly text: string;
|
|
72
|
+
readonly startSeconds: number;
|
|
73
|
+
readonly endSeconds: number;
|
|
74
|
+
readonly speaker?: string | undefined;
|
|
75
|
+
};
|
|
76
|
+
} | {
|
|
77
|
+
readonly type: "finish";
|
|
78
|
+
readonly text: string;
|
|
79
|
+
readonly durationSeconds?: number | undefined;
|
|
80
|
+
readonly providerMetadata?: {
|
|
81
|
+
readonly [x: string]: {
|
|
82
|
+
readonly [x: string]: unknown;
|
|
83
|
+
};
|
|
84
|
+
} | undefined;
|
|
85
|
+
readonly usage?: {
|
|
86
|
+
readonly type: "tokens";
|
|
87
|
+
readonly input?: number | undefined;
|
|
88
|
+
readonly output?: number | undefined;
|
|
89
|
+
readonly total?: number | undefined;
|
|
90
|
+
readonly details?: {
|
|
91
|
+
readonly [x: string]: unknown;
|
|
92
|
+
} | undefined;
|
|
93
|
+
} | {
|
|
94
|
+
readonly type: "seconds";
|
|
95
|
+
readonly seconds: number;
|
|
96
|
+
} | {
|
|
97
|
+
readonly type: "characters";
|
|
98
|
+
readonly characters: number;
|
|
99
|
+
} | {
|
|
100
|
+
readonly type: "credits";
|
|
101
|
+
readonly credits: number;
|
|
102
|
+
} | {
|
|
103
|
+
readonly type: "compute";
|
|
104
|
+
readonly seconds: number;
|
|
105
|
+
} | undefined;
|
|
106
|
+
readonly notices?: readonly {
|
|
107
|
+
readonly type: "other" | "moderated" | "filtered";
|
|
108
|
+
readonly message: string;
|
|
109
|
+
readonly providerMetadata?: {
|
|
110
|
+
readonly [x: string]: {
|
|
111
|
+
readonly [x: string]: unknown;
|
|
112
|
+
};
|
|
113
|
+
} | undefined;
|
|
114
|
+
}[] | undefined;
|
|
115
|
+
readonly language?: string | undefined;
|
|
116
|
+
readonly segments?: readonly {
|
|
117
|
+
readonly text: string;
|
|
118
|
+
readonly startSeconds: number;
|
|
119
|
+
readonly endSeconds: number;
|
|
120
|
+
readonly speaker?: string | undefined;
|
|
121
|
+
}[] | undefined;
|
|
122
|
+
readonly words?: readonly {
|
|
123
|
+
readonly text: string;
|
|
124
|
+
readonly startSeconds: number;
|
|
125
|
+
readonly endSeconds: number;
|
|
126
|
+
readonly speaker?: string | undefined;
|
|
127
|
+
readonly confidence?: number | undefined;
|
|
128
|
+
}[] | undefined;
|
|
129
|
+
}, Frame, State>;
|
|
130
|
+
export declare const model: (input: MediaRoute.ModelInput) => TranscriptionModel<OpenAITranscriptionOptions>;
|
|
131
|
+
export declare const OpenAITranscription: {
|
|
132
|
+
readonly protocol: MediaProtocol.Streamed<Request, {
|
|
133
|
+
readonly id: string;
|
|
134
|
+
readonly type: "generation-queued";
|
|
135
|
+
readonly position?: number | undefined;
|
|
136
|
+
} | {
|
|
137
|
+
readonly id: string;
|
|
138
|
+
readonly type: "generation-progress";
|
|
139
|
+
readonly progress?: number | undefined;
|
|
140
|
+
} | {
|
|
141
|
+
readonly type: "text-delta";
|
|
142
|
+
readonly delta: string;
|
|
143
|
+
} | {
|
|
144
|
+
readonly type: "segment";
|
|
145
|
+
readonly segment: {
|
|
146
|
+
readonly text: string;
|
|
147
|
+
readonly startSeconds: number;
|
|
148
|
+
readonly endSeconds: number;
|
|
149
|
+
readonly speaker?: string | undefined;
|
|
150
|
+
};
|
|
151
|
+
} | {
|
|
152
|
+
readonly type: "finish";
|
|
153
|
+
readonly text: string;
|
|
154
|
+
readonly durationSeconds?: number | undefined;
|
|
155
|
+
readonly providerMetadata?: {
|
|
156
|
+
readonly [x: string]: {
|
|
157
|
+
readonly [x: string]: unknown;
|
|
158
|
+
};
|
|
159
|
+
} | undefined;
|
|
160
|
+
readonly usage?: {
|
|
161
|
+
readonly type: "tokens";
|
|
162
|
+
readonly input?: number | undefined;
|
|
163
|
+
readonly output?: number | undefined;
|
|
164
|
+
readonly total?: number | undefined;
|
|
165
|
+
readonly details?: {
|
|
166
|
+
readonly [x: string]: unknown;
|
|
167
|
+
} | undefined;
|
|
168
|
+
} | {
|
|
169
|
+
readonly type: "seconds";
|
|
170
|
+
readonly seconds: number;
|
|
171
|
+
} | {
|
|
172
|
+
readonly type: "characters";
|
|
173
|
+
readonly characters: number;
|
|
174
|
+
} | {
|
|
175
|
+
readonly type: "credits";
|
|
176
|
+
readonly credits: number;
|
|
177
|
+
} | {
|
|
178
|
+
readonly type: "compute";
|
|
179
|
+
readonly seconds: number;
|
|
180
|
+
} | undefined;
|
|
181
|
+
readonly notices?: readonly {
|
|
182
|
+
readonly type: "other" | "moderated" | "filtered";
|
|
183
|
+
readonly message: string;
|
|
184
|
+
readonly providerMetadata?: {
|
|
185
|
+
readonly [x: string]: {
|
|
186
|
+
readonly [x: string]: unknown;
|
|
187
|
+
};
|
|
188
|
+
} | undefined;
|
|
189
|
+
}[] | undefined;
|
|
190
|
+
readonly language?: string | undefined;
|
|
191
|
+
readonly segments?: readonly {
|
|
192
|
+
readonly text: string;
|
|
193
|
+
readonly startSeconds: number;
|
|
194
|
+
readonly endSeconds: number;
|
|
195
|
+
readonly speaker?: string | undefined;
|
|
196
|
+
}[] | undefined;
|
|
197
|
+
readonly words?: readonly {
|
|
198
|
+
readonly text: string;
|
|
199
|
+
readonly startSeconds: number;
|
|
200
|
+
readonly endSeconds: number;
|
|
201
|
+
readonly speaker?: string | undefined;
|
|
202
|
+
readonly confidence?: number | undefined;
|
|
203
|
+
}[] | undefined;
|
|
204
|
+
}, Frame, State>;
|
|
205
|
+
readonly model: (input: MediaRoute.ModelInput) => TranscriptionModel<OpenAITranscriptionOptions>;
|
|
206
|
+
};
|
|
207
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Effect, Schema, Stream } from "effect";
|
|
2
|
+
import { Framing } from "../route/framing.js";
|
|
3
|
+
import { MediaProtocol } from "../route/media-protocol.js";
|
|
4
|
+
import { MediaRoute } from "../route/media.js";
|
|
5
|
+
import { ProviderID, mergeJsonRecords } from "../schema/index.js";
|
|
6
|
+
import { TranscriptionFinishEvent, TranscriptionModel, TranscriptionSegmentEvent, TranscriptionTextDeltaEvent, } from "../transcription.js";
|
|
7
|
+
import { mediaTypeExtension } from "../utils/media-type.js";
|
|
8
|
+
import { ProviderShared } from "./shared.js";
|
|
9
|
+
import { MediaInput } from "./utils/media-input.js";
|
|
10
|
+
const ADAPTER = "openai-transcription";
|
|
11
|
+
const NAME = "OpenAI Transcription";
|
|
12
|
+
const PROVIDER = ProviderID.make("openai");
|
|
13
|
+
export const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
14
|
+
export const PATH = "/audio/transcriptions";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// 3. Streaming event schema
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const Segment = Schema.Struct({
|
|
19
|
+
text: Schema.String,
|
|
20
|
+
start: Schema.Number,
|
|
21
|
+
end: Schema.Number,
|
|
22
|
+
speaker: Schema.optional(Schema.String),
|
|
23
|
+
});
|
|
24
|
+
const Usage = Schema.Union([
|
|
25
|
+
Schema.Struct({
|
|
26
|
+
type: Schema.Literal("tokens"),
|
|
27
|
+
input_tokens: Schema.optional(Schema.Number),
|
|
28
|
+
output_tokens: Schema.optional(Schema.Number),
|
|
29
|
+
total_tokens: Schema.optional(Schema.Number),
|
|
30
|
+
}),
|
|
31
|
+
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
|
|
32
|
+
]);
|
|
33
|
+
const transcriptFields = {
|
|
34
|
+
text: Schema.String,
|
|
35
|
+
language: Schema.optional(Schema.String),
|
|
36
|
+
languages: Schema.optional(Schema.Array(Schema.Struct({ code: Schema.String }))),
|
|
37
|
+
duration: Schema.optional(Schema.Number),
|
|
38
|
+
segments: Schema.optional(Schema.Array(Segment)),
|
|
39
|
+
words: Schema.optional(Schema.Array(Schema.Struct({ word: Schema.String, start: Schema.Number, end: Schema.Number }))),
|
|
40
|
+
usage: Schema.optional(Usage),
|
|
41
|
+
};
|
|
42
|
+
const Event = Schema.Union([
|
|
43
|
+
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
|
|
44
|
+
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
|
|
45
|
+
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
|
|
46
|
+
]);
|
|
47
|
+
const Transcript = Schema.Struct(transcriptFields);
|
|
48
|
+
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, Event);
|
|
49
|
+
const decodeTranscript = MediaProtocol.decodeFrame(ADAPTER, NAME, Transcript);
|
|
50
|
+
const TRANSCRIBE = { stream: true, timestamps: [], diarize: false, languageField: "language" };
|
|
51
|
+
const capabilities = (model) => {
|
|
52
|
+
if (model.startsWith("whisper"))
|
|
53
|
+
return { ...TRANSCRIBE, stream: false, timestamps: ["segment", "word"] };
|
|
54
|
+
if (model.includes("diarize"))
|
|
55
|
+
return { ...TRANSCRIBE, timestamps: ["segment"], diarize: true };
|
|
56
|
+
// `gpt-transcribe` replaces `language` with `languages[]` and rejects both together.
|
|
57
|
+
if (model.startsWith("gpt-transcribe"))
|
|
58
|
+
return { ...TRANSCRIBE, languageField: "languages" };
|
|
59
|
+
return TRANSCRIBE;
|
|
60
|
+
};
|
|
61
|
+
const unsupported = (operation, message) => Effect.fail(ProviderShared.unsupportedOperation({ operation, provider: PROVIDER, route: ADAPTER, message }));
|
|
62
|
+
const validate = (request, model) => {
|
|
63
|
+
const id = request.model.id;
|
|
64
|
+
if (request.mode === "stream" && !model.stream)
|
|
65
|
+
return unsupported("media.stream", `${id} does not stream; use Transcription.generate`);
|
|
66
|
+
if (request.diarize === true && !model.diarize)
|
|
67
|
+
return unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`);
|
|
68
|
+
if (request.prompt !== undefined && model.diarize)
|
|
69
|
+
return unsupported("media.prompt", `${id} does not accept a prompt`);
|
|
70
|
+
if (request.timestamps === undefined ||
|
|
71
|
+
request.timestamps === "none" ||
|
|
72
|
+
model.timestamps.includes(request.timestamps))
|
|
73
|
+
return Effect.void;
|
|
74
|
+
return unsupported("media.timestamps", `${id} does not return ${request.timestamps} timestamps`);
|
|
75
|
+
};
|
|
76
|
+
const RESERVED_FORM_FIELDS = new Set([
|
|
77
|
+
"file",
|
|
78
|
+
"model",
|
|
79
|
+
"prompt",
|
|
80
|
+
"language",
|
|
81
|
+
"response_format",
|
|
82
|
+
"timestamp_granularities",
|
|
83
|
+
"stream",
|
|
84
|
+
]);
|
|
85
|
+
const appendField = (form, key, value) => {
|
|
86
|
+
if (Array.isArray(value))
|
|
87
|
+
return value.forEach((item) => form.append(`${key}[]`, String(item)));
|
|
88
|
+
form.append(key, typeof value === "object" && value !== null ? ProviderShared.encodeJson(value) : String(value));
|
|
89
|
+
};
|
|
90
|
+
const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (request) {
|
|
91
|
+
const model = capabilities(request.model.id);
|
|
92
|
+
yield* validate(request, model);
|
|
93
|
+
// The API detects the audio format from the upload's filename extension.
|
|
94
|
+
const extension = mediaTypeExtension(request.audio.mediaType);
|
|
95
|
+
if (extension === undefined)
|
|
96
|
+
return yield* ProviderShared.invalidRequest(`${NAME} cannot name a ${request.audio.mediaType} upload; send mp3, mp4, m4a, wav, webm, ogg, or flac audio`);
|
|
97
|
+
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio);
|
|
98
|
+
const responseFormat = model.diarize
|
|
99
|
+
? "diarized_json"
|
|
100
|
+
: request.timestamps === undefined || request.timestamps === "none"
|
|
101
|
+
? undefined
|
|
102
|
+
: "verbose_json";
|
|
103
|
+
const native = Object.entries(mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}).filter(([key]) => !RESERVED_FORM_FIELDS.has(key));
|
|
104
|
+
const fields = mergeJsonRecords({
|
|
105
|
+
model: request.model.id,
|
|
106
|
+
language: model.languageField === "language" ? request.language : undefined,
|
|
107
|
+
languages: model.languageField === "languages" && request.language !== undefined ? [request.language] : undefined,
|
|
108
|
+
prompt: request.prompt,
|
|
109
|
+
response_format: responseFormat,
|
|
110
|
+
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
|
|
111
|
+
// Diarizing audio longer than 30 seconds requires a chunking strategy.
|
|
112
|
+
chunking_strategy: model.diarize ? "auto" : undefined,
|
|
113
|
+
stream: request.mode === "stream" ? true : undefined,
|
|
114
|
+
}, Object.fromEntries(native));
|
|
115
|
+
const form = new FormData();
|
|
116
|
+
form.append("file", MediaInput.blob(audio, request.audio.mediaType), `audio.${extension}`);
|
|
117
|
+
Object.entries(fields ?? {}).forEach(([key, value]) => appendField(form, key, value));
|
|
118
|
+
return MediaProtocol.multipart(form);
|
|
119
|
+
});
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 6. Stream parsing
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
const segment = (value) => ({
|
|
124
|
+
text: value.text.trim(),
|
|
125
|
+
startSeconds: value.start,
|
|
126
|
+
endSeconds: value.end,
|
|
127
|
+
speaker: value.speaker,
|
|
128
|
+
});
|
|
129
|
+
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state, frame) {
|
|
130
|
+
const event = yield* decodeEvent(frame);
|
|
131
|
+
if (event.type === "transcript.text.done")
|
|
132
|
+
return [{ ...state, transcript: event }, []];
|
|
133
|
+
if (event.type === "transcript.text.delta")
|
|
134
|
+
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]];
|
|
135
|
+
const next = segment(event);
|
|
136
|
+
state.segments.push(next);
|
|
137
|
+
return [state, [TranscriptionSegmentEvent.make({ segment: next })]];
|
|
138
|
+
});
|
|
139
|
+
const step = (state, frame) => typeof frame === "string"
|
|
140
|
+
? onEvent(state, frame)
|
|
141
|
+
: decodeTranscript(frame.document).pipe(Effect.map((transcript) => [{ ...state, transcript }, []]));
|
|
142
|
+
const usage = (value) => {
|
|
143
|
+
if (value === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (value.type === "duration")
|
|
146
|
+
return { type: "seconds", seconds: value.seconds };
|
|
147
|
+
return {
|
|
148
|
+
type: "tokens",
|
|
149
|
+
input: value.input_tokens,
|
|
150
|
+
output: value.output_tokens,
|
|
151
|
+
total: value.total_tokens,
|
|
152
|
+
details: { openai: value },
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
const finish = (state) => {
|
|
156
|
+
const transcript = state.transcript;
|
|
157
|
+
if (transcript === undefined)
|
|
158
|
+
return Effect.fail(MediaProtocol.incomplete(ADAPTER));
|
|
159
|
+
const segments = transcript.segments?.map(segment) ?? state.segments;
|
|
160
|
+
return Effect.succeed([
|
|
161
|
+
TranscriptionFinishEvent.make({
|
|
162
|
+
text: transcript.text,
|
|
163
|
+
segments: segments.length === 0 ? undefined : segments,
|
|
164
|
+
words: transcript.words?.map((word) => ({ text: word.word, startSeconds: word.start, endSeconds: word.end })),
|
|
165
|
+
language: (transcript.language ?? transcript.languages?.[0]?.code)?.toLowerCase(),
|
|
166
|
+
durationSeconds: transcript.duration,
|
|
167
|
+
usage: usage(transcript.usage),
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
};
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// 7. Protocol and route
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
export const protocol = MediaProtocol.stream({
|
|
175
|
+
id: ADAPTER,
|
|
176
|
+
name: NAME,
|
|
177
|
+
unsupported: ["speakers"],
|
|
178
|
+
body: { from: fromRequest },
|
|
179
|
+
frames: (bytes, context) => context.request.mode === "stream"
|
|
180
|
+
? Framing.sse.frame(bytes)
|
|
181
|
+
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
|
|
182
|
+
initial: () => ({ segments: [] }),
|
|
183
|
+
step,
|
|
184
|
+
finish,
|
|
185
|
+
});
|
|
186
|
+
export const model = (input) => TranscriptionModel.fromRoute({ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input);
|
|
187
|
+
export const OpenAITranscription = {
|
|
188
|
+
protocol,
|
|
189
|
+
model,
|
|
190
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect, Schema, type Stream } from "effect";
|
|
2
|
+
import type { Media } from "../../media.js";
|
|
3
|
+
import type { MediaProtocol } from "../../route/media-protocol.js";
|
|
4
|
+
import { AIError, type MediaUsage, type ProviderMetadata } from "../../schema/index.js";
|
|
5
|
+
declare const UsageMetadata: Schema.Struct<{
|
|
6
|
+
readonly promptTokenCount: Schema.optional<Schema.Number>;
|
|
7
|
+
readonly candidatesTokenCount: Schema.optional<Schema.Number>;
|
|
8
|
+
readonly totalTokenCount: Schema.optional<Schema.Number>;
|
|
9
|
+
}>;
|
|
10
|
+
type UsageMetadata = Schema.Schema.Type<typeof UsageMetadata>;
|
|
11
|
+
export declare const chunk: <const Part extends Schema.Top>(part: Part) => Schema.Struct<{
|
|
12
|
+
readonly candidates: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
13
|
+
readonly content: Schema.optional<Schema.Struct<{
|
|
14
|
+
readonly parts: Schema.optional<Schema.$Array<Part>>;
|
|
15
|
+
}>>;
|
|
16
|
+
readonly finishReason: Schema.optional<Schema.String>;
|
|
17
|
+
}>>>;
|
|
18
|
+
readonly promptFeedback: Schema.optional<Schema.Struct<{
|
|
19
|
+
readonly blockReason: Schema.optional<Schema.String>;
|
|
20
|
+
readonly blockReasonMessage: Schema.optional<Schema.String>;
|
|
21
|
+
}>>;
|
|
22
|
+
readonly usageMetadata: Schema.optional<Schema.Struct<{
|
|
23
|
+
readonly promptTokenCount: Schema.optional<Schema.Number>;
|
|
24
|
+
readonly candidatesTokenCount: Schema.optional<Schema.Number>;
|
|
25
|
+
readonly totalTokenCount: Schema.optional<Schema.Number>;
|
|
26
|
+
}>>;
|
|
27
|
+
readonly modelVersion: Schema.optional<Schema.String>;
|
|
28
|
+
readonly responseId: Schema.optional<Schema.String>;
|
|
29
|
+
}>;
|
|
30
|
+
interface Chunk {
|
|
31
|
+
readonly candidates?: ReadonlyArray<{
|
|
32
|
+
readonly finishReason?: string;
|
|
33
|
+
}>;
|
|
34
|
+
readonly promptFeedback?: {
|
|
35
|
+
readonly blockReason?: string;
|
|
36
|
+
readonly blockReasonMessage?: string;
|
|
37
|
+
};
|
|
38
|
+
readonly usageMetadata?: UsageMetadata;
|
|
39
|
+
readonly modelVersion?: string;
|
|
40
|
+
readonly responseId?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface Metadata {
|
|
43
|
+
readonly usage?: UsageMetadata;
|
|
44
|
+
readonly finishReason?: string;
|
|
45
|
+
readonly modelVersion?: string;
|
|
46
|
+
readonly responseId?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const track: <State extends Metadata>(state: State, chunk: Chunk) => State;
|
|
49
|
+
export declare const blocked: (name: string, chunk: Chunk, frame: string) => AIError | undefined;
|
|
50
|
+
export declare const usage: (usage: UsageMetadata | undefined) => MediaUsage | undefined;
|
|
51
|
+
export declare const providerMetadata: (state: Metadata) => ProviderMetadata;
|
|
52
|
+
export declare const path: (model: string, mode: MediaProtocol.Mode) => string;
|
|
53
|
+
export declare const frames: (bytes: Stream.Stream<Uint8Array, AIError>, mode: MediaProtocol.Mode) => Stream.Stream<string, AIError, never>;
|
|
54
|
+
export declare const mediaPart: (route: string, asset: Media.Asset) => Effect.Effect<{
|
|
55
|
+
readonly fileData: {
|
|
56
|
+
readonly mimeType: string;
|
|
57
|
+
readonly fileUri: string;
|
|
58
|
+
};
|
|
59
|
+
} | {
|
|
60
|
+
readonly inlineData: {
|
|
61
|
+
readonly mimeType: string;
|
|
62
|
+
readonly data: string;
|
|
63
|
+
};
|
|
64
|
+
}, AIError>;
|
|
65
|
+
export * as GeminiGenerateContent from "./gemini-generate-content.js";
|