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