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/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# pi-media-models
|
|
2
|
+
|
|
3
|
+
Pi Coding Agent 的 Provider-neutral 多模态生成 Extension。只注册 6 个稳定 Tool;模型和 Provider 的变化被隔离在 Capability Router 与 Adapter 内。
|
|
4
|
+
|
|
5
|
+
## 架构
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
Pi Tool
|
|
9
|
+
→ Capability Router
|
|
10
|
+
→ Provider Adapter (Provider 与模型厂商分离)
|
|
11
|
+
→ MediaJob (poll/backoff/timeout/AbortSignal/cancel-if-supported)
|
|
12
|
+
→ Normalized Result
|
|
13
|
+
→ Download (~/.pi/agent/media/outputs, .part + atomic rename)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Provider 原始大 JSON 不进入 LLM 上下文。Tool 只返回 Provider/模型、capability、任务 ID(如有)、本地文件路径或 STT 文本。
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
|
|
20
|
+
- `media_models`:列出 Provider、已知模型/厂商和显式 capability。
|
|
21
|
+
- `image_generate`:文生图、图生图、多参考图。
|
|
22
|
+
- `image_edit`:图片编辑、多图编辑、mask(按 Provider 支持情况)。
|
|
23
|
+
- `video_generate`:统一参数 `prompt`、`provider`、`model`、`inputImage`、`endImage`、`referenceImages`、`referenceVideos`、`referenceAudios`、`inputVideo`、`duration`、`resolution`、`aspectRatio`、`seed`、`generateAudio`、`operation`、`providerOptions`;自动映射 T2V/I2V/首尾帧/reference/edit/extend。
|
|
24
|
+
- `audio_generate`:音乐或模型原生音频生成(不是 TTS)。
|
|
25
|
+
- `speech_generate`:`operation=tts|stt`。
|
|
26
|
+
|
|
27
|
+
所有输入文件字段接受本地路径、`file://`、`http(s)://` 和 data URI。Adapter 会按接口转为 data URI、base64、multipart 或上传 URL。fal 本地输入先上传 fal CDN;需要公网 URL 且没有文档化上传接口的 Provider 会使用 data URI,最终是否接受仍取决于具体模型。
|
|
28
|
+
|
|
29
|
+
## Provider
|
|
30
|
+
|
|
31
|
+
| Provider | Adapter | 能力摘要 | Key |
|
|
32
|
+
|---|---|---|---|
|
|
33
|
+
| OpenRouter | `OpenRouterAdapter` | Images API、视频异步任务、TTS | `OPENROUTER_API_KEY` |
|
|
34
|
+
| fal.ai | `FalAdapter` | 任意 endpoint 的图片/视频/音频/TTS/STT;Queue + CDN upload | `FAL_KEY` |
|
|
35
|
+
| 百炼/DashScope | `DashScopeAdapter` | Qwen/Wan 图片、Wan 视频参考/编辑/延长/原生音频、Fun-Music、TTS/STT | `DASHSCOPE_API_KEY` |
|
|
36
|
+
| QwenCloud | `DashScopeAdapter`(国际 endpoint) | 同 DashScope 协议 | `DASHSCOPE_API_KEY` |
|
|
37
|
+
| OpenAI API | `OpenAIAdapter` | 图片生成/编辑、TTS/STT;**不实现 Sora/OpenAI Video** | `OPENAI_API_KEY` |
|
|
38
|
+
| Gemini API | `GoogleMediaAdapter` | Gemini/Imagen 图片、Veo、Lyria、TTS/STT | `GEMINI_API_KEY` |
|
|
39
|
+
| Vertex AI | `GoogleMediaAdapter` | ADC、Imagen/Gemini、Veo、Lyria、TTS/STT | ADC |
|
|
40
|
+
| xAI | 独立 `XAIAdapter` | Grok Imagine 图片生成/多图编辑、T2V/I2V/reference-to-video、video edit/extend | `XAI_API_KEY` |
|
|
41
|
+
| Atlas | 独立 `AtlasAdapter` | 文档化图片同步/异步/编辑、视频任务及 reference image/video/audio、原生音频 | `ATLAS_API_KEY` |
|
|
42
|
+
| 自定义 OpenAI-compatible | `CustomOpenAICompatibleAdapter` | 仅用户显式声明的 model/capability/endpoint | 用户声明的 env 名 |
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
本目录已位于 Pi 全局自动发现位置:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd ~/.pi/agent/extensions/pi-media
|
|
50
|
+
npm install
|
|
51
|
+
npm run check
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
在 Pi 中运行 `/reload`,或启动时显式加载:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pi -e ~/.pi/agent/extensions/pi-media/index.ts
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
生成结果默认立即下载到:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
~/.pi/agent/media/outputs/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## 配置
|
|
67
|
+
|
|
68
|
+
API Key 只从环境变量读取,不写 JSON。可选配置:
|
|
69
|
+
|
|
70
|
+
- 全局:`~/.pi/agent/media-models.json`
|
|
71
|
+
- 项目:`<repo>/.pi/media-models.json`(只有 Pi 信任项目后才读取)
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"outputDir": "D:/media-output",
|
|
76
|
+
"providerOptions": {
|
|
77
|
+
"dashscope": {
|
|
78
|
+
"baseUrl": "https://WORKSPACE_ID.cn-beijing.maas.aliyuncs.com"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"customProviders": [
|
|
82
|
+
{
|
|
83
|
+
"id": "my-media",
|
|
84
|
+
"name": "My explicit media gateway",
|
|
85
|
+
"baseUrl": "https://media.example.com/v1",
|
|
86
|
+
"apiKeyEnv": "MY_MEDIA_API_KEY",
|
|
87
|
+
"auth": "bearer",
|
|
88
|
+
"models": [
|
|
89
|
+
{
|
|
90
|
+
"id": "vendor/image-model",
|
|
91
|
+
"vendor": "vendor",
|
|
92
|
+
"capabilities": ["image.text_to_image"],
|
|
93
|
+
"endpoints": {
|
|
94
|
+
"image.text_to_image": "/images/generations"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": "vendor/video-model",
|
|
99
|
+
"vendor": "vendor",
|
|
100
|
+
"capabilities": ["video.text_to_video"],
|
|
101
|
+
"endpoints": {
|
|
102
|
+
"video.text_to_video": {
|
|
103
|
+
"path": "/videos",
|
|
104
|
+
"format": "json",
|
|
105
|
+
"async": {
|
|
106
|
+
"idPath": "id",
|
|
107
|
+
"statusPath": "status",
|
|
108
|
+
"pollEndpoint": "/videos/{id}",
|
|
109
|
+
"resultPath": "result",
|
|
110
|
+
"cancelEndpoint": "/videos/{id}/cancel",
|
|
111
|
+
"successValues": ["completed"],
|
|
112
|
+
"failureValues": ["failed", "cancelled"]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
自定义 Provider **不会**请求或信任 `GET /models` 来推断能力;每个模型必须同时声明 `capabilities` 与对应 `endpoints`。
|
|
124
|
+
|
|
125
|
+
## 使用示例
|
|
126
|
+
|
|
127
|
+
先调用 `media_models` 查看 capability,再调用统一 Tool。例如:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"provider": "xai",
|
|
132
|
+
"model": "grok-imagine-video-1.5",
|
|
133
|
+
"prompt": "A paper boat drifting down a rainy street",
|
|
134
|
+
"inputImage": "C:/assets/boat.png",
|
|
135
|
+
"duration": 8,
|
|
136
|
+
"resolution": "720p",
|
|
137
|
+
"aspectRatio": "16:9",
|
|
138
|
+
"generateAudio": true
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Atlas 多模态 reference-to-video:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"provider": "atlas",
|
|
147
|
+
"model": "bytedance/seedance-2.0/text-to-video",
|
|
148
|
+
"prompt": "Product launch film",
|
|
149
|
+
"referenceImages": ["https://example.com/product.png"],
|
|
150
|
+
"referenceVideos": ["https://example.com/motion.mp4"],
|
|
151
|
+
"referenceAudios": ["https://example.com/voice.mp3"],
|
|
152
|
+
"generateAudio": true
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 安全与可靠性
|
|
157
|
+
|
|
158
|
+
- Key 仅取环境变量;错误消息自动脱敏,不记录请求头或完整 Provider JSON。
|
|
159
|
+
- HTTP 请求有超时;幂等请求对 429/5xx 退避重试并尊重 `Retry-After`。为避免重复计费,非幂等生成 POST 默认不自动重试。
|
|
160
|
+
- 异步任务统一支持 polling、backoff、总超时和 `AbortSignal`;仅在 Provider 文档明确提供 cancel 时调用远端取消(fal、DashScope、自定义显式 cancel)。
|
|
161
|
+
- 测试进程设置 `PI_MEDIA_TEST_MODE=1`,未注入 mock fetch 时真实网络请求会直接失败,避免付费误调用。
|
|
162
|
+
|
|
163
|
+
## 已知限制
|
|
164
|
+
|
|
165
|
+
- 媒体模型与参数变化很快;`media_models` 中内置列表是已知入口,不是实时价格/可用性保证。具体模型、区域和账户权限仍由 Provider 校验。
|
|
166
|
+
- fal 的输入/输出 schema 按 endpoint 变化,通用字段可通过 `providerOptions` 覆盖;应按所选 endpoint 文档传原生字段。
|
|
167
|
+
- xAI 官方未公开视频任务 cancel;中止只停止本地 polling。自定义音频 reference 可能要求 trusted-partner 权限。
|
|
168
|
+
- Gemini/Veo 和 Atlas 未公开视频任务 cancel。Atlas 文档未声明独立 TTS/STT、视频 edit/extend,因此不虚构这些 capability。
|
|
169
|
+
- Vertex Veo REST 使用 `:fetchPredictOperation`;GCS 输出需要调用身份有对象读取权限。建议配置 Provider 原生输出到可读 GCS 或返回 base64。
|
|
170
|
+
- 本仓库测试不进行真实付费生成;真实 Key、配额、内容策略和临时 URL 生命周期需在用户明确授权后做 smoke test。
|
|
171
|
+
|
|
172
|
+
## 文档基线
|
|
173
|
+
|
|
174
|
+
Atlas 以用户指定的 <https://doc.aixoras.com/jieruwendang/1-jiekouwendang.html> 为准。其余实现基于 OpenAI、OpenRouter、fal.ai、Alibaba/QwenCloud、Google/Vertex 和 xAI 官方文档(检查日期:2026-08-31)。
|
package/SKILL.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
---\nname: pi-media\ndescription: 统一的多模态媒体生成能力(文生图、图生图、图片编辑、文生视频、图生视频、视频延长、原声音频、TTS、STT等)。支持 OpenAI, Gemini, Vertex, xAI, Atlas, DashScope, QwenCloud, fal.ai, OpenRouter 等。当用户要求生成、编辑媒体内容时,使用该技能提供的媒体生成工具。\n---\n\n# Pi Media Models (Multimodal Generation)\n\nThis extension provides a unified, provider-neutral set of tools to generate, edit, and manipulate multimodal content (images, videos, audio, speech) across leading AI API providers.\n\n## 核心工作流 (Core Workflow)\n\n当你收到用户的多模态生成、编辑需求(如“画一张图”、“生成一段视频”、“把这段话读出来”等)时,请遵循以下步骤:\n\n1. **确认模型和能力**:\n 如果用户没有指定具体的模型,或者你不确定哪个平台支持当前操作,请先调用 `media_models` 工具,查看当前已配置(`configured: true`)的 Provider 以及它们支持的 capabilities。\n *提示:绝不要凭空捏造模型名称或能力,一定要查阅 `media_models` 返回的支持列表。*\n\n2. **调用生成工具**:\n 根据任务类型,调用对应的生成工具:\n - `image_generate`:用于生成全新的图片、或者带有参考图的生成。\n - `image_edit`:用于编辑现有图片(支持局部重绘、背景替换等,取决于平台)。\n - `video_generate`:用于文生视频、图生视频、首尾帧视频、参考视频、视频编辑、视频延长。\n - `audio_generate`:用于生成音乐、音效或模型原生的声音表达。\n - `speech_generate`:用于传统的 TTS(文字转语音)或 STT(语音转文字)。\n\n3. **处理输入文件 (Input Files)**:\n 如果用户提供了参考图片、音频或视频的**本地绝对路径**,或者是公网 URL,请直接将它们填入 `inputImage`、`referenceImages`、`inputVideo` 等参数中。底层的媒体路由会自动处理路径解析、文件读取、Multipart 构建甚至 CDN 预先上传(例如针对 fal.ai)。你不需要自己去读文件内容并转换为 Base64。\n\n4. **输出产物 (Output)**:\n 所有的工具调用都会**自动下载**生成的媒体文件,并将其保存在本地磁盘(通常在 `~/.pi/agent/media/outputs/`)。工具的返回结果会包含这些绝对路径。你只需要在回复中清晰地将这个路径展示给用户即可,不需要做额外的文件提取操作。\n\n## 可用参数指南\n\n对于 `video_generate` 或其他多模态生成任务,你可能需要使用丰富的控制参数:\n* `prompt`: 必需。描述你想要的画面或声音。\n* `aspectRatio`: 画面比例,如 \"16:9\", \"9:16\", \"1:1\" 等。\n* `resolution`: 画面分辨率或尺寸,如 \"720p\", \"1080p\", \"1024x1024\"。\n* `duration`: 视频或音频的时长(秒),如 5, 8。\n* `generateAudio`: 布尔值,用于那些同时支持生成画面的伴随音效的模型(如 xAI, Veo, Atlas)。\n* `providerOptions`: 如果某个特定 Provider 有其独占的高级参数(例如 fal.ai 的 endpoints 专属参数,或者设置单独的 `apiKey`),可以放在这里。例如 `{\"apiKey\": \"sk-...\"}`。\n\n## API Key 配置说明\n\n如果用户询问如何配置密钥,请告诉他们有两种方式:\n\n1. **系统环境变量**:直接 export 对应的 KEY,如 `OPENAI_API_KEY`, `FAL_KEY`, `XAI_API_KEY`, `GEMINI_API_KEY`, `DASHSCOPE_API_KEY`, `ATLAS_API_KEY`,或者用于 Vertex 的 `GOOGLE_APPLICATION_CREDENTIALS`。\n2. **配置文件 (media-models.json)**:在 `~/.pi/agent/media-models.json` (或项目下的 `.pi/media-models.json`) 中,可以直接配置特定厂商的 `apiKey`。\n ```json\n {\n \"providerOptions\": {\n \"xai\": {\n \"apiKey\": \"xai-xxxxxxxx\"\n },\n \"vertex\": {\n \"credentialsFile\": \"C:/path/to/vertex-service-account.json\",\n \"project\": \"my-gcp-project\"\n }\n }\n }\n ```\n 此方法方便在项目中进行独立配置,而不会污染全局环境变量。\n
|
package/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { registerMediaTools } from './src/tools.js'
|
|
3
|
+
|
|
4
|
+
export { CapabilityRouter } from './src/router.js'
|
|
5
|
+
export { MediaJob } from './src/media-job.js'
|
|
6
|
+
export { MediaError, redactSecrets } from './src/errors.js'
|
|
7
|
+
export * from './src/types.js'
|
|
8
|
+
|
|
9
|
+
export default function piMediaExtension(pi: ExtensionAPI): void {
|
|
10
|
+
registerMediaTools(pi)
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-media-models",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Provider-neutral multimodal generation tools for Pi Coding Agent",
|
|
6
|
+
"pi": {
|
|
7
|
+
"extensions": [
|
|
8
|
+
"./index.ts"
|
|
9
|
+
],
|
|
10
|
+
"skills": [
|
|
11
|
+
"./SKILL.md"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "cross-env PI_MEDIA_TEST_MODE=1 tsx --test test/**/*.test.ts",
|
|
17
|
+
"check": "npm run typecheck && npm test",
|
|
18
|
+
"smoke:live": "tsx scripts/live-smoke.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"google-auth-library": "^10.5.0",
|
|
22
|
+
"typebox": "^1.0.62"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
26
|
+
"@types/node": "^24.10.0",
|
|
27
|
+
"cross-env": "^10.1.0",
|
|
28
|
+
"tsx": "^4.21.0",
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"index.ts",
|
|
37
|
+
"SKILL.md",
|
|
38
|
+
"README.md"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
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 ATLAS_CAPS: Capability[] = [
|
|
7
|
+
'image.text_to_image', 'image.image_to_image', 'image.edit',
|
|
8
|
+
'video.text_to_video', 'video.image_to_video', 'video.first_last_frame', 'video.reference',
|
|
9
|
+
'video.native_audio',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export class AtlasAdapter extends BaseAdapter {
|
|
13
|
+
readonly id = 'atlas'
|
|
14
|
+
readonly displayName = 'Atlas API (aixoras.com)'
|
|
15
|
+
readonly envKey = 'ATLAS_API_KEY'
|
|
16
|
+
private readonly baseUrl = 'https://api.aixoras.com/v1'
|
|
17
|
+
|
|
18
|
+
models(): ModelDescriptor[] {
|
|
19
|
+
return [
|
|
20
|
+
makeModel(this.id, 'openai', 'gpt-image-2', ['image.text_to_image', 'image.image_to_image', 'image.edit'], 'Use the exact model id returned for your Atlas account'),
|
|
21
|
+
makeModel(this.id, 'bytedance', 'bytedance/seedance-2.0/text-to-video', ['video.text_to_video', 'video.reference', 'video.native_audio']),
|
|
22
|
+
makeModel(this.id, 'bytedance', 'bytedance/seedance-2.0/image-to-video', ['video.image_to_video', 'video.first_last_frame', 'video.reference', 'video.native_audio']),
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
supports(capability: Capability): boolean { return ATLAS_CAPS.includes(capability) }
|
|
27
|
+
|
|
28
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
29
|
+
this.assertSupport(request)
|
|
30
|
+
if (request.capability.startsWith('video.')) return this.video(request, context)
|
|
31
|
+
if (request.capability === 'image.text_to_image' && !request.inputImage && !request.referenceImages?.length) return this.imageGenerate(request, context)
|
|
32
|
+
return this.imageEdit(request, context)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async imageGenerate(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
36
|
+
const key = this.key(request)
|
|
37
|
+
const asyncMode = request.providerOptions?.async !== false
|
|
38
|
+
const { async: _async, timeoutMs: _timeoutMs, ...nativeOptions } = request.providerOptions ?? {}
|
|
39
|
+
const payload = mergeOptions({
|
|
40
|
+
model: request.model, prompt: requirePrompt(request), n: request.count ?? 1,
|
|
41
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
42
|
+
...(request.resolution ? { size: request.resolution } : {}),
|
|
43
|
+
response_format: 'url',
|
|
44
|
+
...(request.seed !== undefined ? { extra_fields: { seed: request.seed } } : {}),
|
|
45
|
+
}, nativeOptions)
|
|
46
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/images/generations${asyncMode ? '/async' : ''}`, {
|
|
47
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
48
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
49
|
+
})
|
|
50
|
+
return asyncMode ? this.waitImage(request, submitted, context) : artifactsOrThrow(this.result(request, submitted, 'image'))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async imageEdit(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
54
|
+
const key = this.key(request)
|
|
55
|
+
const sources = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
56
|
+
if (!sources.length) throw new MediaError('INPUT', 'Atlas image edit requires inputImage or referenceImages', { provider: this.id })
|
|
57
|
+
if (sources.length > 1) throw new MediaError('CAPABILITY_UNSUPPORTED', 'Atlas documentation defines one image upload for image edits', { provider: this.id })
|
|
58
|
+
const asyncMode = request.providerOptions?.async === true
|
|
59
|
+
const form = new FormData()
|
|
60
|
+
form.set('model', request.model)
|
|
61
|
+
form.set('prompt', requirePrompt(request))
|
|
62
|
+
form.set('n', String(request.count ?? 1))
|
|
63
|
+
form.set('response_format', 'url')
|
|
64
|
+
for (const source of sources) {
|
|
65
|
+
const image = await this.input.asBlob(source, context.signal)
|
|
66
|
+
form.append(sources.length === 1 ? 'image' : 'image[]', image.blob, image.fileName)
|
|
67
|
+
}
|
|
68
|
+
for (const [name, value] of Object.entries(request.providerOptions ?? {})) {
|
|
69
|
+
if (!['async', 'timeoutMs'].includes(name) && value !== undefined) form.set(name, typeof value === 'string' ? value : JSON.stringify(value))
|
|
70
|
+
}
|
|
71
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/images/edits${asyncMode ? '/async' : ''}`, {
|
|
72
|
+
method: 'POST', headers: bearerHeaders(key), body: form, signal: context.signal,
|
|
73
|
+
provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
74
|
+
})
|
|
75
|
+
return asyncMode ? this.waitImage(request, submitted, context) : artifactsOrThrow(this.result(request, submitted, 'image'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async waitImage(request: MediaRequest, submitted: Record<string, unknown>, context: AdapterContext): Promise<AdapterResult> {
|
|
79
|
+
const key = this.key(request)
|
|
80
|
+
const taskId = stringId(submitted)
|
|
81
|
+
if (!taskId) return artifactsOrThrow(this.result(request, submitted, 'image'))
|
|
82
|
+
const completed = await this.pollTask<Record<string, unknown>>(
|
|
83
|
+
taskId, `${this.baseUrl}/images/tasks/${encodeURIComponent(taskId)}`, key, request, context,
|
|
84
|
+
)
|
|
85
|
+
return artifactsOrThrow(this.result(request, completed, 'image', { jobId: taskId }))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async video(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
89
|
+
const key = this.key(request)
|
|
90
|
+
const inputImage = request.inputImage ? await this.input.asDataUri(request.inputImage, context.signal) : undefined
|
|
91
|
+
const endImage = request.endImage ? await this.input.asDataUri(request.endImage, context.signal) : undefined
|
|
92
|
+
const references = await dataUris(this.input, request.referenceImages, context.signal)
|
|
93
|
+
const referenceVideos = await dataUris(this.input, request.referenceVideos, context.signal)
|
|
94
|
+
const referenceAudios = await dataUris(this.input, request.referenceAudios, context.signal)
|
|
95
|
+
const inputVideo = request.inputVideo ? await this.input.asDataUri(request.inputVideo, context.signal) : undefined
|
|
96
|
+
const images = [...new Set([inputImage, endImage, ...references].filter((value): value is string => Boolean(value)))]
|
|
97
|
+
const metadata: JsonObject = {
|
|
98
|
+
...(references.length ? { reference_images: references } : {}),
|
|
99
|
+
...(referenceVideos.length ? { reference_videos: referenceVideos } : {}),
|
|
100
|
+
...(referenceAudios.length ? { reference_audios: referenceAudios } : {}),
|
|
101
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
102
|
+
...(request.generateAudio !== undefined ? { generate_audio: request.generateAudio } : {}),
|
|
103
|
+
...(inputVideo ? { input_video: inputVideo } : {}),
|
|
104
|
+
...(request.operation ? { operation: request.operation } : {}),
|
|
105
|
+
...((request.providerOptions?.metadata && typeof request.providerOptions.metadata === 'object') ? request.providerOptions.metadata as JsonObject : {}),
|
|
106
|
+
}
|
|
107
|
+
const { metadata: _metadata, timeoutMs: _timeoutMs, ...nativeOptions } = request.providerOptions ?? {}
|
|
108
|
+
const payload = mergeOptions({
|
|
109
|
+
model: request.model, prompt: requirePrompt(request),
|
|
110
|
+
...(request.duration ? { duration: request.duration, seconds: String(request.duration) } : {}),
|
|
111
|
+
...(request.resolution ? { resolution: request.resolution, size: request.resolution } : {}),
|
|
112
|
+
...(images.length ? { images, image: images[0], input_reference: images[0] } : {}),
|
|
113
|
+
metadata,
|
|
114
|
+
}, nativeOptions)
|
|
115
|
+
const submitted = await this.http.json<Record<string, unknown>>(`${this.baseUrl}/video/generations`, {
|
|
116
|
+
method: 'POST', headers: bearerHeaders(key, { 'Content-Type': 'application/json' }), body: JSON.stringify(payload),
|
|
117
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 60_000,
|
|
118
|
+
})
|
|
119
|
+
const taskId = stringId(submitted)
|
|
120
|
+
if (!taskId) return artifactsOrThrow(this.result(request, submitted, 'video'))
|
|
121
|
+
const completed = await this.pollTask<Record<string, unknown>>(
|
|
122
|
+
taskId, `${this.baseUrl}/video/generations/${encodeURIComponent(taskId)}`, key, request, context,
|
|
123
|
+
)
|
|
124
|
+
return artifactsOrThrow(this.result(request, completed, 'video', { jobId: taskId }))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async pollTask<T extends Record<string, unknown>>(taskId: string, url: string, key: string, request: MediaRequest, context: AdapterContext): Promise<T> {
|
|
128
|
+
const job = new MediaJob<T>({
|
|
129
|
+
id: taskId, provider: this.id, signal: context.signal, timeoutMs: timeout(request), minDelayMs: 3_000, maxDelayMs: 5_000,
|
|
130
|
+
onProgress: status => context.onProgress?.(`Atlas ${taskId}: ${status.state}`),
|
|
131
|
+
poll: async signal => {
|
|
132
|
+
const status = await this.http.json<T>(url, {
|
|
133
|
+
headers: bearerHeaders(key), signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
134
|
+
})
|
|
135
|
+
const state = mapJobState(status.status ?? status.raw_status)
|
|
136
|
+
const message = typeof status.fail_reason === 'string' ? status.fail_reason : undefined
|
|
137
|
+
return { state, ...(state === 'succeeded' ? { result: status } : {}), ...(message ? { message } : {}) } satisfies JobStatus<T>
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
return job.wait()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stringId(payload: Record<string, unknown>): string | undefined {
|
|
145
|
+
return typeof payload.task_id === 'string' ? payload.task_id : typeof payload.id === 'string' ? payload.id : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function timeout(request: MediaRequest): number {
|
|
149
|
+
const value = request.providerOptions?.timeoutMs
|
|
150
|
+
return typeof value === 'number' && value > 0 ? value : 30 * 60_000
|
|
151
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { MediaError } from '../errors.js'
|
|
2
|
+
import { extractArtifacts } from '../artifacts.js'
|
|
3
|
+
import type { Capability, MediaKind, MediaRequest, ModelDescriptor, ProviderAdapter, AdapterContext, AdapterResult, JsonObject, RemoteArtifact } from '../types.js'
|
|
4
|
+
import type { HttpClient } from '../http.js'
|
|
5
|
+
import type { InputResolver } from '../input.js'
|
|
6
|
+
|
|
7
|
+
export interface AdapterDependencies {
|
|
8
|
+
http: HttpClient
|
|
9
|
+
input: InputResolver
|
|
10
|
+
env?: NodeJS.ProcessEnv
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export abstract class BaseAdapter implements ProviderAdapter {
|
|
14
|
+
abstract readonly id: string
|
|
15
|
+
abstract readonly displayName: string
|
|
16
|
+
abstract readonly envKey?: string
|
|
17
|
+
protected readonly http: HttpClient
|
|
18
|
+
protected readonly input: InputResolver
|
|
19
|
+
protected readonly env: NodeJS.ProcessEnv
|
|
20
|
+
|
|
21
|
+
constructor(dependencies: AdapterDependencies) {
|
|
22
|
+
this.http = dependencies.http
|
|
23
|
+
this.input = dependencies.input
|
|
24
|
+
this.env = dependencies.env ?? process.env
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
abstract models(): ModelDescriptor[]
|
|
28
|
+
abstract supports(capability: Capability, model: string): boolean
|
|
29
|
+
abstract execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult>
|
|
30
|
+
|
|
31
|
+
protected key(request: MediaRequest): string {
|
|
32
|
+
const configKey = request.providerOptions?.apiKey;
|
|
33
|
+
const value = (typeof configKey === "string" ? configKey : undefined) ?? (this.envKey ? this.env[this.envKey] : undefined)
|
|
34
|
+
if (!value) throw new MediaError('AUTH', `${this.envKey ?? `${this.id} API key`} is not set in environment or config`, { provider: this.id })
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
protected assertSupport(request: MediaRequest): void {
|
|
39
|
+
if (!this.supports(request.capability, request.model)) {
|
|
40
|
+
throw new MediaError('CAPABILITY_UNSUPPORTED', `${this.displayName} model ${request.model} does not declare ${request.capability}`, { provider: this.id })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected result(request: MediaRequest, payload: unknown, fallback: MediaKind, options: { jobId?: string; text?: string; warnings?: string[]; headers?: Record<string, string> } = {}): AdapterResult {
|
|
45
|
+
const artifacts = extractArtifacts(payload, fallback).map(artifact => options.headers ? { ...artifact, headers: options.headers } : artifact)
|
|
46
|
+
return {
|
|
47
|
+
provider: this.id,
|
|
48
|
+
model: request.model,
|
|
49
|
+
capability: request.capability,
|
|
50
|
+
artifacts,
|
|
51
|
+
...(options.jobId ? { jobId: options.jobId } : {}),
|
|
52
|
+
...(options.text ? { text: options.text } : {}),
|
|
53
|
+
...(options.warnings?.length ? { warnings: options.warnings } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mergeOptions(base: JsonObject, options?: JsonObject): JsonObject {
|
|
59
|
+
return options ? { ...base, ...options } : base
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function bearerHeaders(key: string, extra: Record<string, string> = {}): Record<string, string> {
|
|
63
|
+
return { Authorization: `Bearer ${key}`, ...extra }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function requirePrompt(request: MediaRequest): string {
|
|
67
|
+
if (!request.prompt?.trim()) throw new MediaError('INPUT', `${request.capability} requires prompt`, { provider: request.provider })
|
|
68
|
+
return request.prompt
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function dataUris(input: InputResolver, sources: readonly string[] | undefined, signal?: AbortSignal): Promise<string[]> {
|
|
72
|
+
if (!sources) return []
|
|
73
|
+
return Promise.all(sources.map(source => input.asDataUri(source, signal)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function artifactsOrThrow(result: AdapterResult): AdapterResult {
|
|
77
|
+
if (result.artifacts.length === 0 && !result.text) {
|
|
78
|
+
throw new MediaError('PROVIDER', `${result.provider} returned no media artifact`, { provider: result.provider })
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeModel(provider: string, vendor: string, id: string, capabilities: Capability[], notes?: string): ModelDescriptor {
|
|
84
|
+
return { provider, vendor, id, capabilities, ...(notes ? { notes } : {}) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function withArtifactHeaders(artifacts: RemoteArtifact[], headers: Record<string, string>): RemoteArtifact[] {
|
|
88
|
+
return artifacts.map(artifact => ({ ...artifact, headers }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function extractText(payload: unknown): string | undefined {
|
|
92
|
+
const candidates: string[] = []
|
|
93
|
+
function walk(value: unknown, key = ''): void {
|
|
94
|
+
if (typeof value === 'string' && /^(?:text|transcript|transcription|output_text)$/i.test(key)) {
|
|
95
|
+
candidates.push(value)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
for (const item of value) walk(item, key)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (!value || typeof value !== 'object') return
|
|
103
|
+
for (const [childKey, child] of Object.entries(value as Record<string, unknown>)) walk(child, childKey)
|
|
104
|
+
}
|
|
105
|
+
walk(payload)
|
|
106
|
+
return candidates.length ? [...new Set(candidates)].join('\n') : undefined
|
|
107
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { MediaError } from '../errors.js'
|
|
2
|
+
import { MediaJob, mapJobState } from '../media-job.js'
|
|
3
|
+
import type { CustomEndpointConfig, CustomModelConfig, CustomProviderConfig } from '../config.js'
|
|
4
|
+
import type { AdapterContext, AdapterResult, Capability, JobStatus, JsonObject, MediaRequest, ModelDescriptor } from '../types.js'
|
|
5
|
+
import type { AdapterDependencies } from './base.js'
|
|
6
|
+
import { BaseAdapter, artifactsOrThrow, dataUris, makeModel, mergeOptions } from './base.js'
|
|
7
|
+
|
|
8
|
+
export class CustomOpenAICompatibleAdapter extends BaseAdapter {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly displayName: string
|
|
11
|
+
readonly envKey: string
|
|
12
|
+
|
|
13
|
+
constructor(private readonly config: CustomProviderConfig, dependencies: AdapterDependencies) {
|
|
14
|
+
super(dependencies)
|
|
15
|
+
this.id = config.id
|
|
16
|
+
this.displayName = config.name ?? config.id
|
|
17
|
+
this.envKey = config.apiKeyEnv
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
models(): ModelDescriptor[] {
|
|
21
|
+
return this.config.models.map(model => makeModel(this.id, model.vendor, model.id, model.capabilities, 'Explicit custom-provider declaration; no /models capability inference'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
supports(capability: Capability, model: string): boolean {
|
|
25
|
+
return Boolean(this.model(model)?.capabilities.includes(capability))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async execute(request: MediaRequest, context: AdapterContext): Promise<AdapterResult> {
|
|
29
|
+
this.assertSupport(request)
|
|
30
|
+
const model = this.model(request.model)
|
|
31
|
+
if (!model) throw new MediaError('CONFIG', `Unknown custom model ${this.id}/${request.model}`, { provider: this.id })
|
|
32
|
+
const declared = model.endpoints[request.capability]
|
|
33
|
+
if (!declared) throw new MediaError('CONFIG', `No endpoint declared for ${request.capability}`, { provider: this.id })
|
|
34
|
+
const endpoint: CustomEndpointConfig = typeof declared === 'string' ? { path: declared } : declared
|
|
35
|
+
const key = this.key(request)
|
|
36
|
+
const headers = { ...this.authHeaders(key), ...(this.config.headers ?? {}) }
|
|
37
|
+
const { body, contentType } = await this.body(request, endpoint.format ?? 'json', context.signal)
|
|
38
|
+
const submitted = await this.http.json<Record<string, unknown>>(this.url(endpoint.path), {
|
|
39
|
+
method: endpoint.method ?? 'POST', headers: { ...headers, ...(contentType ? { 'Content-Type': contentType } : {}) }, body,
|
|
40
|
+
signal: context.signal, provider: this.id, secrets: [key], timeoutMs: 120_000,
|
|
41
|
+
})
|
|
42
|
+
if (!endpoint.async) return artifactsOrThrow(this.result(request, submitted, kind(request), { text: textResult(submitted) }))
|
|
43
|
+
const asyncConfig = endpoint.async
|
|
44
|
+
const jobId = getPath(submitted, asyncConfig.idPath)
|
|
45
|
+
if (typeof jobId !== 'string') throw new MediaError('PROVIDER', `${this.id} async response did not contain ${asyncConfig.idPath}`, { provider: this.id })
|
|
46
|
+
const pollUrl = this.url(asyncConfig.pollEndpoint.replace('{id}', encodeURIComponent(jobId)))
|
|
47
|
+
const job = new MediaJob<Record<string, unknown>>({
|
|
48
|
+
id: jobId, provider: this.id, signal: context.signal, timeoutMs: timeout(request),
|
|
49
|
+
onProgress: status => context.onProgress?.(`${this.id} ${jobId}: ${status.state}`),
|
|
50
|
+
poll: async signal => {
|
|
51
|
+
const status = await this.http.json<Record<string, unknown>>(pollUrl, {
|
|
52
|
+
headers, signal, provider: this.id, secrets: [key], timeoutMs: 30_000,
|
|
53
|
+
})
|
|
54
|
+
const raw = String(getPath(status, asyncConfig.statusPath) ?? '')
|
|
55
|
+
const state = customState(raw, asyncConfig)
|
|
56
|
+
const selected = asyncConfig.resultPath ? getPath(status, asyncConfig.resultPath) : status
|
|
57
|
+
return {
|
|
58
|
+
state,
|
|
59
|
+
...(state === 'succeeded' ? { result: (selected && typeof selected === 'object' ? selected : status) as Record<string, unknown> } : {}),
|
|
60
|
+
} satisfies JobStatus<Record<string, unknown>>
|
|
61
|
+
},
|
|
62
|
+
...(asyncConfig.cancelEndpoint ? { cancel: async (signal: AbortSignal) => {
|
|
63
|
+
await this.http.request(this.url(asyncConfig.cancelEndpoint?.replace('{id}', encodeURIComponent(jobId)) ?? ''), {
|
|
64
|
+
method: 'POST', headers, signal, provider: this.id, secrets: [key], timeoutMs: 10_000,
|
|
65
|
+
})
|
|
66
|
+
} } : {}),
|
|
67
|
+
})
|
|
68
|
+
const completed = await job.wait()
|
|
69
|
+
return artifactsOrThrow(this.result(request, completed, kind(request), { jobId, text: textResult(completed) }))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private model(id: string): CustomModelConfig | undefined { return this.config.models.find(model => model.id === id) }
|
|
73
|
+
|
|
74
|
+
private authHeaders(key: string): Record<string, string> {
|
|
75
|
+
if (this.config.auth === 'none') return {}
|
|
76
|
+
if (this.config.auth === 'x-api-key') return { 'x-api-key': key }
|
|
77
|
+
return { Authorization: `Bearer ${key}` }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private url(path: string): string {
|
|
81
|
+
if (/^https?:\/\//i.test(path)) return path
|
|
82
|
+
return `${this.config.baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async body(request: MediaRequest, format: 'json' | 'multipart', signal?: AbortSignal): Promise<{ body: BodyInit; contentType?: string }> {
|
|
86
|
+
const sources = [request.inputImage, ...(request.referenceImages ?? [])].filter((value): value is string => Boolean(value))
|
|
87
|
+
if (format === 'multipart') {
|
|
88
|
+
const form = new FormData()
|
|
89
|
+
form.set('model', request.model)
|
|
90
|
+
if (request.prompt) form.set('prompt', request.prompt)
|
|
91
|
+
if (request.text) form.set('input', request.text)
|
|
92
|
+
for (const source of sources) {
|
|
93
|
+
const file = await this.input.asBlob(source, signal)
|
|
94
|
+
form.append(sources.length > 1 ? 'image[]' : 'image', file.blob, file.fileName)
|
|
95
|
+
}
|
|
96
|
+
if (request.inputAudio) {
|
|
97
|
+
const file = await this.input.asBlob(request.inputAudio, signal)
|
|
98
|
+
form.set('file', file.blob, file.fileName)
|
|
99
|
+
}
|
|
100
|
+
if (request.inputVideo) {
|
|
101
|
+
const file = await this.input.asBlob(request.inputVideo, signal)
|
|
102
|
+
form.set('video', file.blob, file.fileName)
|
|
103
|
+
}
|
|
104
|
+
for (const [key, value] of Object.entries(request.providerOptions ?? {})) if (value !== undefined) form.set(key, typeof value === 'string' ? value : JSON.stringify(value))
|
|
105
|
+
return { body: form }
|
|
106
|
+
}
|
|
107
|
+
const references = await dataUris(this.input, sources, signal)
|
|
108
|
+
const payload = mergeOptions({
|
|
109
|
+
model: request.model,
|
|
110
|
+
...(request.prompt ? { prompt: request.prompt } : {}),
|
|
111
|
+
...(request.text ? { input: request.text } : {}),
|
|
112
|
+
...(references[0] ? { image: references[0] } : {}),
|
|
113
|
+
...(references.length ? { images: references } : {}),
|
|
114
|
+
...(request.inputVideo ? { video: await this.input.asDataUri(request.inputVideo, signal) } : {}),
|
|
115
|
+
...(request.inputAudio ? { audio: await this.input.asDataUri(request.inputAudio, signal) } : {}),
|
|
116
|
+
...(request.endImage ? { end_image: await this.input.asDataUri(request.endImage, signal) } : {}),
|
|
117
|
+
...(request.duration ? { duration: request.duration } : {}),
|
|
118
|
+
...(request.resolution ? { size: request.resolution } : {}),
|
|
119
|
+
...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
|
|
120
|
+
...(request.seed !== undefined ? { seed: request.seed } : {}),
|
|
121
|
+
...(request.generateAudio !== undefined ? { generate_audio: request.generateAudio } : {}),
|
|
122
|
+
...(request.voice ? { voice: request.voice } : {}),
|
|
123
|
+
...(request.language ? { language: request.language } : {}),
|
|
124
|
+
...(request.responseFormat ? { response_format: request.responseFormat } : {}),
|
|
125
|
+
...(request.operation ? { operation: request.operation } : {}),
|
|
126
|
+
}, request.providerOptions)
|
|
127
|
+
return { body: JSON.stringify(payload), contentType: 'application/json' }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getPath(value: unknown, path: string): unknown {
|
|
132
|
+
return path.split('.').filter(Boolean).reduce<unknown>((current, key) => current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined, value)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function customState(raw: string, config: CustomEndpointConfig['async']): JobStatus<never>['state'] {
|
|
136
|
+
const normalized = raw.toLowerCase()
|
|
137
|
+
if (config?.successValues?.some(value => value.toLowerCase() === normalized)) return 'succeeded'
|
|
138
|
+
if (config?.failureValues?.some(value => value.toLowerCase() === normalized)) return 'failed'
|
|
139
|
+
return mapJobState(raw)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function kind(request: MediaRequest): 'image' | 'video' | 'audio' | 'text' {
|
|
143
|
+
return request.capability.startsWith('image.') ? 'image' : request.capability.startsWith('video.') ? 'video' : request.capability === 'speech.stt' ? 'text' : 'audio'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function textResult(payload: unknown): string | undefined {
|
|
147
|
+
const text = getPath(payload, 'text') ?? getPath(payload, 'data.text')
|
|
148
|
+
return typeof text === 'string' ? text : undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function timeout(request: MediaRequest): number {
|
|
152
|
+
const value = request.providerOptions?.timeoutMs
|
|
153
|
+
return typeof value === 'number' && value > 0 ? value : 30 * 60_000
|
|
154
|
+
}
|