pi-media-models 0.1.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/README.md +174 -0
- package/SKILL.md +1 -0
- package/index.ts +11 -0
- package/package.json +40 -0
- package/src/adapters/atlas.ts +151 -0
- package/src/adapters/base.ts +107 -0
- package/src/adapters/custom.ts +154 -0
- package/src/adapters/dashscope.ts +151 -0
- package/src/adapters/fal.ts +135 -0
- package/src/adapters/google.ts +214 -0
- package/src/adapters/openai.ts +117 -0
- package/src/adapters/openrouter.ts +112 -0
- package/src/adapters/xai.ts +122 -0
- package/src/artifacts.ts +130 -0
- package/src/config.ts +94 -0
- package/src/errors.ts +61 -0
- package/src/http.ts +131 -0
- package/src/input.ts +104 -0
- package/src/media-job.ts +89 -0
- package/src/router.ts +95 -0
- package/src/tools.ts +205 -0
- package/src/types.ts +146 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { MediaJob, mapJobState } from '../media-job.js'
|
|
2
|
+
import { BaseAdapter, artifactsOrThrow, dataUris, makeModel } from './base.js'
|
|
3
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
4
|
+
|
|
5
|
+
function withoutControlOptions(options: JsonObject | undefined): JsonObject {
|
|
6
|
+
if (!options) return {}
|
|
7
|
+
const { endpoint: _endpoint, taskEndpoint: _taskEndpoint, timeoutMs: _timeoutMs, baseUrl: _baseUrl, async: _async, ...payload } = options
|
|
8
|
+
return payload
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DashScopeAdapter extends BaseAdapter {
|
|
12
|
+
readonly displayName: string
|
|
13
|
+
readonly envKey = 'DASHSCOPE_API_KEY'
|
|
14
|
+
|
|
15
|
+
constructor(readonly id: 'dashscope' | 'qwencloud', private readonly baseUrl: string, dependencies: ConstructorParameters<typeof BaseAdapter>[0]) {
|
|
16
|
+
super(dependencies)
|
|
17
|
+
this.displayName = id === 'dashscope' ? 'Alibaba Cloud Bailian / DashScope' : 'QwenCloud (DashScope international)'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
models(): ModelDescriptor[] {
|
|
21
|
+
return [
|
|
22
|
+
makeModel(this.id, 'alibaba', 'qwen-image-3.0-pro', ['image.text_to_image']),
|
|
23
|
+
makeModel(this.id, 'alibaba', 'qwen-image-edit-plus', ['image.image_to_image', 'image.edit', 'image.multi_reference']),
|
|
24
|
+
makeModel(this.id, 'alibaba', 'wan3.0-video', ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.edit', 'video.extend', 'video.native_audio']),
|
|
25
|
+
makeModel(this.id, 'alibaba', 'wan2.7-videoedit', ['video.edit', 'video.reference', 'video.native_audio']),
|
|
26
|
+
makeModel(this.id, 'alibaba', 'fun-music-v1', ['audio.generate']),
|
|
27
|
+
makeModel(this.id, 'alibaba', 'qwen3-tts-flash', ['speech.tts']),
|
|
28
|
+
makeModel(this.id, 'alibaba', 'qwen-audio-3.0-asr-flash-filetrans', ['speech.stt']),
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
supports(_capability: Capability): boolean { return true }
|
|
33
|
+
|
|
34
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
35
|
+
this.assertSupport(request)
|
|
36
|
+
const key = this.key(request)
|
|
37
|
+
const endpoint = this.endpoint(request)
|
|
38
|
+
const baseUrl = typeof request.providerOptions?.baseUrl === 'string' ? request.providerOptions.baseUrl.replace(/\/+$/, '') : this.baseUrl
|
|
39
|
+
const payload = await this.payload(request, context.signal)
|
|
40
|
+
const asyncRequest = request.capability.startsWith('video.') || request.capability === 'speech.stt' ||
|
|
41
|
+
(request.capability === 'image.text_to_image' && !/^qwen-image-3/i.test(request.model)) || request.providerOptions?.async === true
|
|
42
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${baseUrl}${endpoint}`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', ...(asyncRequest ? { 'X-DashScope-Async': 'enable' } : {}) },
|
|
45
|
+
body: JSON.stringify(payload), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
46
|
+
})
|
|
47
|
+
const output = (submitted.output && typeof submitted.output === 'object' ? submitted.output : submitted) as Record<string, unknown>
|
|
48
|
+
const taskId = typeof output.task_id === 'string' ? output.task_id : undefined
|
|
49
|
+
if (!taskId) return artifactsOrThrow(this.result(request, submitted, this.kind(request), { text: this.text(submitted) }))
|
|
50
|
+
const taskEndpoint = typeof request.providerOptions?.taskEndpoint === 'string'
|
|
51
|
+
? request.providerOptions.taskEndpoint.replace('{task_id}', encodeURIComponent(taskId))
|
|
52
|
+
: `/api/v1/tasks/${encodeURIComponent(taskId)}`
|
|
53
|
+
const job = new MediaJob<Record<string, unknown>>({
|
|
54
|
+
id: taskId, provider: this.id, signal: context.signal, timeoutMs: this.timeout(request), minDelayMs: 1_000, maxDelayMs: 8_000,
|
|
55
|
+
onProgress: status => context.onProgress?.(`${this.id} ${taskId}: ${status.state}`),
|
|
56
|
+
poll: async signal => {
|
|
57
|
+
const status = await this.http.json<Record<string, unknown>>(`${baseUrl}${taskEndpoint}`, {
|
|
58
|
+
headers: { Authorization: `Bearer ${key}` }, signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
59
|
+
})
|
|
60
|
+
const currentOutput = (status.output && typeof status.output === 'object' ? status.output : status) as Record<string, unknown>
|
|
61
|
+
const state = mapJobState(currentOutput.task_status ?? currentOutput.status)
|
|
62
|
+
return {
|
|
63
|
+
state,
|
|
64
|
+
...(state === 'succeeded' ? { result: status } : {}),
|
|
65
|
+
...(typeof currentOutput.message === 'string' ? { message: currentOutput.message } : {}),
|
|
66
|
+
} satisfies JobStatus<Record<string, unknown>>
|
|
67
|
+
},
|
|
68
|
+
cancel: async signal => {
|
|
69
|
+
await this.http.request(`${baseUrl}${taskEndpoint}/cancel`, {
|
|
70
|
+
method: 'POST', headers: { Authorization: `Bearer ${key}` }, signal,
|
|
71
|
+
provider: this.id, secrets: [key], timeoutMs: 10_000,
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
const completed = await job.wait()
|
|
76
|
+
return artifactsOrThrow(this.result(request, completed, this.kind(request), { jobId: taskId, text: this.text(completed) }))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private endpoint(request: MediaRequest): string {
|
|
80
|
+
if (typeof request.providerOptions?.endpoint === 'string') return request.providerOptions.endpoint
|
|
81
|
+
if (request.capability.startsWith('video.')) return '/api/v1/services/aigc/video-generation/video-synthesis'
|
|
82
|
+
if (request.capability === 'image.text_to_image' && /^qwen-image-3/i.test(request.model)) return '/api/v1/services/aigc/multimodal-generation/generation'
|
|
83
|
+
if (request.capability === 'image.text_to_image') return '/api/v1/services/aigc/text2image/image-synthesis'
|
|
84
|
+
if (request.capability.startsWith('image.')) return '/api/v1/services/aigc/multimodal-generation/generation'
|
|
85
|
+
if (request.capability === 'speech.stt') return '/api/v1/services/audio/asr/transcription'
|
|
86
|
+
if (request.capability === 'audio.generate') return '/api/v1/services/audio/music/generation'
|
|
87
|
+
return '/api/v1/services/aigc/multimodal-generation/generation'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async payload(request: MediaRequest, signal?: AbortSignal): Promise<JsonObject> {
|
|
91
|
+
const inputImage = request.inputImage ? await this.input.asDataUri(request.inputImage, signal) : undefined
|
|
92
|
+
const endImage = request.endImage ? await this.input.asDataUri(request.endImage, signal) : undefined
|
|
93
|
+
const inputVideo = request.inputVideo ? await this.input.asDataUri(request.inputVideo, signal) : undefined
|
|
94
|
+
const inputAudio = request.inputAudio ? await this.input.asDataUri(request.inputAudio, signal) : undefined
|
|
95
|
+
const referenceImages = await dataUris(this.input, request.referenceImages, signal)
|
|
96
|
+
const referenceVideos = await dataUris(this.input, request.referenceVideos, signal)
|
|
97
|
+
const referenceAudios = await dataUris(this.input, request.referenceAudios, signal)
|
|
98
|
+
const parameters: JsonObject = {
|
|
99
|
+
...(request.resolution ? { resolution: request.resolution, size: request.resolution } : {}),
|
|
100
|
+
...(request.aspectRatio ? { ratio: request.aspectRatio, aspect_ratio: request.aspectRatio } : {}),
|
|
101
|
+
...(request.duration ? { duration: request.duration } : {}),
|
|
102
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
103
|
+
...(request.count ? { n: request.count } : {}),
|
|
104
|
+
...(request.generateAudio !== undefined ? { audio: request.generateAudio } : {}),
|
|
105
|
+
}
|
|
106
|
+
let input: JsonObject
|
|
107
|
+
if (request.capability.startsWith('image.') && (request.capability !== 'image.text_to_image' || /^qwen-image-3/i.test(request.model))) {
|
|
108
|
+
input = { messages: [{ role: 'user', content: [
|
|
109
|
+
...[inputImage, ...referenceImages].filter(Boolean).map(image => ({ image })),
|
|
110
|
+
{ text: request.prompt ?? '' },
|
|
111
|
+
] }] }
|
|
112
|
+
} else if (request.capability === 'audio.generate') {
|
|
113
|
+
input = { prompt: request.prompt ?? '', ...(request.text ? { lyrics: request.text } : {}) }
|
|
114
|
+
} else if (request.capability === 'speech.tts') {
|
|
115
|
+
input = { text: request.text ?? request.prompt ?? '', voice: request.voice, language: request.language }
|
|
116
|
+
} else if (request.capability === 'speech.stt') {
|
|
117
|
+
input = { file_urls: inputAudio ? [inputAudio] : [], language_hints: request.language ? [request.language] : undefined }
|
|
118
|
+
} else if (request.capability.startsWith('video.')) {
|
|
119
|
+
const media = [
|
|
120
|
+
...(inputImage ? [{ type: 'first_frame', url: inputImage }] : []),
|
|
121
|
+
...(endImage ? [{ type: 'last_frame', url: endImage }] : []),
|
|
122
|
+
...referenceImages.map(url => ({ type: 'reference_image', url })),
|
|
123
|
+
...(inputVideo ? [{ type: 'reference_video', url: inputVideo }] : []),
|
|
124
|
+
...referenceVideos.map(url => ({ type: 'reference_video', url })),
|
|
125
|
+
...(inputAudio ? [{ type: 'reference_audio', url: inputAudio }] : []),
|
|
126
|
+
...referenceAudios.map(url => ({ type: 'reference_audio', url })),
|
|
127
|
+
]
|
|
128
|
+
input = { ...(request.prompt ? { prompt: request.prompt } : {}), ...(media.length ? { media } : {}) }
|
|
129
|
+
} else {
|
|
130
|
+
input = { ...(request.prompt ? { prompt: request.prompt } : {}) }
|
|
131
|
+
}
|
|
132
|
+
return { model: request.model, input, parameters, ...withoutControlOptions(request.providerOptions) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private kind(request: MediaRequest): 'image' | 'video' | 'audio' | 'text' {
|
|
136
|
+
return request.capability.startsWith('image.') ? 'image' : request.capability.startsWith('video.') ? 'video' : request.capability === 'speech.stt' ? 'text' : 'audio'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private text(payload: unknown): string | undefined {
|
|
140
|
+
if (!payload || typeof payload !== 'object') return undefined
|
|
141
|
+
const output = (payload as Record<string, unknown>).output
|
|
142
|
+
if (!output || typeof output !== 'object') return undefined
|
|
143
|
+
const text = (output as Record<string, unknown>).text
|
|
144
|
+
return typeof text === 'string' ? text : undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private timeout(request: MediaRequest): number {
|
|
148
|
+
const value = request.providerOptions?.timeoutMs
|
|
149
|
+
return typeof value === 'number' && value > 0 ? value : 30 * 60_000
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { MediaJob, mapJobState } from '../media-job.js'
|
|
2
|
+
import { BaseAdapter, artifactsOrThrow, extractText, makeModel, mergeOptions } from './base.js'
|
|
3
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
4
|
+
|
|
5
|
+
interface FalSubmit { request_id?: string; status_url?: string; response_url?: string; cancel_url?: string; status?: string }
|
|
6
|
+
|
|
7
|
+
export class FalAdapter extends BaseAdapter {
|
|
8
|
+
readonly id = 'fal'
|
|
9
|
+
readonly displayName = 'fal.ai'
|
|
10
|
+
readonly envKey = 'FAL_KEY'
|
|
11
|
+
|
|
12
|
+
models(): ModelDescriptor[] {
|
|
13
|
+
return [
|
|
14
|
+
makeModel(this.id, 'fal-partner', '<fal endpoint slug>', [
|
|
15
|
+
'image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference',
|
|
16
|
+
'video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference',
|
|
17
|
+
'video.edit', 'video.extend', 'video.native_audio', 'audio.generate', 'speech.tts', 'speech.stt',
|
|
18
|
+
], 'fal schemas are endpoint-specific; providerOptions passes model-native fields'),
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
supports(): boolean { return true }
|
|
23
|
+
|
|
24
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
25
|
+
const key = this.key(request)
|
|
26
|
+
const payload = await this.payload(request, key, context.signal)
|
|
27
|
+
const endpoint = `https://queue.fal.run/${request.model.replace(/^\/+/, '')}`
|
|
28
|
+
const submitted = await this.http.json<FalSubmit>(endpoint, {
|
|
29
|
+
method: 'POST', headers: { Authorization: `Key ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
30
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
31
|
+
})
|
|
32
|
+
if (!submitted.request_id && !submitted.response_url) {
|
|
33
|
+
return artifactsOrThrow(this.result(request, submitted, this.kind(request), {
|
|
34
|
+
...(request.capability === 'speech.stt' ? { text: extractText(submitted) } : {}),
|
|
35
|
+
}))
|
|
36
|
+
}
|
|
37
|
+
const requestId = submitted.request_id ?? 'fal-job'
|
|
38
|
+
const statusUrl = submitted.status_url ?? `${endpoint}/requests/${encodeURIComponent(requestId)}/status`
|
|
39
|
+
const responseUrl = submitted.response_url ?? `${endpoint}/requests/${encodeURIComponent(requestId)}`
|
|
40
|
+
const cancelUrl = submitted.cancel_url ?? `${endpoint}/requests/${encodeURIComponent(requestId)}/cancel`
|
|
41
|
+
|
|
42
|
+
const job = new MediaJob<unknown>({
|
|
43
|
+
id: requestId,
|
|
44
|
+
provider: this.id,
|
|
45
|
+
signal: context.signal,
|
|
46
|
+
timeoutMs: this.timeout(request),
|
|
47
|
+
minDelayMs: 800,
|
|
48
|
+
maxDelayMs: 5_000,
|
|
49
|
+
onProgress: status => context.onProgress?.(`fal ${requestId}: ${status.state}`),
|
|
50
|
+
poll: async signal => {
|
|
51
|
+
const status = await this.http.json<Record<string, unknown>>(statusUrl, {
|
|
52
|
+
headers: { Authorization: `Key ${key}` }, signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
53
|
+
})
|
|
54
|
+
const state = mapJobState(status.status)
|
|
55
|
+
if (state === 'succeeded') {
|
|
56
|
+
const result = await this.http.json<unknown>(responseUrl, {
|
|
57
|
+
headers: { Authorization: `Key ${key}` }, signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
58
|
+
})
|
|
59
|
+
return { state, result } satisfies JobStatus<unknown>
|
|
60
|
+
}
|
|
61
|
+
return { state, ...(typeof status.error === 'string' ? { message: status.error } : {}) } satisfies JobStatus<unknown>
|
|
62
|
+
},
|
|
63
|
+
cancel: async signal => {
|
|
64
|
+
await this.http.request(cancelUrl, {
|
|
65
|
+
method: 'PUT', headers: { Authorization: `Key ${key}` }, signal, provider: this.id, secrets: [key], timeoutMs: 10_000,
|
|
66
|
+
})
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
const data = await job.wait()
|
|
70
|
+
return artifactsOrThrow(this.result(request, data, this.kind(request), {
|
|
71
|
+
jobId: requestId,
|
|
72
|
+
...(request.capability === 'speech.stt' ? { text: extractText(data) } : {}),
|
|
73
|
+
}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async payload(request: MediaRequest, key: string, signal?: AbortSignal): Promise<JsonObject> {
|
|
77
|
+
const resolveMany = async (sources: readonly string[] | undefined) => Promise.all((sources ?? []).map(source => this.asFalUrl(source, key, signal)))
|
|
78
|
+
const refs = await resolveMany(request.referenceImages)
|
|
79
|
+
const videos = await resolveMany(request.referenceVideos)
|
|
80
|
+
const audios = await resolveMany(request.referenceAudios)
|
|
81
|
+
const imageUrl = request.inputImage ? await this.asFalUrl(request.inputImage, key, signal) : undefined
|
|
82
|
+
const endImageUrl = request.endImage ? await this.asFalUrl(request.endImage, key, signal) : undefined
|
|
83
|
+
const videoUrl = request.inputVideo ? await this.asFalUrl(request.inputVideo, key, signal) : undefined
|
|
84
|
+
const audioUrl = request.inputAudio ? await this.asFalUrl(request.inputAudio, key, signal) : undefined
|
|
85
|
+
return mergeOptions({
|
|
86
|
+
...(request.prompt ? { prompt: request.prompt } : {}),
|
|
87
|
+
...(request.text ? { text: request.text } : {}),
|
|
88
|
+
...(imageUrl ? { image_url: imageUrl } : {}),
|
|
89
|
+
...(endImageUrl ? { end_image_url: endImageUrl } : {}),
|
|
90
|
+
...(refs.length ? { reference_image_urls: refs } : {}),
|
|
91
|
+
...(videos.length ? { reference_video_urls: videos } : {}),
|
|
92
|
+
...(audios.length ? { reference_audio_urls: audios } : {}),
|
|
93
|
+
...(videoUrl ? { video_url: videoUrl } : {}),
|
|
94
|
+
...(audioUrl ? { audio_url: audioUrl } : {}),
|
|
95
|
+
...(request.duration ? { duration: request.duration } : {}),
|
|
96
|
+
...(request.resolution ? { resolution: request.resolution } : {}),
|
|
97
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
98
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
99
|
+
...(request.generateAudio !== undefined ? { generate_audio: request.generateAudio } : {}),
|
|
100
|
+
...(request.voice ? { voice: request.voice } : {}),
|
|
101
|
+
...(request.language ? { language: request.language } : {}),
|
|
102
|
+
...(request.responseFormat ? { output_format: request.responseFormat } : {}),
|
|
103
|
+
...(request.operation ? { operation: request.operation } : {}),
|
|
104
|
+
}, request.providerOptions)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async asFalUrl(source: string, key: string, signal?: AbortSignal): Promise<string> {
|
|
108
|
+
const resolved = await this.input.resolve(source)
|
|
109
|
+
if (resolved.kind === 'url') return resolved.url ?? source
|
|
110
|
+
const bytes = await this.input.bytes(resolved, signal)
|
|
111
|
+
const upload = await this.http.json<{ upload_url?: string; file_url?: string }>('https://rest.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3', {
|
|
112
|
+
method: 'POST', headers: { Authorization: `Key ${key}`, 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ content_type: resolved.mimeType, file_name: resolved.fileName, file_size: bytes.byteLength }),
|
|
114
|
+
signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
115
|
+
})
|
|
116
|
+
if (!upload.upload_url || !upload.file_url) throw new Error('fal CDN upload initiation returned no upload_url/file_url')
|
|
117
|
+
await this.http.request(upload.upload_url, {
|
|
118
|
+
method: 'PUT', headers: { 'Content-Type': resolved.mimeType }, body: new Blob([Uint8Array.from(bytes)], { type: resolved.mimeType }), signal,
|
|
119
|
+
provider: this.id, timeoutMs: 120_000,
|
|
120
|
+
})
|
|
121
|
+
return upload.file_url
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private kind(request: MediaRequest): 'image' | 'video' | 'audio' | 'text' {
|
|
125
|
+
if (request.capability.startsWith('image.')) return 'image'
|
|
126
|
+
if (request.capability.startsWith('video.')) return 'video'
|
|
127
|
+
if (request.capability === 'speech.stt') return 'text'
|
|
128
|
+
return 'audio'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private timeout(request: MediaRequest): number {
|
|
132
|
+
const value = request.providerOptions?.timeoutMs
|
|
133
|
+
return typeof value === 'number' && value > 0 ? value : 30 * 60_000
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { GoogleAuth } from 'google-auth-library'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { MediaError } from '../errors.js'
|
|
4
|
+
import { MediaJob } from '../media-job.js'
|
|
5
|
+
import { BaseAdapter, artifactsOrThrow, makeModel } from './base.js'
|
|
6
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
7
|
+
import type { AdapterDependencies } from './base.js'
|
|
8
|
+
|
|
9
|
+
const GOOGLE_CAPS: Capability[] = [
|
|
10
|
+
'image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference',
|
|
11
|
+
'video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference',
|
|
12
|
+
'video.extend', 'video.native_audio', 'audio.generate', 'speech.tts', 'speech.stt',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export class GoogleMediaAdapter extends BaseAdapter {
|
|
16
|
+
readonly displayName: string
|
|
17
|
+
readonly envKey: string | undefined
|
|
18
|
+
|
|
19
|
+
constructor(readonly id: 'gemini' | 'vertex', dependencies: AdapterDependencies) {
|
|
20
|
+
super(dependencies)
|
|
21
|
+
this.displayName = id === 'gemini' ? 'Google Gemini API' : 'Google Vertex AI (ADC)'
|
|
22
|
+
this.envKey = id === 'gemini' ? 'GEMINI_API_KEY' : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
models(): ModelDescriptor[] {
|
|
26
|
+
return [
|
|
27
|
+
makeModel(this.id, 'google', 'gemini-2.5-flash-image', ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']),
|
|
28
|
+
makeModel(this.id, 'google', 'imagen-4.0-generate-001', ['image.text_to_image']),
|
|
29
|
+
makeModel(this.id, 'google', 'veo-3.1-generate-preview', ['video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.extend', 'video.native_audio']),
|
|
30
|
+
makeModel(this.id, 'google', 'gemini-2.5-flash-preview-tts', ['speech.tts']),
|
|
31
|
+
makeModel(this.id, 'google', 'gemini-2.5-flash', ['speech.stt']),
|
|
32
|
+
makeModel(this.id, 'google', 'lyria-3.5', ['audio.generate']),
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
supports(capability: Capability): boolean { return GOOGLE_CAPS.includes(capability) }
|
|
37
|
+
|
|
38
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
39
|
+
this.assertSupport(request)
|
|
40
|
+
if (request.capability.startsWith('video.')) return this.longRunning(request, context)
|
|
41
|
+
if (request.capability === 'audio.generate' || (request.capability === 'image.text_to_image' && /^imagen-/i.test(request.model))) {
|
|
42
|
+
return this.predictMedia(request, context)
|
|
43
|
+
}
|
|
44
|
+
return this.generateContent(request, context)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async generateContent(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
48
|
+
const parts: JsonObject[] = []
|
|
49
|
+
if (request.prompt || request.text) parts.push({ text: request.text ?? request.prompt })
|
|
50
|
+
const sources = [request.inputImage, ...(request.referenceImages ?? []), request.inputAudio]
|
|
51
|
+
.filter((value): value is string => Boolean(value))
|
|
52
|
+
for (const source of sources) parts.push({ inlineData: await this.input.asInlineData(source, context.signal) })
|
|
53
|
+
if (request.capability === 'speech.stt' && !request.prompt) parts.unshift({ text: 'Transcribe this audio accurately. Return only the transcript.' })
|
|
54
|
+
const generationConfig: JsonObject = request.capability === 'speech.tts'
|
|
55
|
+
? {
|
|
56
|
+
responseModalities: ['AUDIO'],
|
|
57
|
+
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: request.voice ?? 'Kore' } } },
|
|
58
|
+
}
|
|
59
|
+
: request.capability.startsWith('image.')
|
|
60
|
+
? {
|
|
61
|
+
responseModalities: ['TEXT', 'IMAGE'],
|
|
62
|
+
...(request.aspectRatio || request.resolution ? { imageConfig: {
|
|
63
|
+
...(request.aspectRatio ? { aspectRatio: request.aspectRatio } : {}),
|
|
64
|
+
...(request.resolution ? { imageSize: request.resolution } : {}),
|
|
65
|
+
} } : {}),
|
|
66
|
+
}
|
|
67
|
+
: { responseModalities: ['TEXT'] }
|
|
68
|
+
const body = { contents: [{ role: 'user', parts }], generationConfig, ...googleNativeOptions(request.providerOptions) }
|
|
69
|
+
const { url, headers } = await this.modelRequest(request, "generateContent")
|
|
70
|
+
const payload = await this.http.json<Record<string, unknown>>(url, {
|
|
71
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
72
|
+
signal: context.signal, provider: this.id, secrets: this.secrets(request), timeoutMs: 180_000,
|
|
73
|
+
})
|
|
74
|
+
const kind = request.capability.startsWith('image.') ? 'image' : request.capability === 'speech.tts' ? 'audio' : 'text'
|
|
75
|
+
const text = findText(payload)
|
|
76
|
+
const result = this.result(request, payload, kind, { ...(text ? { text } : {}), ...(this.id === 'gemini' ? { headers } : {}) })
|
|
77
|
+
return request.capability === 'speech.stt' ? result : artifactsOrThrow(result)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async predictMedia(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
81
|
+
const kind = request.capability === 'audio.generate' ? 'audio' : 'image'
|
|
82
|
+
const body = request.capability === 'audio.generate'
|
|
83
|
+
? { instances: [{ prompt: request.prompt ?? request.text ?? '' }], parameters: googleNativeOptions(request.providerOptions) }
|
|
84
|
+
: { instances: [{ prompt: request.prompt ?? '' }], parameters: {
|
|
85
|
+
sampleCount: request.count ?? 1,
|
|
86
|
+
...(request.aspectRatio ? { aspectRatio: request.aspectRatio } : {}),
|
|
87
|
+
...(request.resolution ? { sampleImageSize: request.resolution } : {}),
|
|
88
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
89
|
+
...googleNativeOptions(request.providerOptions),
|
|
90
|
+
} }
|
|
91
|
+
const { url, headers } = await this.modelRequest(request, "predict")
|
|
92
|
+
const payload = await this.http.json<Record<string, unknown>>(url, {
|
|
93
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
94
|
+
signal: context.signal, provider: this.id, secrets: this.secrets(request), timeoutMs: 180_000,
|
|
95
|
+
})
|
|
96
|
+
return artifactsOrThrow(this.result(request, payload, kind, { headers }))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async longRunning(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
100
|
+
const instance: JsonObject = { ...(request.prompt ? { prompt: request.prompt } : {}) }
|
|
101
|
+
if (request.inputImage) instance.image = await this.googleMedia(request.inputImage, context.signal)
|
|
102
|
+
if (request.endImage) instance.lastFrame = await this.googleMedia(request.endImage, context.signal)
|
|
103
|
+
if (request.inputVideo) instance.video = await this.googleMedia(request.inputVideo, context.signal)
|
|
104
|
+
if (request.referenceImages?.length) {
|
|
105
|
+
instance.referenceImages = await Promise.all(request.referenceImages.map(async source => ({
|
|
106
|
+
image: await this.googleMedia(source, context.signal), referenceType: 'asset',
|
|
107
|
+
})))
|
|
108
|
+
}
|
|
109
|
+
const parameters: JsonObject = {
|
|
110
|
+
...(request.aspectRatio ? { aspectRatio: request.aspectRatio } : {}),
|
|
111
|
+
...(request.resolution ? { resolution: request.resolution } : {}),
|
|
112
|
+
...(request.duration ? { durationSeconds: request.duration } : {}),
|
|
113
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
114
|
+
...(request.generateAudio !== undefined ? { generateAudio: request.generateAudio } : {}),
|
|
115
|
+
...(request.count ? { sampleCount: request.count } : {}),
|
|
116
|
+
}
|
|
117
|
+
const body = { instances: [instance], parameters: { ...parameters, ...googleNativeOptions(request.providerOptions) } }
|
|
118
|
+
const { url, headers, operationsBase } = await this.modelRequest(request, "predictLongRunning")
|
|
119
|
+
const submitted = await this.http.json<Record<string, unknown>>(url, {
|
|
120
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
121
|
+
signal: context.signal, provider: this.id, secrets: this.secrets(request), timeoutMs: 60_000,
|
|
122
|
+
})
|
|
123
|
+
const name = typeof submitted.name === 'string' ? submitted.name : undefined
|
|
124
|
+
if (!name) return artifactsOrThrow(this.result(request, submitted, request.capability === 'audio.generate' ? 'audio' : 'video', { headers }))
|
|
125
|
+
const operationUrl = `${operationsBase}/${name.replace(/^\/+/, '')}`
|
|
126
|
+
const vertexPollUrl = url.replace(/:predictLongRunning$/, ':fetchPredictOperation')
|
|
127
|
+
const job = new MediaJob<Record<string, unknown>>({
|
|
128
|
+
id: name, provider: this.id, signal: context.signal, timeoutMs: timeout(request), minDelayMs: 5_000, maxDelayMs: 15_000,
|
|
129
|
+
onProgress: () => context.onProgress?.(`${this.id} operation ${name}: running`),
|
|
130
|
+
poll: async signal => {
|
|
131
|
+
const status = this.id === 'vertex'
|
|
132
|
+
? await this.http.json<Record<string, unknown>>(vertexPollUrl, {
|
|
133
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify({ operationName: name }), signal, provider: this.id, secrets: this.secrets(request), timeoutMs: 30_000,
|
|
135
|
+
})
|
|
136
|
+
: await this.http.json<Record<string, unknown>>(operationUrl, {
|
|
137
|
+
headers, signal, provider: this.id, secrets: this.secrets(request), timeoutMs: 30_000,
|
|
138
|
+
})
|
|
139
|
+
if (status.error) return { state: 'failed', message: JSON.stringify(status.error) }
|
|
140
|
+
return status.done === true
|
|
141
|
+
? { state: 'succeeded', result: status }
|
|
142
|
+
: { state: 'running' }
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
const completed = await job.wait()
|
|
146
|
+
return artifactsOrThrow(this.result(request, completed, request.capability === 'audio.generate' ? 'audio' : 'video', { jobId: name, headers }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async googleMedia(source: string, signal?: AbortSignal): Promise<JsonObject> {
|
|
150
|
+
const resolved = await this.input.resolve(source)
|
|
151
|
+
if (resolved.kind === 'url') return { uri: resolved.url, mimeType: resolved.mimeType }
|
|
152
|
+
const inline = await this.input.asInlineData(source, signal)
|
|
153
|
+
return this.id === 'gemini'
|
|
154
|
+
? { inlineData: { data: inline.data, mimeType: inline.mimeType } }
|
|
155
|
+
: { bytesBase64Encoded: inline.data, mimeType: inline.mimeType }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async modelRequest(request: MediaRequest, method: string): Promise<{ url: string; headers: Record<string, string>; operationsBase: string }> {
|
|
159
|
+
if (this.id === 'gemini') {
|
|
160
|
+
const key = this.key(request)
|
|
161
|
+
const base = 'https://generativelanguage.googleapis.com/v1beta'
|
|
162
|
+
return { url: `${base}/models/${encodeURIComponent(request.model)}:${method}`, headers: { 'x-goog-api-key': key }, operationsBase: base }
|
|
163
|
+
}
|
|
164
|
+
const options = request.providerOptions;
|
|
165
|
+
const configuredFile = typeof options?.credentialsFile === "string" ? options.credentialsFile : undefined
|
|
166
|
+
const rawKeyFilename = configuredFile ?? this.env.VERTEX_CREDENTIALS_FILE ?? this.env.GOOGLE_APPLICATION_CREDENTIALS
|
|
167
|
+
const keyFilename = rawKeyFilename?.startsWith('file://') ? fileURLToPath(rawKeyFilename) : rawKeyFilename
|
|
168
|
+
const auth = new GoogleAuth({
|
|
169
|
+
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
|
170
|
+
...(keyFilename ? { keyFilename } : {}),
|
|
171
|
+
})
|
|
172
|
+
const configuredProject = typeof options?.project === 'string' ? options.project : undefined
|
|
173
|
+
const project = configuredProject ?? this.env.GOOGLE_CLOUD_PROJECT ?? this.env.GCLOUD_PROJECT ?? await auth.getProjectId()
|
|
174
|
+
const configuredLocation = typeof options?.location === 'string' ? options.location : undefined
|
|
175
|
+
const location = configuredLocation ?? this.env.GOOGLE_CLOUD_LOCATION ?? 'us-central1'
|
|
176
|
+
if (!project) throw new MediaError('CONFIG', 'Vertex AI requires a project id from providerOptions.vertex.project, environment, ADC, or the credentials JSON', { provider: this.id })
|
|
177
|
+
const base = `https://${location}-aiplatform.googleapis.com/v1`
|
|
178
|
+
const resource = `projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/publishers/google/models/${encodeURIComponent(request.model)}`
|
|
179
|
+
const authHeaders = await auth.getRequestHeaders(`${base}/${resource}:${method}`)
|
|
180
|
+
const headers: Record<string, string> = {}
|
|
181
|
+
for (const [key, value] of authHeaders.entries()) headers[key] = value
|
|
182
|
+
return { url: `${base}/${resource}:${method}`, headers, operationsBase: base }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private secrets(request: MediaRequest): string[] {
|
|
186
|
+
const configKey = typeof request.providerOptions?.apiKey === 'string' ? request.providerOptions.apiKey : undefined
|
|
187
|
+
const envKey = this.envKey && this.env[this.envKey] ? this.env[this.envKey] as string : undefined
|
|
188
|
+
return [configKey, envKey].filter((val): val is string => Boolean(val))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function findText(payload: unknown): string | undefined {
|
|
193
|
+
if (!payload || typeof payload !== 'object') return undefined
|
|
194
|
+
const candidates = (payload as { candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }> }).candidates
|
|
195
|
+
const values = candidates?.flatMap(candidate => candidate.content?.parts?.map(part => part.text).filter((text): text is string => Boolean(text)) ?? []) ?? []
|
|
196
|
+
return values.length ? values.join('\n') : undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function googleNativeOptions(options?: JsonObject): JsonObject {
|
|
200
|
+
if (!options) return {}
|
|
201
|
+
const {
|
|
202
|
+
credentialsFile: _credentialsFile,
|
|
203
|
+
project: _project,
|
|
204
|
+
location: _location,
|
|
205
|
+
timeoutMs: _timeoutMs,
|
|
206
|
+
...native
|
|
207
|
+
} = options
|
|
208
|
+
return native
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function timeout(request: MediaRequest): number {
|
|
212
|
+
const value = request.providerOptions?.timeoutMs
|
|
213
|
+
return typeof value === 'number' && value > 0 ? value : 40 * 60_000
|
|
214
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { BaseAdapter, artifactsOrThrow, bearerHeaders, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
2
|
+
import type { AdapterContext, AdapterResult, Capability, MediaRequest, ModelDescriptor } from '../types.js'
|
|
3
|
+
|
|
4
|
+
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
5
|
+
const SPEECH_CAPS: Capability[] = ['speech.tts', 'speech.stt']
|
|
6
|
+
|
|
7
|
+
export class OpenAIAdapter extends BaseAdapter {
|
|
8
|
+
readonly id = 'openai'
|
|
9
|
+
readonly displayName = 'OpenAI API'
|
|
10
|
+
readonly envKey = 'OPENAI_API_KEY'
|
|
11
|
+
private readonly baseUrl = 'https://api.openai.com/v1'
|
|
12
|
+
|
|
13
|
+
models(): ModelDescriptor[] {
|
|
14
|
+
return [
|
|
15
|
+
makeModel(this.id, 'openai', 'gpt-image-2', IMAGE_CAPS),
|
|
16
|
+
makeModel(this.id, 'openai', 'gpt-image-1.5', IMAGE_CAPS),
|
|
17
|
+
makeModel(this.id, 'openai', 'dall-e-3', ['image.text_to_image']),
|
|
18
|
+
makeModel(this.id, 'openai', 'gpt-4o-mini-tts', ['speech.tts']),
|
|
19
|
+
makeModel(this.id, 'openai', 'gpt-4o-transcribe', ['speech.stt']),
|
|
20
|
+
makeModel(this.id, 'openai', 'whisper-1', ['speech.stt']),
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
supports(capability: Capability, model: string): boolean {
|
|
25
|
+
if (capability.startsWith('video.') || capability === 'audio.generate') return false
|
|
26
|
+
if (capability.startsWith('image.')) return /(?:gpt-image|dall-e)/i.test(model)
|
|
27
|
+
if (capability === 'speech.tts') return /tts/i.test(model)
|
|
28
|
+
if (capability === 'speech.stt') return /(?:transcribe|whisper)/i.test(model)
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
33
|
+
this.assertSupport(request)
|
|
34
|
+
if (request.capability === 'speech.tts') return this.tts(request, context)
|
|
35
|
+
if (request.capability === 'speech.stt') return this.stt(request, context)
|
|
36
|
+
if (request.capability === 'image.text_to_image') return this.generateImage(request, context)
|
|
37
|
+
return this.editImage(request, context)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async generateImage(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
41
|
+
const key = this.key(request)
|
|
42
|
+
const payload = mergeOptions({
|
|
43
|
+
model: request.model,
|
|
44
|
+
prompt: requirePrompt(request),
|
|
45
|
+
n: request.count ?? 1,
|
|
46
|
+
...(request.resolution ? { size: request.resolution } : {}),
|
|
47
|
+
}, request.providerOptions)
|
|
48
|
+
const data = await this.http.json<unknown>(`${this.baseUrl}/images/generations`, {
|
|
49
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }),
|
|
50
|
+
body: JSON.stringify(payload), signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
51
|
+
})
|
|
52
|
+
return artifactsOrThrow(this.result(request, data, 'image'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async editImage(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
56
|
+
const key = this.key(request)
|
|
57
|
+
const sources = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
58
|
+
if (sources.length === 0) throw new Error('OpenAI image edit requires inputImage or referenceImages')
|
|
59
|
+
if (sources.length > 16) throw new Error('OpenAI GPT Image editing supports at most 16 source images')
|
|
60
|
+
const form = new FormData()
|
|
61
|
+
form.set('model', request.model)
|
|
62
|
+
form.set('prompt', requirePrompt(request))
|
|
63
|
+
form.set('n', String(request.count ?? 1))
|
|
64
|
+
if (request.resolution) form.set('size', request.resolution)
|
|
65
|
+
for (const source of sources) {
|
|
66
|
+
const file = await this.input.asBlob(source, context.signal)
|
|
67
|
+
form.append('image[]', file.blob, file.fileName)
|
|
68
|
+
}
|
|
69
|
+
if (request.mask) {
|
|
70
|
+
const mask = await this.input.asBlob(request.mask, context.signal)
|
|
71
|
+
form.set('mask', mask.blob, mask.fileName)
|
|
72
|
+
}
|
|
73
|
+
for (const [name, value] of Object.entries(request.providerOptions ?? {})) {
|
|
74
|
+
if (value !== undefined) form.set(name, typeof value === 'string' ? value : JSON.stringify(value))
|
|
75
|
+
}
|
|
76
|
+
const data = await this.http.json<unknown>(`${this.baseUrl}/images/edits`, {
|
|
77
|
+
method: 'POST', headers: bearerHeaders(key), body: form, signal: context.signal,
|
|
78
|
+
provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
79
|
+
})
|
|
80
|
+
return artifactsOrThrow(this.result(request, data, 'image'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async tts(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
84
|
+
const key = this.key(request)
|
|
85
|
+
const format = request.responseFormat ?? 'mp3'
|
|
86
|
+
const payload = mergeOptions({
|
|
87
|
+
model: request.model,
|
|
88
|
+
input: request.text ?? request.prompt ?? '',
|
|
89
|
+
voice: request.voice ?? 'alloy',
|
|
90
|
+
response_format: format,
|
|
91
|
+
}, request.providerOptions)
|
|
92
|
+
const response = await this.http.request(`${this.baseUrl}/audio/speech`, {
|
|
93
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
94
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
95
|
+
})
|
|
96
|
+
const bytes = Buffer.from(await response.arrayBuffer()).toString('base64')
|
|
97
|
+
return this.result(request, { b64_json: bytes, mime_type: response.headers.get('content-type') ?? `audio/${format}` }, 'audio')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async stt(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
101
|
+
if (!request.inputAudio) throw new Error('OpenAI transcription requires inputAudio')
|
|
102
|
+
const key = this.key(request)
|
|
103
|
+
const audio = await this.input.asBlob(request.inputAudio, context.signal)
|
|
104
|
+
const form = new FormData()
|
|
105
|
+
form.set('file', audio.blob, audio.fileName)
|
|
106
|
+
form.set('model', request.model)
|
|
107
|
+
if (request.language) form.set('language', request.language)
|
|
108
|
+
for (const [name, value] of Object.entries(request.providerOptions ?? {})) {
|
|
109
|
+
if (value !== undefined) form.set(name, typeof value === 'string' ? value : JSON.stringify(value))
|
|
110
|
+
}
|
|
111
|
+
const response = await this.http.json<{ text?: string }>(`${this.baseUrl}/audio/transcriptions`, {
|
|
112
|
+
method: 'POST', headers: bearerHeaders(key), body: form, signal: context.signal,
|
|
113
|
+
provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
114
|
+
})
|
|
115
|
+
return this.result(request, {}, 'text', { text: response.text ?? '' })
|
|
116
|
+
}
|
|
117
|
+
}
|