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.
@@ -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
+ }