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,112 @@
|
|
|
1
|
+
import { MediaError } from '../errors.js'
|
|
2
|
+
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
|
+
import { BaseAdapter, artifactsOrThrow, bearerHeaders, dataUris, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor, RemoteArtifact } from '../types.js'
|
|
5
|
+
|
|
6
|
+
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
7
|
+
const VIDEO_CAPS: Capability[] = ['video.text_to_video', 'video.image_to_video', 'video.reference', 'video.native_audio']
|
|
8
|
+
|
|
9
|
+
export class OpenRouterAdapter extends BaseAdapter {
|
|
10
|
+
readonly id = 'openrouter'
|
|
11
|
+
readonly displayName = 'OpenRouter'
|
|
12
|
+
readonly envKey = 'OPENROUTER_API_KEY'
|
|
13
|
+
private readonly baseUrl = 'https://openrouter.ai/api/v1'
|
|
14
|
+
|
|
15
|
+
models(): ModelDescriptor[] {
|
|
16
|
+
return [
|
|
17
|
+
makeModel(this.id, 'multi-vendor', '<OpenRouter image model>', IMAGE_CAPS, 'Use /images/models endpoint externally to choose a current image model'),
|
|
18
|
+
makeModel(this.id, 'multi-vendor', '<OpenRouter video model>', VIDEO_CAPS, 'Availability and parameters vary by upstream endpoint'),
|
|
19
|
+
makeModel(this.id, 'multi-vendor', '<OpenRouter TTS model>', ['speech.tts']),
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
supports(capability: Capability): boolean {
|
|
24
|
+
return IMAGE_CAPS.includes(capability) || VIDEO_CAPS.includes(capability) || capability === 'speech.tts'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
28
|
+
this.assertSupport(request)
|
|
29
|
+
if (request.capability.startsWith('image.')) return this.image(request, context)
|
|
30
|
+
if (request.capability.startsWith('video.')) return this.video(request, context)
|
|
31
|
+
return this.tts(request, context)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async image(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
35
|
+
const key = this.key(request)
|
|
36
|
+
const references = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
37
|
+
const inputReferences = await dataUris(this.input, references, context.signal)
|
|
38
|
+
if (inputReferences.length > 16) throw new MediaError('INPUT', 'OpenRouter Images API supports at most 16 input references', { provider: this.id })
|
|
39
|
+
const payload = mergeOptions({
|
|
40
|
+
model: request.model,
|
|
41
|
+
prompt: requirePrompt(request),
|
|
42
|
+
n: request.count ?? 1,
|
|
43
|
+
...(inputReferences.length ? { input_references: inputReferences } : {}),
|
|
44
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
45
|
+
...(request.resolution ? { resolution: request.resolution } : {}),
|
|
46
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
47
|
+
}, request.providerOptions)
|
|
48
|
+
const data = await this.http.json<unknown>(`${this.baseUrl}/images`, {
|
|
49
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
50
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 180_000,
|
|
51
|
+
})
|
|
52
|
+
return artifactsOrThrow(this.result(request, data, 'image'))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async tts(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
56
|
+
const key = this.key(request)
|
|
57
|
+
const format = request.responseFormat ?? 'mp3'
|
|
58
|
+
const payload = mergeOptions({
|
|
59
|
+
model: request.model, input: request.text ?? request.prompt ?? '', voice: request.voice ?? 'alloy', response_format: format,
|
|
60
|
+
}, request.providerOptions)
|
|
61
|
+
const response = await this.http.request(`${this.baseUrl}/audio/speech`, {
|
|
62
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
63
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 180_000,
|
|
64
|
+
})
|
|
65
|
+
const base64 = Buffer.from(await response.arrayBuffer()).toString('base64')
|
|
66
|
+
return this.result(request, { b64_json: base64, mime_type: response.headers.get('content-type') ?? `audio/${format}` }, 'audio')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async video(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
70
|
+
const key = this.key(request)
|
|
71
|
+
const refs = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
72
|
+
const inputReferences = await dataUris(this.input, refs, context.signal)
|
|
73
|
+
const payload: JsonObject = mergeOptions({
|
|
74
|
+
model: request.model,
|
|
75
|
+
prompt: requirePrompt(request),
|
|
76
|
+
...(inputReferences.length ? { input_references: inputReferences } : {}),
|
|
77
|
+
...(request.duration ? { duration: request.duration } : {}),
|
|
78
|
+
...(request.resolution ? { resolution: request.resolution } : {}),
|
|
79
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
80
|
+
...(request.generateAudio !== undefined ? { generate_audio: request.generateAudio } : {}),
|
|
81
|
+
}, request.providerOptions)
|
|
82
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/videos`, {
|
|
83
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
84
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
85
|
+
})
|
|
86
|
+
const jobId = typeof submitted.id === 'string' ? submitted.id : typeof submitted.job_id === 'string' ? submitted.job_id : undefined
|
|
87
|
+
if (!jobId) return artifactsOrThrow(this.result(request, submitted, 'video'))
|
|
88
|
+
const job = new MediaJob<Record<string, unknown>>({
|
|
89
|
+
id: jobId, provider: this.id, signal: context.signal, timeoutMs: timeout(request), minDelayMs: 5_000, maxDelayMs: 30_000,
|
|
90
|
+
onProgress: status => context.onProgress?.(`OpenRouter ${jobId}: ${status.state}`),
|
|
91
|
+
poll: async signal => {
|
|
92
|
+
const status = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/videos/${encodeURIComponent(jobId)}`, {
|
|
93
|
+
headers: bearerHeaders(key), signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
94
|
+
})
|
|
95
|
+
const state = mapJobState(status.status)
|
|
96
|
+
return { state, ...(state === 'succeeded' ? { result: status } : {}), ...(typeof status.error === 'string' ? { message: status.error } : {}) } satisfies JobStatus<Record<string, unknown>>
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
const completed = await job.wait()
|
|
100
|
+
const result = this.result(request, completed, 'video', { jobId })
|
|
101
|
+
if (result.artifacts.length === 0) {
|
|
102
|
+
const artifact: RemoteArtifact = { kind: 'video', url: `${this.baseUrl}/videos/${encodeURIComponent(jobId)}/content`, headers: bearerHeaders(key) }
|
|
103
|
+
result.artifacts.push(artifact)
|
|
104
|
+
}
|
|
105
|
+
return result
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function timeout(request: MediaRequest): number {
|
|
110
|
+
const value = request.providerOptions?.timeoutMs
|
|
111
|
+
return typeof value === 'number' && value > 0 ? value : 40 * 60_000
|
|
112
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { MediaError } from '../errors.js'
|
|
2
|
+
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
|
+
import { BaseAdapter, artifactsOrThrow, bearerHeaders, dataUris, makeModel, mergeOptions, requirePrompt } from './base.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
5
|
+
|
|
6
|
+
const IMAGE_CAPS: Capability[] = ['image.text_to_image', 'image.image_to_image', 'image.edit', 'image.multi_reference']
|
|
7
|
+
const VIDEO_CAPS: Capability[] = ['video.text_to_video', 'video.image_to_video', 'video.reference', 'video.edit', 'video.extend', 'video.native_audio']
|
|
8
|
+
|
|
9
|
+
export class XAIAdapter extends BaseAdapter {
|
|
10
|
+
readonly id = 'xai'
|
|
11
|
+
readonly displayName = 'xAI / Grok Imagine'
|
|
12
|
+
readonly envKey = 'XAI_API_KEY'
|
|
13
|
+
private readonly baseUrl = 'https://api.x.ai/v1'
|
|
14
|
+
|
|
15
|
+
models(): ModelDescriptor[] {
|
|
16
|
+
return [
|
|
17
|
+
makeModel(this.id, 'xai', 'grok-imagine-image-2.0', IMAGE_CAPS),
|
|
18
|
+
makeModel(this.id, 'xai', 'grok-imagine-video-1.5', VIDEO_CAPS.filter(capability => capability !== 'video.edit' && capability !== 'video.extend'), 'T2V/I2V supports 1080p; reference-to-video is capped at 720p'),
|
|
19
|
+
makeModel(this.id, 'xai', 'grok-imagine-video', ['video.edit', 'video.extend'], 'Current official edit/extend examples use this model id'),
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
supports(capability: Capability, model: string): boolean {
|
|
24
|
+
if (capability.startsWith('image.')) return /image/i.test(model)
|
|
25
|
+
if (capability.startsWith('video.')) {
|
|
26
|
+
if (!/video/i.test(model) || !VIDEO_CAPS.includes(capability)) return false
|
|
27
|
+
if (/video-1\.5$/i.test(model) && ['video.edit', 'video.extend'].includes(capability)) return false
|
|
28
|
+
return true
|
|
29
|
+
}
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
34
|
+
this.assertSupport(request)
|
|
35
|
+
return request.capability.startsWith('image.') ? this.image(request, context) : this.video(request, context)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private async image(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
39
|
+
const key = this.key(request)
|
|
40
|
+
const sources = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
41
|
+
const editing = request.capability !== 'image.text_to_image' || sources.length > 0
|
|
42
|
+
const images = await dataUris(this.input, sources, context.signal)
|
|
43
|
+
if (images.length > 5) throw new MediaError('INPUT', 'xAI image editing supports at most 5 source images', { provider: this.id })
|
|
44
|
+
const payload = mergeOptions({
|
|
45
|
+
model: request.model,
|
|
46
|
+
prompt: requirePrompt(request),
|
|
47
|
+
...(editing ? { images: images.map(url => ({ url })) } : {}),
|
|
48
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
49
|
+
...(request.resolution ? { resolution: request.resolution } : {}),
|
|
50
|
+
...(request.count ? { n: request.count } : {}),
|
|
51
|
+
response_format: 'url',
|
|
52
|
+
}, request.providerOptions)
|
|
53
|
+
const data = await this.http.json<unknown>(`${this.baseUrl}/images/${editing ? 'edits' : 'generations'}`, {
|
|
54
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
55
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 180_000,
|
|
56
|
+
})
|
|
57
|
+
return artifactsOrThrow(this.result(request, data, 'image'))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async video(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
61
|
+
const key = this.key(request)
|
|
62
|
+
const operation = request.operation ?? capabilityOperation(request.capability)
|
|
63
|
+
const path = operation === 'edit' ? 'edits' : operation === 'extend' ? 'extensions' : 'generations'
|
|
64
|
+
const image = request.inputImage ? await this.input.asDataUri(request.inputImage, context.signal) : undefined
|
|
65
|
+
const video = request.inputVideo ? await this.input.asDataUri(request.inputVideo, context.signal) : undefined
|
|
66
|
+
const referenceImages = await dataUris(this.input, request.referenceImages, context.signal)
|
|
67
|
+
const referenceAudios = await dataUris(this.input, request.referenceAudios, context.signal)
|
|
68
|
+
if (request.referenceVideos?.length) throw new MediaError('CAPABILITY_UNSUPPORTED', 'xAI reference-to-video does not document reference video inputs', { provider: this.id })
|
|
69
|
+
if (operation === 'reference' && referenceImages.length > 7) {
|
|
70
|
+
throw new MediaError('INPUT', 'xAI reference-to-video supports at most 7 reference images', { provider: this.id })
|
|
71
|
+
}
|
|
72
|
+
if ((operation === 'edit' || operation === 'extend') && !video) {
|
|
73
|
+
throw new MediaError('INPUT', `xAI video ${operation} requires inputVideo`, { provider: this.id })
|
|
74
|
+
}
|
|
75
|
+
const base: JsonObject = {
|
|
76
|
+
model: request.model,
|
|
77
|
+
prompt: requirePrompt(request),
|
|
78
|
+
...(video ? { video: { url: video } } : {}),
|
|
79
|
+
...(image ? { image: { url: image } } : {}),
|
|
80
|
+
...(referenceImages.length ? { reference_images: referenceImages.map(url => ({ url })) } : {}),
|
|
81
|
+
...(referenceAudios.length ? { reference_audios: referenceAudios.map(url => ({ url })) } : {}),
|
|
82
|
+
...((operation !== 'edit') && request.duration ? { duration: request.duration } : {}),
|
|
83
|
+
...((operation !== 'edit') && request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
84
|
+
...((operation !== 'edit') && request.resolution ? { resolution: request.resolution } : {}),
|
|
85
|
+
...(request.generateAudio !== undefined ? { generate_audio: request.generateAudio } : {}),
|
|
86
|
+
}
|
|
87
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/videos/${path}`, {
|
|
88
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(mergeOptions(base, request.providerOptions)),
|
|
89
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
90
|
+
})
|
|
91
|
+
const requestId = typeof submitted.request_id === 'string' ? submitted.request_id : undefined
|
|
92
|
+
if (!requestId) return artifactsOrThrow(this.result(request, submitted, 'video'))
|
|
93
|
+
const job = new MediaJob<Record<string, unknown>>({
|
|
94
|
+
id: requestId, provider: this.id, signal: context.signal, timeoutMs: timeout(request), minDelayMs: 1_000, maxDelayMs: 8_000,
|
|
95
|
+
onProgress: status => context.onProgress?.(`xAI ${requestId}: ${status.state}`),
|
|
96
|
+
poll: async signal => {
|
|
97
|
+
const status = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/videos/${encodeURIComponent(requestId)}`, {
|
|
98
|
+
headers: bearerHeaders(key), signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
99
|
+
})
|
|
100
|
+
const state = mapJobState(status.status)
|
|
101
|
+
const error = status.error && typeof status.error === 'object'
|
|
102
|
+
? JSON.stringify(status.error)
|
|
103
|
+
: typeof status.error === 'string' ? status.error : undefined
|
|
104
|
+
return { state, ...(state === 'succeeded' ? { result: status } : {}), ...(error ? { message: error } : {}) } satisfies JobStatus<Record<string, unknown>>
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
const completed = await job.wait()
|
|
108
|
+
return artifactsOrThrow(this.result(request, completed, 'video', { jobId: requestId }))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function capabilityOperation(capability: Capability): string {
|
|
113
|
+
if (capability === 'video.edit') return 'edit'
|
|
114
|
+
if (capability === 'video.extend') return 'extend'
|
|
115
|
+
if (capability === 'video.reference') return 'reference'
|
|
116
|
+
return 'generate'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function timeout(request: MediaRequest): number {
|
|
120
|
+
const value = request.providerOptions?.timeoutMs
|
|
121
|
+
return typeof value === 'number' && value > 0 ? value : 15 * 60_000
|
|
122
|
+
}
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { extname, join } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { HttpClient } from './http.js'
|
|
6
|
+
import { MediaError } from './errors.js'
|
|
7
|
+
import type { MediaKind, NormalizedArtifact, RemoteArtifact } from './types.js'
|
|
8
|
+
|
|
9
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
10
|
+
'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/L16': '.pcm', 'audio/mpeg': '.mp3', 'audio/mp4': '.m4a',
|
|
11
|
+
'audio/ogg': '.ogg', 'audio/pcm': '.pcm', 'audio/wav': '.wav', 'image/gif': '.gif', 'image/jpeg': '.jpg',
|
|
12
|
+
'image/png': '.png', 'image/webp': '.webp', 'video/mp4': '.mp4', 'video/mpeg': '.mpeg',
|
|
13
|
+
'video/quicktime': '.mov', 'video/webm': '.webm',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function kindFrom(value: string, fallback: MediaKind): MediaKind {
|
|
17
|
+
const lower = value.toLowerCase()
|
|
18
|
+
if (/\.(?:png|jpe?g|gif|webp)(?:[?#]|$)/.test(lower) || lower.startsWith('image/')) return 'image'
|
|
19
|
+
if (/\.(?:mp4|mov|webm|mkv|mpeg)(?:[?#]|$)/.test(lower) || lower.startsWith('video/')) return 'video'
|
|
20
|
+
if (/\.(?:mp3|wav|ogg|m4a|aac|flac)(?:[?#]|$)/.test(lower) || lower.startsWith('audio/')) return 'audio'
|
|
21
|
+
return fallback
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function looksLikeBase64(value: string): boolean {
|
|
25
|
+
return value.length > 100 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function extractArtifacts(payload: unknown, fallback: MediaKind): RemoteArtifact[] {
|
|
29
|
+
const found: RemoteArtifact[] = []
|
|
30
|
+
const seen = new Set<string>()
|
|
31
|
+
|
|
32
|
+
function push(artifact: RemoteArtifact): void {
|
|
33
|
+
const identity = artifact.url ?? artifact.base64
|
|
34
|
+
if (!identity || seen.has(identity)) return
|
|
35
|
+
seen.add(identity)
|
|
36
|
+
found.push(artifact)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walk(value: unknown, key = '', mimeHint?: string): void {
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
if (/^https?:\/\//i.test(value)) push({ kind: kindFrom(value, mimeHint ? kindFrom(mimeHint, fallback) : fallback), url: value, ...(mimeHint ? { mimeType: mimeHint } : {}) })
|
|
42
|
+
else if (value.startsWith('data:')) {
|
|
43
|
+
const match = /^data:([^;,]+)?;base64,(.*)$/s.exec(value)
|
|
44
|
+
if (match?.[2]) push({ kind: kindFrom(match[1] ?? '', fallback), base64: match[2], ...(match[1] ? { mimeType: match[1] } : {}) })
|
|
45
|
+
} else if (/(?:b64|base64|bytes|data|content)/i.test(key) && looksLikeBase64(value)) {
|
|
46
|
+
push({ kind: kindFrom(mimeHint ?? '', fallback), base64: value, ...(mimeHint ? { mimeType: mimeHint } : {}) })
|
|
47
|
+
}
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
for (const item of value) walk(item, key, mimeHint)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (!value || typeof value !== 'object') return
|
|
55
|
+
const object = value as Record<string, unknown>
|
|
56
|
+
const mime = typeof object.mime_type === 'string'
|
|
57
|
+
? object.mime_type
|
|
58
|
+
: typeof object.mimeType === 'string'
|
|
59
|
+
? object.mimeType
|
|
60
|
+
: mimeHint
|
|
61
|
+
for (const [childKey, child] of Object.entries(object)) walk(child, childKey, mime)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
walk(payload)
|
|
65
|
+
return found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inferExtension(artifact: RemoteArtifact): string {
|
|
69
|
+
if (artifact.fileName) {
|
|
70
|
+
const extension = extname(artifact.fileName)
|
|
71
|
+
if (extension) return extension
|
|
72
|
+
}
|
|
73
|
+
if (artifact.mimeType) {
|
|
74
|
+
const baseMime = artifact.mimeType.split(';', 1)[0] ?? artifact.mimeType
|
|
75
|
+
const known = EXT_BY_MIME[baseMime]
|
|
76
|
+
if (known) return known
|
|
77
|
+
}
|
|
78
|
+
if (artifact.url) {
|
|
79
|
+
const extension = extname(new URL(artifact.url).pathname)
|
|
80
|
+
if (extension && extension.length <= 8) return extension
|
|
81
|
+
}
|
|
82
|
+
return artifact.kind === 'image' ? '.png' : artifact.kind === 'video' ? '.mp4' : artifact.kind === 'audio' ? '.mp3' : '.txt'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ArtifactDownloader {
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly http: HttpClient,
|
|
88
|
+
private readonly outputDir = join(homedir(), '.pi', 'agent', 'media', 'outputs'),
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
async downloadAll(artifacts: readonly RemoteArtifact[], signal?: AbortSignal): Promise<NormalizedArtifact[]> {
|
|
92
|
+
await mkdir(this.outputDir, { recursive: true })
|
|
93
|
+
const outputs: NormalizedArtifact[] = []
|
|
94
|
+
for (const artifact of artifacts) outputs.push(await this.download(artifact, signal))
|
|
95
|
+
return outputs
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async download(artifact: RemoteArtifact, signal?: AbortSignal): Promise<NormalizedArtifact> {
|
|
99
|
+
const extension = inferExtension(artifact)
|
|
100
|
+
const finalPath = join(this.outputDir, `${Date.now()}-${randomUUID().slice(0, 8)}${extension}`)
|
|
101
|
+
const partPath = `${finalPath}.part`
|
|
102
|
+
try {
|
|
103
|
+
let bytes: Uint8Array
|
|
104
|
+
let mimeType = artifact.mimeType
|
|
105
|
+
if (artifact.base64) {
|
|
106
|
+
bytes = Buffer.from(artifact.base64, 'base64')
|
|
107
|
+
} else if (artifact.url) {
|
|
108
|
+
const response = await this.http.request(artifact.url, {
|
|
109
|
+
signal,
|
|
110
|
+
headers: artifact.headers,
|
|
111
|
+
timeoutMs: 120_000,
|
|
112
|
+
retries: 2,
|
|
113
|
+
provider: 'download',
|
|
114
|
+
})
|
|
115
|
+
bytes = new Uint8Array(await response.arrayBuffer())
|
|
116
|
+
mimeType ??= response.headers.get('content-type')?.split(';', 1)[0] ?? undefined
|
|
117
|
+
} else {
|
|
118
|
+
throw new MediaError('DOWNLOAD', 'Artifact has neither URL nor base64 data')
|
|
119
|
+
}
|
|
120
|
+
await writeFile(partPath, bytes)
|
|
121
|
+
await rename(partPath, finalPath)
|
|
122
|
+
const info = await stat(finalPath)
|
|
123
|
+
return { kind: artifact.kind, path: finalPath, bytes: info.size, ...(mimeType ? { mimeType } : {}) }
|
|
124
|
+
} catch (error) {
|
|
125
|
+
await rm(partPath, { force: true }).catch(() => undefined)
|
|
126
|
+
if (error instanceof MediaError) throw error
|
|
127
|
+
throw new MediaError('DOWNLOAD', `Failed to save generated ${artifact.kind}`, { cause: error })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { CAPABILITIES, type Capability, type JsonObject } from './types.js'
|
|
5
|
+
import { MediaError } from './errors.js'
|
|
6
|
+
|
|
7
|
+
export interface CustomAsyncConfig {
|
|
8
|
+
idPath: string
|
|
9
|
+
statusPath: string
|
|
10
|
+
pollEndpoint: string
|
|
11
|
+
resultPath?: string
|
|
12
|
+
cancelEndpoint?: string
|
|
13
|
+
successValues?: string[]
|
|
14
|
+
failureValues?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CustomEndpointConfig {
|
|
18
|
+
path: string
|
|
19
|
+
method?: string
|
|
20
|
+
format?: 'json' | 'multipart'
|
|
21
|
+
async?: CustomAsyncConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CustomModelConfig {
|
|
25
|
+
id: string
|
|
26
|
+
vendor: string
|
|
27
|
+
capabilities: Capability[]
|
|
28
|
+
endpoints: Partial<Record<Capability, string | CustomEndpointConfig>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CustomProviderConfig {
|
|
32
|
+
id: string
|
|
33
|
+
name?: string
|
|
34
|
+
baseUrl: string
|
|
35
|
+
apiKeyEnv: string
|
|
36
|
+
auth?: 'bearer' | 'x-api-key' | 'none'
|
|
37
|
+
headers?: Record<string, string>
|
|
38
|
+
models: CustomModelConfig[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MediaConfig {
|
|
42
|
+
outputDir?: string
|
|
43
|
+
customProviders: CustomProviderConfig[]
|
|
44
|
+
providerOptions?: Record<string, JsonObject>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY_CONFIG: MediaConfig = { customProviders: [] }
|
|
48
|
+
|
|
49
|
+
async function parseFile(path: string): Promise<Partial<MediaConfig> | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(await readFile(path, 'utf8')) as unknown
|
|
52
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('expected JSON object')
|
|
53
|
+
return value as Partial<MediaConfig>
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
56
|
+
throw new MediaError('CONFIG', `Invalid media config ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadMediaConfig(cwd: string, allowProjectConfig: boolean): Promise<MediaConfig> {
|
|
61
|
+
const globalPath = join(homedir(), '.pi', 'agent', 'media-models.json')
|
|
62
|
+
const global = await parseFile(globalPath) ?? EMPTY_CONFIG
|
|
63
|
+
const project = allowProjectConfig ? await parseFile(join(cwd, '.pi', 'media-models.json')) : undefined
|
|
64
|
+
const merged: MediaConfig = {
|
|
65
|
+
outputDir: project?.outputDir ?? global.outputDir,
|
|
66
|
+
customProviders: project?.customProviders ?? global.customProviders ?? [],
|
|
67
|
+
providerOptions: { ...(global.providerOptions ?? {}), ...(project?.providerOptions ?? {}) },
|
|
68
|
+
}
|
|
69
|
+
validateCustomProviders(merged.customProviders)
|
|
70
|
+
return merged
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validateCustomProviders(providers: CustomProviderConfig[]): void {
|
|
74
|
+
const ids = new Set<string>()
|
|
75
|
+
for (const provider of providers) {
|
|
76
|
+
if (!provider.id || !provider.baseUrl || !provider.apiKeyEnv || !Array.isArray(provider.models)) {
|
|
77
|
+
throw new MediaError('CONFIG', 'Each custom provider requires id, baseUrl, apiKeyEnv, and models[]')
|
|
78
|
+
}
|
|
79
|
+
if (ids.has(provider.id)) throw new MediaError('CONFIG', `Duplicate custom provider id: ${provider.id}`)
|
|
80
|
+
ids.add(provider.id)
|
|
81
|
+
if (['openrouter', 'fal', 'dashscope', 'qwencloud', 'openai', 'gemini', 'vertex', 'xai', 'atlas'].includes(provider.id)) {
|
|
82
|
+
throw new MediaError('CONFIG', `Custom provider id conflicts with built-in provider: ${provider.id}`)
|
|
83
|
+
}
|
|
84
|
+
for (const model of provider.models) {
|
|
85
|
+
if (!model.id || !model.vendor || !model.capabilities?.length || !model.endpoints) {
|
|
86
|
+
throw new MediaError('CONFIG', `Custom provider ${provider.id} model requires id, vendor, capabilities, endpoints`)
|
|
87
|
+
}
|
|
88
|
+
for (const capability of model.capabilities) {
|
|
89
|
+
if (!(CAPABILITIES as readonly string[]).includes(capability)) throw new MediaError('CONFIG', `Unknown capability ${capability} in ${provider.id}/${model.id}`)
|
|
90
|
+
if (!model.endpoints[capability]) throw new MediaError('CONFIG', `${provider.id}/${model.id} declares ${capability} without an endpoint`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type MediaErrorCode =
|
|
2
|
+
| 'ABORTED'
|
|
3
|
+
| 'AUTH'
|
|
4
|
+
| 'CAPABILITY_UNSUPPORTED'
|
|
5
|
+
| 'CONFIG'
|
|
6
|
+
| 'DOWNLOAD'
|
|
7
|
+
| 'HTTP'
|
|
8
|
+
| 'INPUT'
|
|
9
|
+
| 'PROVIDER'
|
|
10
|
+
| 'RATE_LIMITED'
|
|
11
|
+
| 'TIMEOUT'
|
|
12
|
+
|
|
13
|
+
const SECRET_PATTERN = /(?:sk-[A-Za-z0-9_-]{8,}|Bearer\s+[A-Za-z0-9._~+/=-]{8,}|(?:api[_-]?key|token)["'\s:=]+[A-Za-z0-9._~+/=-]{8,})/gi
|
|
14
|
+
|
|
15
|
+
export function redactSecrets(value: string, secrets: readonly string[] = []): string {
|
|
16
|
+
let output = value
|
|
17
|
+
for (const secret of secrets) {
|
|
18
|
+
if (secret.length >= 4) output = output.split(secret).join('[REDACTED]')
|
|
19
|
+
}
|
|
20
|
+
return output.replace(SECRET_PATTERN, match => {
|
|
21
|
+
const prefix = match.match(/^(Bearer\s+|(?:api[_-]?key|token)["'\s:=]+)/i)?.[0] ?? ''
|
|
22
|
+
return `${prefix}[REDACTED]`
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class MediaError extends Error {
|
|
27
|
+
readonly code: MediaErrorCode
|
|
28
|
+
readonly provider?: string
|
|
29
|
+
readonly status?: number
|
|
30
|
+
readonly retryAfterMs?: number
|
|
31
|
+
override readonly cause?: unknown
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
code: MediaErrorCode,
|
|
35
|
+
message: string,
|
|
36
|
+
options: {
|
|
37
|
+
provider?: string
|
|
38
|
+
status?: number
|
|
39
|
+
retryAfterMs?: number
|
|
40
|
+
cause?: unknown
|
|
41
|
+
secrets?: readonly string[]
|
|
42
|
+
} = {},
|
|
43
|
+
) {
|
|
44
|
+
super(redactSecrets(message, options.secrets))
|
|
45
|
+
this.name = 'MediaError'
|
|
46
|
+
this.code = code
|
|
47
|
+
if (options.provider !== undefined) this.provider = options.provider
|
|
48
|
+
if (options.status !== undefined) this.status = options.status
|
|
49
|
+
if (options.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs
|
|
50
|
+
if (options.cause !== undefined) this.cause = options.cause
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function asMediaError(error: unknown, provider?: string): MediaError {
|
|
55
|
+
if (error instanceof MediaError) return error
|
|
56
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
57
|
+
return new MediaError('ABORTED', 'Media request aborted', { provider, cause: error })
|
|
58
|
+
}
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
60
|
+
return new MediaError('PROVIDER', message, { provider, cause: error })
|
|
61
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { MediaError, redactSecrets } from './errors.js'
|
|
2
|
+
|
|
3
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
|
4
|
+
|
|
5
|
+
export interface HttpRequestOptions extends RequestInit {
|
|
6
|
+
timeoutMs?: number
|
|
7
|
+
retries?: number
|
|
8
|
+
retryUnsafe?: boolean
|
|
9
|
+
provider?: string
|
|
10
|
+
secrets?: readonly string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function retryAfterMs(headers: Headers): number | undefined {
|
|
14
|
+
const raw = headers.get('retry-after')
|
|
15
|
+
if (!raw) return undefined
|
|
16
|
+
const seconds = Number(raw)
|
|
17
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
|
18
|
+
const date = Date.parse(raw)
|
|
19
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function combineSignals(signal: AbortSignal | null | undefined, timeoutMs: number): { signal: AbortSignal; cleanup: () => void } {
|
|
23
|
+
const timeoutController = new AbortController()
|
|
24
|
+
const timer = setTimeout(() => timeoutController.abort(new DOMException('Request timed out', 'TimeoutError')), timeoutMs)
|
|
25
|
+
const combined = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal
|
|
26
|
+
return { signal: combined, cleanup: () => clearTimeout(timer) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
|
|
30
|
+
if (ms <= 0) return
|
|
31
|
+
await new Promise<void>((resolve, reject) => {
|
|
32
|
+
const done = () => {
|
|
33
|
+
signal?.removeEventListener('abort', abort)
|
|
34
|
+
resolve()
|
|
35
|
+
}
|
|
36
|
+
const timer = setTimeout(done, ms)
|
|
37
|
+
const abort = () => {
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
signal?.removeEventListener('abort', abort)
|
|
40
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
41
|
+
}
|
|
42
|
+
if (signal?.aborted) return abort()
|
|
43
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class HttpClient {
|
|
48
|
+
private readonly fetchImpl: FetchLike
|
|
49
|
+
|
|
50
|
+
constructor(fetchImpl?: FetchLike) {
|
|
51
|
+
if (!fetchImpl && process.env.PI_MEDIA_TEST_MODE === '1') {
|
|
52
|
+
this.fetchImpl = async () => {
|
|
53
|
+
throw new MediaError('CONFIG', 'Real network requests are disabled in tests')
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
this.fetchImpl = fetchImpl ?? globalThis.fetch.bind(globalThis)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async request(url: string, options: HttpRequestOptions = {}): Promise<Response> {
|
|
61
|
+
const {
|
|
62
|
+
timeoutMs = 30_000,
|
|
63
|
+
retries = 2,
|
|
64
|
+
retryUnsafe = false,
|
|
65
|
+
provider,
|
|
66
|
+
secrets = [],
|
|
67
|
+
...init
|
|
68
|
+
} = options
|
|
69
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
70
|
+
const canRetry = retryUnsafe || method === 'GET' || method === 'HEAD'
|
|
71
|
+
let attempt = 0
|
|
72
|
+
|
|
73
|
+
while (true) {
|
|
74
|
+
const { signal, cleanup } = combineSignals(init.signal, timeoutMs)
|
|
75
|
+
try {
|
|
76
|
+
const response = await this.fetchImpl(url, { ...init, signal })
|
|
77
|
+
if (response.ok) return response
|
|
78
|
+
|
|
79
|
+
const retryMs = retryAfterMs(response.headers)
|
|
80
|
+
const retryable = response.status === 429 || response.status >= 500
|
|
81
|
+
if (retryable && canRetry && attempt < retries) {
|
|
82
|
+
attempt += 1
|
|
83
|
+
await response.body?.cancel().catch(() => undefined)
|
|
84
|
+
await sleep(retryMs ?? Math.min(500 * 2 ** (attempt - 1), 5_000), init.signal)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const body = redactSecrets((await response.text().catch(() => '')).slice(0, 2_000), secrets)
|
|
89
|
+
const code = response.status === 401 || response.status === 403
|
|
90
|
+
? 'AUTH'
|
|
91
|
+
: response.status === 429
|
|
92
|
+
? 'RATE_LIMITED'
|
|
93
|
+
: 'HTTP'
|
|
94
|
+
throw new MediaError(code, `${provider ?? 'Provider'} HTTP ${response.status}${body ? `: ${body}` : ''}`, {
|
|
95
|
+
provider,
|
|
96
|
+
status: response.status,
|
|
97
|
+
...(retryMs === undefined ? {} : { retryAfterMs: retryMs }),
|
|
98
|
+
secrets,
|
|
99
|
+
})
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof MediaError) throw error
|
|
102
|
+
if (init.signal?.aborted) throw new MediaError('ABORTED', 'Media request aborted', { provider, cause: error })
|
|
103
|
+
if (signal.aborted) throw new MediaError('TIMEOUT', `Request timed out after ${timeoutMs}ms`, { provider, cause: error })
|
|
104
|
+
if (canRetry && attempt < retries) {
|
|
105
|
+
attempt += 1
|
|
106
|
+
await sleep(Math.min(500 * 2 ** (attempt - 1), 5_000), init.signal)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
throw new MediaError('HTTP', `Network request failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
110
|
+
provider,
|
|
111
|
+
cause: error,
|
|
112
|
+
secrets,
|
|
113
|
+
})
|
|
114
|
+
} finally {
|
|
115
|
+
cleanup()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async json<T>(url: string, options: HttpRequestOptions = {}): Promise<T> {
|
|
121
|
+
const response = await this.request(url, options)
|
|
122
|
+
try {
|
|
123
|
+
return await response.json() as T
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new MediaError('PROVIDER', `${options.provider ?? 'Provider'} returned invalid JSON`, {
|
|
126
|
+
provider: options.provider,
|
|
127
|
+
cause: error,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|