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
package/src/input.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { basename, extname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { MediaError } from './errors.js'
|
|
5
|
+
import { HttpClient } from './http.js'
|
|
6
|
+
import type { ResolvedInput } from './types.js'
|
|
7
|
+
|
|
8
|
+
const MIME_BY_EXTENSION: Record<string, string> = {
|
|
9
|
+
'.aac': 'audio/aac', '.flac': 'audio/flac', '.gif': 'image/gif', '.jpeg': 'image/jpeg',
|
|
10
|
+
'.jpg': 'image/jpeg', '.m4a': 'audio/mp4', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime',
|
|
11
|
+
'.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.mpeg': 'video/mpeg', '.oga': 'audio/ogg',
|
|
12
|
+
'.ogg': 'audio/ogg', '.png': 'image/png', '.wav': 'audio/wav', '.webm': 'video/webm',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mimeFromName(name: string, fallback = 'application/octet-stream'): string {
|
|
17
|
+
const clean = name.split(/[?#]/, 1)[0] ?? name
|
|
18
|
+
return MIME_BY_EXTENSION[extname(clean).toLowerCase()] ?? fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extensionForMime(mime: string): string {
|
|
22
|
+
return Object.entries(MIME_BY_EXTENSION).find(([, value]) => value === mime)?.[0] ?? '.bin'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decodeDataUri(input: string): { bytes: Uint8Array; mimeType: string } {
|
|
26
|
+
const match = /^data:([^;,]+)?((?:;[^,]*)*?),(.*)$/s.exec(input)
|
|
27
|
+
if (!match) throw new MediaError('INPUT', 'Invalid data URI')
|
|
28
|
+
const mimeType = match[1] || 'application/octet-stream'
|
|
29
|
+
const metadata = match[2] ?? ''
|
|
30
|
+
const payload = match[3] ?? ''
|
|
31
|
+
try {
|
|
32
|
+
return {
|
|
33
|
+
mimeType,
|
|
34
|
+
bytes: metadata.includes(';base64')
|
|
35
|
+
? Buffer.from(payload, 'base64')
|
|
36
|
+
: Buffer.from(decodeURIComponent(payload), 'utf8'),
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
throw new MediaError('INPUT', 'Invalid data URI payload', { cause: error })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class InputResolver {
|
|
44
|
+
constructor(private readonly http: HttpClient, private readonly cwd = process.cwd()) {}
|
|
45
|
+
|
|
46
|
+
async resolve(input: string): Promise<ResolvedInput> {
|
|
47
|
+
const value = input.startsWith('@') ? input.slice(1) : input
|
|
48
|
+
if (/^https?:\/\//i.test(value)) {
|
|
49
|
+
const parsed = new URL(value)
|
|
50
|
+
const fileName = basename(parsed.pathname) || 'remote.bin'
|
|
51
|
+
return { original: input, kind: 'url', url: value, mimeType: mimeFromName(fileName), fileName }
|
|
52
|
+
}
|
|
53
|
+
if (value.startsWith('data:')) {
|
|
54
|
+
const decoded = decodeDataUri(value)
|
|
55
|
+
return {
|
|
56
|
+
original: input,
|
|
57
|
+
kind: 'data',
|
|
58
|
+
bytes: decoded.bytes,
|
|
59
|
+
mimeType: decoded.mimeType,
|
|
60
|
+
fileName: `inline${extensionForMime(decoded.mimeType)}`,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const path = value.startsWith('file://') ? fileURLToPath(value) : resolve(this.cwd, value)
|
|
65
|
+
try {
|
|
66
|
+
const bytes = await readFile(path)
|
|
67
|
+
return {
|
|
68
|
+
original: input,
|
|
69
|
+
kind: 'file',
|
|
70
|
+
bytes,
|
|
71
|
+
mimeType: mimeFromName(path),
|
|
72
|
+
fileName: basename(path),
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new MediaError('INPUT', `Cannot read media input: ${path}`, { cause: error })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async bytes(input: ResolvedInput, signal?: AbortSignal): Promise<Uint8Array> {
|
|
80
|
+
if (input.bytes) return input.bytes
|
|
81
|
+
if (!input.url) throw new MediaError('INPUT', `Input has no readable data: ${input.original}`)
|
|
82
|
+
const response = await this.http.request(input.url, { signal, timeoutMs: 60_000, retries: 1 })
|
|
83
|
+
return new Uint8Array(await response.arrayBuffer())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async asDataUri(source: string, signal?: AbortSignal): Promise<string> {
|
|
87
|
+
const input = await this.resolve(source)
|
|
88
|
+
if (input.kind === 'url') return input.url ?? source
|
|
89
|
+
const bytes = await this.bytes(input, signal)
|
|
90
|
+
return `data:${input.mimeType};base64,${Buffer.from(bytes).toString('base64')}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async asInlineData(source: string, signal?: AbortSignal): Promise<{ mimeType: string; data: string }> {
|
|
94
|
+
const input = await this.resolve(source)
|
|
95
|
+
const bytes = await this.bytes(input, signal)
|
|
96
|
+
return { mimeType: input.mimeType, data: Buffer.from(bytes).toString('base64') }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async asBlob(source: string, signal?: AbortSignal): Promise<{ blob: Blob; fileName: string; mimeType: string }> {
|
|
100
|
+
const input = await this.resolve(source)
|
|
101
|
+
const bytes = await this.bytes(input, signal)
|
|
102
|
+
return { blob: new Blob([Uint8Array.from(bytes)], { type: input.mimeType }), fileName: input.fileName, mimeType: input.mimeType }
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/media-job.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { MediaError } from './errors.js'
|
|
2
|
+
import type { JobStatus, MediaJobOptions } from './types.js'
|
|
3
|
+
|
|
4
|
+
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const done = () => {
|
|
7
|
+
signal.removeEventListener('abort', onAbort)
|
|
8
|
+
resolve()
|
|
9
|
+
}
|
|
10
|
+
const timer = setTimeout(done, ms)
|
|
11
|
+
const onAbort = () => {
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
signal.removeEventListener('abort', onAbort)
|
|
14
|
+
reject(new DOMException('Aborted', 'AbortError'))
|
|
15
|
+
}
|
|
16
|
+
if (signal.aborted) return onAbort()
|
|
17
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class MediaJob<T> {
|
|
22
|
+
constructor(private readonly options: MediaJobOptions<T>) {}
|
|
23
|
+
|
|
24
|
+
async wait(): Promise<T> {
|
|
25
|
+
const {
|
|
26
|
+
timeoutMs = 20 * 60_000,
|
|
27
|
+
minDelayMs = 1_000,
|
|
28
|
+
maxDelayMs = 10_000,
|
|
29
|
+
provider,
|
|
30
|
+
} = this.options
|
|
31
|
+
const timeoutController = new AbortController()
|
|
32
|
+
const timer = setTimeout(() => timeoutController.abort(new DOMException('Job timed out', 'TimeoutError')), timeoutMs)
|
|
33
|
+
const signal = this.options.signal
|
|
34
|
+
? AbortSignal.any([this.options.signal, timeoutController.signal])
|
|
35
|
+
: timeoutController.signal
|
|
36
|
+
let delay = minDelayMs
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const status = await this.options.poll(signal)
|
|
41
|
+
this.options.onProgress?.(status)
|
|
42
|
+
if (status.state === 'succeeded') {
|
|
43
|
+
if (status.result === undefined) throw new MediaError('PROVIDER', `${provider} job completed without a result`, { provider })
|
|
44
|
+
return status.result
|
|
45
|
+
}
|
|
46
|
+
if (status.state === 'failed' || status.state === 'cancelled') {
|
|
47
|
+
throw new MediaError('PROVIDER', status.message ?? `${provider} job ${status.state}`, { provider })
|
|
48
|
+
}
|
|
49
|
+
const sleepFor = status.retryAfterMs ?? delay
|
|
50
|
+
await wait(sleepFor, signal)
|
|
51
|
+
delay = Math.min(maxDelayMs, Math.round(delay * 1.7))
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (this.options.signal?.aborted) {
|
|
55
|
+
await this.cancelBestEffort()
|
|
56
|
+
throw new MediaError('ABORTED', `${provider} job ${this.options.id} aborted`, { provider, cause: error })
|
|
57
|
+
}
|
|
58
|
+
if (timeoutController.signal.aborted) {
|
|
59
|
+
await this.cancelBestEffort()
|
|
60
|
+
throw new MediaError('TIMEOUT', `${provider} job ${this.options.id} timed out after ${timeoutMs}ms`, { provider, cause: error })
|
|
61
|
+
}
|
|
62
|
+
throw error
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async cancelBestEffort(): Promise<void> {
|
|
69
|
+
if (!this.options.cancel) return
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), 5_000)
|
|
72
|
+
try {
|
|
73
|
+
await this.options.cancel(controller.signal)
|
|
74
|
+
} catch {
|
|
75
|
+
// Cancellation is advisory; preserve the original abort/timeout error.
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function mapJobState(raw: unknown): JobStatus<never>['state'] {
|
|
83
|
+
const value = String(raw ?? '').toLowerCase().replace(/[\s-]+/g, '_')
|
|
84
|
+
if (['completed', 'complete', 'succeeded', 'success', 'done', 'ready'].includes(value)) return 'succeeded'
|
|
85
|
+
if (['failed', 'failure', 'error'].includes(value)) return 'failed'
|
|
86
|
+
if (['cancelled', 'canceled'].includes(value)) return 'cancelled'
|
|
87
|
+
if (['queued', 'pending', 'submitted', 'created'].includes(value)) return 'queued'
|
|
88
|
+
return 'running'
|
|
89
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ArtifactDownloader } from './artifacts.js'
|
|
2
|
+
import type { MediaConfig } from './config.js'
|
|
3
|
+
import { asMediaError, MediaError } from './errors.js'
|
|
4
|
+
import { HttpClient, type FetchLike } from './http.js'
|
|
5
|
+
import { InputResolver } from './input.js'
|
|
6
|
+
import type { AdapterContext, Capability, MediaRequest, ModelDescriptor, NormalizedResult, ProviderAdapter } from './types.js'
|
|
7
|
+
import { AtlasAdapter } from './adapters/atlas.js'
|
|
8
|
+
import { CustomOpenAICompatibleAdapter } from './adapters/custom.js'
|
|
9
|
+
import { DashScopeAdapter } from './adapters/dashscope.js'
|
|
10
|
+
import { FalAdapter } from './adapters/fal.js'
|
|
11
|
+
import { GoogleMediaAdapter } from './adapters/google.js'
|
|
12
|
+
import { OpenAIAdapter } from './adapters/openai.js'
|
|
13
|
+
import { OpenRouterAdapter } from './adapters/openrouter.js'
|
|
14
|
+
import { XAIAdapter } from './adapters/xai.js'
|
|
15
|
+
|
|
16
|
+
export interface RouterOptions {
|
|
17
|
+
cwd: string
|
|
18
|
+
config: MediaConfig
|
|
19
|
+
env?: NodeJS.ProcessEnv
|
|
20
|
+
fetch?: FetchLike
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CapabilityRouter {
|
|
24
|
+
private readonly adapters = new Map<string, ProviderAdapter>()
|
|
25
|
+
private readonly downloader: ArtifactDownloader
|
|
26
|
+
private readonly env: NodeJS.ProcessEnv
|
|
27
|
+
private readonly providerDefaults: Record<string, Record<string, unknown>>
|
|
28
|
+
|
|
29
|
+
constructor(options: RouterOptions) {
|
|
30
|
+
const http = new HttpClient(options.fetch)
|
|
31
|
+
const input = new InputResolver(http, options.cwd)
|
|
32
|
+
const dependencies = { http, input, ...(options.env ? { env: options.env } : {}) }
|
|
33
|
+
this.env = options.env ?? process.env
|
|
34
|
+
this.providerDefaults = options.config.providerOptions ?? {}
|
|
35
|
+
const builtins: ProviderAdapter[] = [
|
|
36
|
+
new OpenRouterAdapter(dependencies),
|
|
37
|
+
new FalAdapter(dependencies),
|
|
38
|
+
new DashScopeAdapter('dashscope', 'https://dashscope.aliyuncs.com', dependencies),
|
|
39
|
+
new DashScopeAdapter('qwencloud', 'https://dashscope-intl.aliyuncs.com', dependencies),
|
|
40
|
+
new OpenAIAdapter(dependencies),
|
|
41
|
+
new GoogleMediaAdapter('gemini', dependencies),
|
|
42
|
+
new GoogleMediaAdapter('vertex', dependencies),
|
|
43
|
+
new XAIAdapter(dependencies),
|
|
44
|
+
new AtlasAdapter(dependencies),
|
|
45
|
+
...options.config.customProviders.map(provider => new CustomOpenAICompatibleAdapter(provider, dependencies)),
|
|
46
|
+
]
|
|
47
|
+
for (const adapter of builtins) this.adapters.set(adapter.id, adapter)
|
|
48
|
+
this.downloader = new ArtifactDownloader(http, options.config.outputDir)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private isConfigured(adapter: ProviderAdapter): boolean {
|
|
52
|
+
if (!adapter.envKey) return true
|
|
53
|
+
if (this.env[adapter.envKey]) return true
|
|
54
|
+
if (this.providerDefaults[adapter.id]?.apiKey) return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list(provider?: string, capability?: Capability): Array<ModelDescriptor & { configured: boolean }> {
|
|
59
|
+
const adapters = provider ? [this.adapters.get(provider)].filter((item): item is ProviderAdapter => Boolean(item)) : [...this.adapters.values()]
|
|
60
|
+
return adapters.flatMap(adapter => adapter.models()
|
|
61
|
+
.filter(model => !capability || model.capabilities.includes(capability))
|
|
62
|
+
.map(model => ({ ...model, configured: this.isConfigured(adapter) })))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
providers(): Array<{ id: string; name: string; configured: boolean; envKey?: string }> {
|
|
66
|
+
return [...this.adapters.values()].map(adapter => ({
|
|
67
|
+
id: adapter.id,
|
|
68
|
+
name: adapter.displayName,
|
|
69
|
+
configured: this.isConfigured(adapter),
|
|
70
|
+
...(adapter.envKey ? { envKey: adapter.envKey } : {}),
|
|
71
|
+
}))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async execute(request: MediaRequest, context: AdapterContext = {}): Promise<NormalizedResult> {
|
|
75
|
+
const adapter = this.adapters.get(request.provider)
|
|
76
|
+
if (!adapter) throw new MediaError('CONFIG', `Unknown media provider: ${request.provider}`)
|
|
77
|
+
const providerOptions = { ...(this.providerDefaults[request.provider] ?? {}), ...(request.providerOptions ?? {}) }
|
|
78
|
+
const resolvedRequest: MediaRequest = { ...request, providerOptions }
|
|
79
|
+
try {
|
|
80
|
+
const result = await adapter.execute(resolvedRequest, context)
|
|
81
|
+
const artifacts = await this.downloader.downloadAll(result.artifacts, context.signal)
|
|
82
|
+
return {
|
|
83
|
+
provider: result.provider,
|
|
84
|
+
model: result.model,
|
|
85
|
+
capability: result.capability,
|
|
86
|
+
artifacts,
|
|
87
|
+
warnings: result.warnings ?? [],
|
|
88
|
+
...(result.jobId ? { jobId: result.jobId } : {}),
|
|
89
|
+
...(result.text ? { text: result.text } : {}),
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw asMediaError(error, adapter.id)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Type } from 'typebox'
|
|
3
|
+
import { loadMediaConfig } from './config.js'
|
|
4
|
+
import { MediaError } from './errors.js'
|
|
5
|
+
import { CapabilityRouter } from './router.js'
|
|
6
|
+
import type { Capability, JsonObject, MediaRequest, NormalizedResult } from './types.js'
|
|
7
|
+
|
|
8
|
+
const providerModel = {
|
|
9
|
+
provider: Type.String({ description: 'Provider id: openrouter, fal, dashscope, qwencloud, openai, gemini, vertex, xai, atlas, or an explicitly configured custom provider' }),
|
|
10
|
+
model: Type.String({ description: 'Exact provider model id or fal endpoint slug' }),
|
|
11
|
+
}
|
|
12
|
+
const providerOptions = Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: 'Provider-native options. These override normalized mappings; never include API keys.' }))
|
|
13
|
+
const commonOutput = {
|
|
14
|
+
resolution: Type.Optional(Type.String()),
|
|
15
|
+
aspectRatio: Type.Optional(Type.String()),
|
|
16
|
+
seed: Type.Optional(Type.Integer()),
|
|
17
|
+
providerOptions,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function routerFor(ctx: ExtensionContext): Promise<CapabilityRouter> {
|
|
21
|
+
const config = await loadMediaConfig(ctx.cwd, ctx.isProjectTrusted())
|
|
22
|
+
return new CapabilityRouter({ cwd: ctx.cwd, config })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function progress(onUpdate: ((result: { content: Array<{ type: 'text'; text: string }>; details?: unknown }) => void) | undefined, message: string): void {
|
|
26
|
+
onUpdate?.({ content: [{ type: 'text', text: message }] })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function concise(result: NormalizedResult): string {
|
|
30
|
+
const lines = [`${result.provider}/${result.model} · ${result.capability}`]
|
|
31
|
+
if (result.artifacts.length) lines.push(...result.artifacts.map(artifact => `Saved ${artifact.kind}: ${artifact.path}`))
|
|
32
|
+
if (result.text) lines.push(result.text)
|
|
33
|
+
if (result.jobId) lines.push(`Job: ${result.jobId}`)
|
|
34
|
+
if (result.warnings.length) lines.push(`Warnings: ${result.warnings.join('; ')}`)
|
|
35
|
+
return lines.join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function executeRequest(request: MediaRequest, signal: AbortSignal | undefined, onUpdate: Parameters<Parameters<ExtensionAPI['registerTool']>[0]['execute']>[3], ctx: ExtensionContext) {
|
|
39
|
+
const router = await routerFor(ctx)
|
|
40
|
+
progress(onUpdate as never, `Starting ${request.capability} with ${request.provider}/${request.model}…`)
|
|
41
|
+
const result = await router.execute(request, {
|
|
42
|
+
...(signal ? { signal } : {}),
|
|
43
|
+
onProgress: message => progress(onUpdate as never, message),
|
|
44
|
+
})
|
|
45
|
+
return { content: [{ type: 'text' as const, text: concise(result) }], details: result }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerMediaTools(pi: ExtensionAPI): void {
|
|
49
|
+
pi.registerTool({
|
|
50
|
+
name: 'media_models',
|
|
51
|
+
label: 'Media Models',
|
|
52
|
+
description: 'List media providers and explicitly known/configured model capabilities. Does not infer custom capabilities from GET /models.',
|
|
53
|
+
promptSnippet: 'List available media providers/models/capabilities before choosing a model',
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
provider: Type.Optional(Type.String()),
|
|
56
|
+
capability: Type.Optional(Type.String()),
|
|
57
|
+
}),
|
|
58
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
59
|
+
const router = await routerFor(ctx)
|
|
60
|
+
const capability = params.capability as Capability | undefined
|
|
61
|
+
const models = router.list(params.provider, capability)
|
|
62
|
+
const providers = router.providers()
|
|
63
|
+
const text = models.length
|
|
64
|
+
? models.map(model => `${model.provider}/${model.id} [vendor=${model.vendor}; configured=${model.configured}] ${model.capabilities.join(', ')}`).join('\n')
|
|
65
|
+
: 'No matching declared media models.'
|
|
66
|
+
return { content: [{ type: 'text', text }], details: { providers, models } }
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
pi.registerTool({
|
|
71
|
+
name: 'image_generate',
|
|
72
|
+
label: 'Generate Image',
|
|
73
|
+
description: 'Generate an image from text or reference image(s). Inputs accept local paths, file://, http(s) URLs, and data URIs. Results are downloaded automatically.',
|
|
74
|
+
promptSnippet: 'Generate images through the provider-neutral media router',
|
|
75
|
+
parameters: Type.Object({
|
|
76
|
+
...providerModel,
|
|
77
|
+
prompt: Type.String(),
|
|
78
|
+
inputImage: Type.Optional(Type.String()),
|
|
79
|
+
referenceImages: Type.Optional(Type.Array(Type.String())),
|
|
80
|
+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
81
|
+
...commonOutput,
|
|
82
|
+
}),
|
|
83
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
84
|
+
const references = params.referenceImages ?? []
|
|
85
|
+
const capability: Capability = references.length + (params.inputImage ? 1 : 0) > 1
|
|
86
|
+
? 'image.multi_reference'
|
|
87
|
+
: params.inputImage || references.length ? 'image.image_to_image' : 'image.text_to_image'
|
|
88
|
+
return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
pi.registerTool({
|
|
93
|
+
name: 'image_edit',
|
|
94
|
+
label: 'Edit Image',
|
|
95
|
+
description: 'Edit one or more images with an optional mask. Inputs accept local paths, file://, http(s) URLs, and data URIs. Results are downloaded automatically.',
|
|
96
|
+
promptSnippet: 'Edit images through the provider-neutral media router',
|
|
97
|
+
parameters: Type.Object({
|
|
98
|
+
...providerModel,
|
|
99
|
+
prompt: Type.String(),
|
|
100
|
+
inputImage: Type.String(),
|
|
101
|
+
referenceImages: Type.Optional(Type.Array(Type.String())),
|
|
102
|
+
mask: Type.Optional(Type.String()),
|
|
103
|
+
count: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
104
|
+
...commonOutput,
|
|
105
|
+
}),
|
|
106
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
107
|
+
const capability: Capability = params.referenceImages?.length ? 'image.multi_reference' : 'image.edit'
|
|
108
|
+
return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
pi.registerTool({
|
|
113
|
+
name: 'video_generate',
|
|
114
|
+
label: 'Generate Video',
|
|
115
|
+
description: 'Unified video generation/edit/extension tool. Automatically maps text, first/end frames, references, and input video to the provider capability; downloads temporary results immediately.',
|
|
116
|
+
promptSnippet: 'Generate, edit, or extend video through the provider-neutral media router',
|
|
117
|
+
parameters: Type.Object({
|
|
118
|
+
...providerModel,
|
|
119
|
+
prompt: Type.String(),
|
|
120
|
+
inputImage: Type.Optional(Type.String()),
|
|
121
|
+
endImage: Type.Optional(Type.String()),
|
|
122
|
+
referenceImages: Type.Optional(Type.Array(Type.String())),
|
|
123
|
+
referenceVideos: Type.Optional(Type.Array(Type.String())),
|
|
124
|
+
referenceAudios: Type.Optional(Type.Array(Type.String())),
|
|
125
|
+
inputVideo: Type.Optional(Type.String()),
|
|
126
|
+
duration: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
|
|
127
|
+
resolution: Type.Optional(Type.String()),
|
|
128
|
+
aspectRatio: Type.Optional(Type.String()),
|
|
129
|
+
seed: Type.Optional(Type.Integer()),
|
|
130
|
+
generateAudio: Type.Optional(Type.Boolean()),
|
|
131
|
+
operation: Type.Optional(Type.String({ description: 'generate, reference, edit, or extend; omitted for automatic mapping' })),
|
|
132
|
+
providerOptions,
|
|
133
|
+
}),
|
|
134
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
135
|
+
const capability = videoCapability(params)
|
|
136
|
+
return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
pi.registerTool({
|
|
141
|
+
name: 'audio_generate',
|
|
142
|
+
label: 'Generate Audio',
|
|
143
|
+
description: 'Generate music or model-native audio from a prompt. This is separate from TTS and downloads the result automatically.',
|
|
144
|
+
promptSnippet: 'Generate music or audio through the provider-neutral media router',
|
|
145
|
+
parameters: Type.Object({
|
|
146
|
+
...providerModel,
|
|
147
|
+
prompt: Type.String(),
|
|
148
|
+
text: Type.Optional(Type.String({ description: 'Optional lyrics or secondary text input' })),
|
|
149
|
+
inputAudio: Type.Optional(Type.String()),
|
|
150
|
+
duration: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
|
|
151
|
+
seed: Type.Optional(Type.Integer()),
|
|
152
|
+
providerOptions,
|
|
153
|
+
}),
|
|
154
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
155
|
+
return executeRequest({ ...params, capability: 'audio.generate', providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
pi.registerTool({
|
|
160
|
+
name: 'speech_generate',
|
|
161
|
+
label: 'Speech',
|
|
162
|
+
description: 'Run provider-supported TTS or STT. Use operation="tts" with text, or operation="stt" with inputAudio. Generated audio is downloaded; STT returns concise text.',
|
|
163
|
+
promptSnippet: 'Synthesize speech or transcribe audio through the provider-neutral media router',
|
|
164
|
+
parameters: Type.Object({
|
|
165
|
+
...providerModel,
|
|
166
|
+
operation: Type.String({ description: 'tts or stt' }),
|
|
167
|
+
text: Type.Optional(Type.String()),
|
|
168
|
+
inputAudio: Type.Optional(Type.String()),
|
|
169
|
+
prompt: Type.Optional(Type.String()),
|
|
170
|
+
voice: Type.Optional(Type.String()),
|
|
171
|
+
language: Type.Optional(Type.String()),
|
|
172
|
+
responseFormat: Type.Optional(Type.String()),
|
|
173
|
+
providerOptions,
|
|
174
|
+
}),
|
|
175
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
176
|
+
const operation = params.operation.toLowerCase()
|
|
177
|
+
if (!['tts', 'stt', 'transcribe'].includes(operation)) throw new MediaError('INPUT', 'speech_generate operation must be tts or stt')
|
|
178
|
+
const capability: Capability = operation === 'tts' ? 'speech.tts' : 'speech.stt'
|
|
179
|
+
if (capability === 'speech.tts' && !params.text && !params.prompt) throw new MediaError('INPUT', 'TTS requires text')
|
|
180
|
+
if (capability === 'speech.stt' && !params.inputAudio) throw new MediaError('INPUT', 'STT requires inputAudio')
|
|
181
|
+
return executeRequest({ ...params, capability, providerOptions: params.providerOptions as JsonObject | undefined }, signal, onUpdate, ctx)
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function videoCapability(params: {
|
|
187
|
+
operation?: string
|
|
188
|
+
inputImage?: string
|
|
189
|
+
endImage?: string
|
|
190
|
+
inputVideo?: string
|
|
191
|
+
referenceImages?: string[]
|
|
192
|
+
referenceVideos?: string[]
|
|
193
|
+
referenceAudios?: string[]
|
|
194
|
+
}): Capability {
|
|
195
|
+
const operation = params.operation?.toLowerCase()
|
|
196
|
+
if (operation === 'edit') return 'video.edit'
|
|
197
|
+
if (operation === 'extend') return 'video.extend'
|
|
198
|
+
if (operation === 'reference') return 'video.reference'
|
|
199
|
+
if (operation && operation !== 'generate') throw new MediaError('INPUT', 'video_generate operation must be generate, reference, edit, or extend')
|
|
200
|
+
if (params.inputVideo) return 'video.edit'
|
|
201
|
+
if (params.referenceImages?.length || params.referenceVideos?.length || params.referenceAudios?.length) return 'video.reference'
|
|
202
|
+
if (params.endImage) return 'video.first_last_frame'
|
|
203
|
+
if (params.inputImage) return 'video.image_to_video'
|
|
204
|
+
return 'video.text_to_video'
|
|
205
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export const CAPABILITIES = [
|
|
2
|
+
'image.text_to_image',
|
|
3
|
+
'image.image_to_image',
|
|
4
|
+
'image.edit',
|
|
5
|
+
'image.multi_reference',
|
|
6
|
+
'video.text_to_video',
|
|
7
|
+
'video.image_to_video',
|
|
8
|
+
'video.first_last_frame',
|
|
9
|
+
'video.reference',
|
|
10
|
+
'video.edit',
|
|
11
|
+
'video.extend',
|
|
12
|
+
'video.native_audio',
|
|
13
|
+
'audio.generate',
|
|
14
|
+
'speech.tts',
|
|
15
|
+
'speech.stt',
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
export type Capability = (typeof CAPABILITIES)[number]
|
|
19
|
+
export type ProviderId =
|
|
20
|
+
| 'openrouter'
|
|
21
|
+
| 'fal'
|
|
22
|
+
| 'dashscope'
|
|
23
|
+
| 'qwencloud'
|
|
24
|
+
| 'openai'
|
|
25
|
+
| 'gemini'
|
|
26
|
+
| 'vertex'
|
|
27
|
+
| 'xai'
|
|
28
|
+
| 'atlas'
|
|
29
|
+
| string
|
|
30
|
+
|
|
31
|
+
export type MediaKind = 'image' | 'video' | 'audio' | 'text'
|
|
32
|
+
export type InputSource = string
|
|
33
|
+
export type JsonObject = Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
export interface MediaRequest {
|
|
36
|
+
capability: Capability
|
|
37
|
+
provider: ProviderId
|
|
38
|
+
model: string
|
|
39
|
+
prompt?: string
|
|
40
|
+
inputImage?: InputSource
|
|
41
|
+
endImage?: InputSource
|
|
42
|
+
referenceImages?: InputSource[]
|
|
43
|
+
referenceVideos?: InputSource[]
|
|
44
|
+
referenceAudios?: InputSource[]
|
|
45
|
+
inputVideo?: InputSource
|
|
46
|
+
inputAudio?: InputSource
|
|
47
|
+
mask?: InputSource
|
|
48
|
+
duration?: number
|
|
49
|
+
resolution?: string
|
|
50
|
+
aspectRatio?: string
|
|
51
|
+
seed?: number
|
|
52
|
+
count?: number
|
|
53
|
+
generateAudio?: boolean
|
|
54
|
+
operation?: string
|
|
55
|
+
text?: string
|
|
56
|
+
voice?: string
|
|
57
|
+
language?: string
|
|
58
|
+
responseFormat?: string
|
|
59
|
+
providerOptions?: JsonObject
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ResolvedInput {
|
|
63
|
+
original: string
|
|
64
|
+
kind: 'url' | 'data' | 'file'
|
|
65
|
+
mimeType: string
|
|
66
|
+
fileName: string
|
|
67
|
+
bytes?: Uint8Array
|
|
68
|
+
url?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RemoteArtifact {
|
|
72
|
+
kind: MediaKind
|
|
73
|
+
url?: string
|
|
74
|
+
base64?: string
|
|
75
|
+
mimeType?: string
|
|
76
|
+
fileName?: string
|
|
77
|
+
headers?: Record<string, string>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NormalizedArtifact {
|
|
81
|
+
kind: MediaKind
|
|
82
|
+
path: string
|
|
83
|
+
mimeType?: string
|
|
84
|
+
bytes?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AdapterResult {
|
|
88
|
+
provider: string
|
|
89
|
+
model: string
|
|
90
|
+
capability: Capability
|
|
91
|
+
jobId?: string
|
|
92
|
+
artifacts: RemoteArtifact[]
|
|
93
|
+
text?: string
|
|
94
|
+
warnings?: string[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface NormalizedResult {
|
|
98
|
+
provider: string
|
|
99
|
+
model: string
|
|
100
|
+
capability: Capability
|
|
101
|
+
jobId?: string
|
|
102
|
+
artifacts: NormalizedArtifact[]
|
|
103
|
+
text?: string
|
|
104
|
+
warnings: string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface JobStatus<T> {
|
|
108
|
+
state: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
|
109
|
+
result?: T
|
|
110
|
+
message?: string
|
|
111
|
+
retryAfterMs?: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface MediaJobOptions<T> {
|
|
115
|
+
id: string
|
|
116
|
+
provider: string
|
|
117
|
+
poll: (signal: AbortSignal) => Promise<JobStatus<T>>
|
|
118
|
+
cancel?: (signal: AbortSignal) => Promise<void>
|
|
119
|
+
timeoutMs?: number
|
|
120
|
+
minDelayMs?: number
|
|
121
|
+
maxDelayMs?: number
|
|
122
|
+
signal?: AbortSignal
|
|
123
|
+
onProgress?: (status: JobStatus<T>) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ModelDescriptor {
|
|
127
|
+
provider: string
|
|
128
|
+
vendor: string
|
|
129
|
+
id: string
|
|
130
|
+
capabilities: Capability[]
|
|
131
|
+
notes?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface AdapterContext {
|
|
135
|
+
signal?: AbortSignal
|
|
136
|
+
onProgress?: (message: string) => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ProviderAdapter {
|
|
140
|
+
readonly id: string
|
|
141
|
+
readonly displayName: string
|
|
142
|
+
readonly envKey?: string
|
|
143
|
+
models(): ModelDescriptor[]
|
|
144
|
+
supports(capability: Capability, model: string): boolean
|
|
145
|
+
execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult>
|
|
146
|
+
}
|