@dsh-plus/image-studio 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,266 @@
1
+ /**
2
+ * OpenAI Images API 适配器(首批协议实现)。
3
+ * - 请求构造是纯函数(单测覆盖 JSON/multipart 两种形态);
4
+ * - 执行走 node:http(s),https 目标代理经 https-proxy-agent(与 llm-pi 同约定);
5
+ * - 响应统一取回字节(url → 下载 / b64_json → 解码),调用方零二次请求。
6
+ * 开发测试指向本地 mock,严禁真实调用产生费用。
7
+ * @module image-studio/provider/openai-images
8
+ */
9
+ import { request as httpRequest } from 'node:http'
10
+ import { request as httpsRequest, type IncomingMessage } from 'node:https'
11
+ import { HttpsProxyAgent } from 'https-proxy-agent'
12
+ import type { GeneratedImage, InputImage, NormalizedRequest, ProviderResult } from './types.ts'
13
+ import { ProviderError } from './types.ts'
14
+
15
+ /** 顶层响应体上限(4K PNG 约 10-20MB,放宽到 64MB 防滥用)。 */
16
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024
17
+
18
+ const MIME_BY_FORMAT: Record<string, string> = {
19
+ png: 'image/png',
20
+ jpeg: 'image/jpeg',
21
+ webp: 'image/webp',
22
+ }
23
+
24
+ /** output_format 参数值 → MIME(b64 结果无 content-type 时兜底)。 */
25
+ export function mimeOfFormat(format: unknown): string {
26
+ return MIME_BY_FORMAT[format as string] ?? 'image/png'
27
+ }
28
+
29
+ /** 官方端点路径。 */
30
+ export function endpointPath(endpoint: NormalizedRequest['endpoint']): string {
31
+ return endpoint === 'generation' ? '/images/generations' : '/images/edits'
32
+ }
33
+
34
+ interface HeaderLike {
35
+ [key: string]: string
36
+ }
37
+
38
+ /** 公共请求头(鉴权 + 用户附加头)。 */
39
+ function baseHeaders(apiKey: string, extra?: Record<string, string>): HeaderLike {
40
+ return { authorization: `Bearer ${apiKey}`, ...extra }
41
+ }
42
+
43
+ /** multipart 文本字段分片。 */
44
+ function formField(name: string, value: string): string {
45
+ return `--BOUNDARY\r\ncontent-disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`
46
+ }
47
+
48
+ /** multipart 文件分片头。 */
49
+ function formFileHead(name: string, index: number, file: InputImage): string {
50
+ const ext = file.mime.split('/')[1] ?? 'png'
51
+ return (
52
+ `--BOUNDARY\r\ncontent-disposition: form-data; name="${name}"; filename="${name}-${index}.${ext}"\r\n` +
53
+ `content-type: ${file.mime}\r\n\r\n`
54
+ )
55
+ }
56
+
57
+ /** 构造 generations JSON 请求体(纯函数;params 已由 spec 层裁剪)。 */
58
+ export function buildGenerationBody(request: NormalizedRequest): string {
59
+ return JSON.stringify({ prompt: request.prompt, n: request.n, ...request.params })
60
+ }
61
+
62
+ /** 构造 edits multipart 请求体(纯函数;image 官方上限 ≤16 张由边界校验)。 */
63
+ export function buildEditBody(request: NormalizedRequest): {
64
+ headers: HeaderLike
65
+ body: Buffer
66
+ } {
67
+ const chunks: Buffer[] = []
68
+ const pushText = (text: string): void => chunks.push(Buffer.from(text, 'utf8'))
69
+ pushText(formField('prompt', request.prompt))
70
+ pushText(formField('n', String(request.n)))
71
+ for (const [key, value] of Object.entries(request.params)) {
72
+ pushText(formField(key, String(value)))
73
+ }
74
+ request.images.forEach((file, index) => {
75
+ pushText(formFileHead('image', index, file))
76
+ chunks.push(Buffer.from(file.data), Buffer.from('\r\n', 'utf8'))
77
+ })
78
+ if (request.mask !== null) {
79
+ pushText(formFileHead('mask', 0, request.mask))
80
+ chunks.push(Buffer.from(request.mask.data), Buffer.from('\r\n', 'utf8'))
81
+ }
82
+ chunks.push(Buffer.from('--BOUNDARY--\r\n', 'utf8'))
83
+ return {
84
+ headers: { 'content-type': 'multipart/form-data; boundary=BOUNDARY' },
85
+ body: Buffer.concat(chunks),
86
+ }
87
+ }
88
+
89
+ interface RawReply {
90
+ status: number
91
+ contentType: string
92
+ body: Buffer
93
+ }
94
+
95
+ interface ExecOptions {
96
+ method: 'POST' | 'GET'
97
+ headers: HeaderLike
98
+ body?: Buffer
99
+ proxy: string
100
+ timeoutMs: number
101
+ signal: AbortSignal
102
+ }
103
+
104
+ /** 收集响应体(带字节上限;流错误结算防悬挂)。 */
105
+ function collect(
106
+ req: import('node:http').ClientRequest,
107
+ response: IncomingMessage,
108
+ done: (reply: RawReply) => void,
109
+ fail: (error: Error) => void,
110
+ ): void {
111
+ const chunks: Buffer[] = []
112
+ let size = 0
113
+ response.on('data', (chunk: Buffer) => {
114
+ size += chunk.length
115
+ if (size <= MAX_RESPONSE_BYTES) {
116
+ chunks.push(chunk)
117
+ return
118
+ }
119
+ response.destroy(new Error('响应超过 64MB 上限'))
120
+ })
121
+ response.on('end', () =>
122
+ done({
123
+ status: response.statusCode ?? 0,
124
+ contentType: response.headers['content-type']?.split(';')[0] ?? '',
125
+ body: Buffer.concat(chunks),
126
+ }),
127
+ )
128
+ // 响应流自身出错不经请求转发:缺此监听 Promise 永不 settle。
129
+ response.on('error', fail)
130
+ req.on('error', fail)
131
+ }
132
+
133
+ /** HTTP(S) POST/GET 执行:代理 + 超时 + AbortSignal 贯穿。 */
134
+ function execRequest(url: string, options: ExecOptions): Promise<RawReply> {
135
+ return new Promise((resolve, reject) => {
136
+ const target = new URL(url)
137
+ const doRequest = target.protocol === 'https:' ? httpsRequest : httpRequest
138
+ const agent =
139
+ target.protocol === 'https:' && options.proxy.length > 0
140
+ ? new HttpsProxyAgent(options.proxy)
141
+ : undefined
142
+ let settled = false
143
+ const fail = (error: Error): void => {
144
+ if (settled) return
145
+ settled = true
146
+ reject(error)
147
+ }
148
+ const done = (reply: RawReply): void => {
149
+ if (settled) return
150
+ settled = true
151
+ resolve(reply)
152
+ }
153
+ const req = doRequest(
154
+ url,
155
+ {
156
+ method: options.method,
157
+ headers: options.headers,
158
+ timeout: options.timeoutMs,
159
+ agent,
160
+ signal: options.signal,
161
+ },
162
+ (response) => collect(req, response, done, fail),
163
+ )
164
+ req.on('timeout', () => req.destroy(new Error(`请求超时(${options.timeoutMs}ms)`)))
165
+ req.on('error', fail)
166
+ req.end(options.body ?? Buffer.alloc(0))
167
+ })
168
+ }
169
+
170
+ /** 上游 Images API 响应的最小投影。 */
171
+ interface ImagesReply {
172
+ created?: number
173
+ data?: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> | null
174
+ }
175
+
176
+ /** url 形式结果图下载(同代理/超时策略;MIME 取响应头)。 */
177
+ async function fetchImageBytes(
178
+ url: string,
179
+ proxy: string,
180
+ timeoutMs: number,
181
+ signal: AbortSignal,
182
+ ): Promise<{ data: Uint8Array; mime: string }> {
183
+ const reply = await execRequest(url, { method: 'GET', headers: {}, proxy, timeoutMs, signal })
184
+ if (reply.status !== 200) {
185
+ throw new ProviderError(reply.status, '', `结果图下载失败(HTTP ${reply.status})`)
186
+ }
187
+ return { data: new Uint8Array(reply.body), mime: reply.contentType || 'image/png' }
188
+ }
189
+
190
+ /** 解析响应体为结果图列表(url 下载 / b64 解码统一为字节)。 */
191
+ async function parseImagesReply(
192
+ bodyText: string,
193
+ fallbackMime: string,
194
+ proxy: string,
195
+ timeoutMs: number,
196
+ signal: AbortSignal,
197
+ ): Promise<ProviderResult> {
198
+ let doc: ImagesReply
199
+ try {
200
+ doc = JSON.parse(bodyText) as ImagesReply
201
+ } catch {
202
+ throw new ProviderError(502, bodyText.slice(0, 500), '上游响应不是合法 JSON')
203
+ }
204
+ const images: GeneratedImage[] = []
205
+ for (const item of doc.data ?? []) {
206
+ if (typeof item.b64_json === 'string' && item.b64_json.length > 0) {
207
+ images.push({
208
+ data: new Uint8Array(Buffer.from(item.b64_json, 'base64')),
209
+ mime: fallbackMime,
210
+ revisedPrompt: item.revised_prompt ?? null,
211
+ })
212
+ continue
213
+ }
214
+ if (typeof item.url === 'string' && item.url.length > 0) {
215
+ const fetched = await fetchImageBytes(item.url, proxy, timeoutMs, signal)
216
+ images.push({ ...fetched, revisedPrompt: item.revised_prompt ?? null })
217
+ }
218
+ }
219
+ if (images.length === 0)
220
+ throw new ProviderError(502, bodyText.slice(0, 500), '上游未返回任何图片')
221
+ return { images, createdAt: typeof doc.created === 'number' ? doc.created : null }
222
+ }
223
+
224
+ /** 适配器主入口:构造请求 → 执行 → 解析。 */
225
+ export async function generateViaOpenAIImages(
226
+ request: NormalizedRequest,
227
+ signal: AbortSignal,
228
+ ): Promise<ProviderResult> {
229
+ const { target } = request
230
+ const url = `${target.baseUrl.replace(/\/+$/, '')}${endpointPath(request.endpoint)}`
231
+ const fallbackMime = mimeOfFormat(request.params.output_format)
232
+ const reply =
233
+ request.endpoint === 'generation'
234
+ ? await execRequest(url, {
235
+ method: 'POST',
236
+ headers: {
237
+ ...baseHeaders(target.apiKey, target.extraHeaders),
238
+ 'content-type': 'application/json',
239
+ },
240
+ body: Buffer.from(buildGenerationBody(request), 'utf8'),
241
+ proxy: target.proxy,
242
+ timeoutMs: target.timeoutMs,
243
+ signal,
244
+ })
245
+ : await execRequest(url, {
246
+ method: 'POST',
247
+ headers: baseHeaders(target.apiKey, {
248
+ ...target.extraHeaders,
249
+ ...buildEditBody(request).headers,
250
+ }),
251
+ body: buildEditBody(request).body,
252
+ proxy: target.proxy,
253
+ timeoutMs: target.timeoutMs,
254
+ signal,
255
+ })
256
+ if (reply.status !== 200) {
257
+ throw new ProviderError(reply.status, reply.body.toString('utf8').slice(0, 2000))
258
+ }
259
+ return parseImagesReply(
260
+ reply.body.toString('utf8'),
261
+ fallbackMime,
262
+ target.proxy,
263
+ target.timeoutMs,
264
+ signal,
265
+ )
266
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 协议注册表:协议 id → 适配器构造。封闭集合 + 统一注册入口,
3
+ * 新增协议(如 gemini、stable-diffusion 等)只增注册不改核心。
4
+ * @module image-studio/provider/registry
5
+ */
6
+ import { ImageStudioError } from '../errors.ts'
7
+ import { generateViaOpenAIImages } from './openai-images.ts'
8
+ import type { ImageEndpoint, ImageProvider, NormalizedRequest } from './types.ts'
9
+
10
+ type GenerateFn = (request: NormalizedRequest, signal: AbortSignal) => Promise<ProviderResult>
11
+
12
+ interface ProtocolEntry {
13
+ /** 协议 id(settings 提供商预设的 protocol 字段取值)。 */
14
+ id: string
15
+ /** 协议名称(前端展示)。 */
16
+ label: string
17
+ /** 支持的端点。 */
18
+ endpoints: readonly ImageEndpoint[]
19
+ /** 生图执行函数。 */
20
+ generate: GenerateFn
21
+ }
22
+
23
+ const PROTOCOLS: readonly ProtocolEntry[] = Object.freeze([
24
+ {
25
+ id: 'openai-images',
26
+ label: 'OpenAI Images API',
27
+ endpoints: ['generation', 'edit'],
28
+ generate: generateViaOpenAIImages,
29
+ },
30
+ ])
31
+
32
+ /** 全部已注册协议(前端下拉依据)。 */
33
+ export function listProtocols(): Array<Pick<ProtocolEntry, 'id' | 'label' | 'endpoints'>> {
34
+ return PROTOCOLS.map(({ id, label, endpoints }) => ({ id, label, endpoints }))
35
+ }
36
+
37
+ /** 按协议 id 查条目;未知协议抛结构化错误。 */
38
+ export function protocolOf(id: string): ProtocolEntry {
39
+ const found = PROTOCOLS.find((entry) => entry.id === id)
40
+ if (found === undefined) {
41
+ throw new ImageStudioError('unknown-protocol', `未知协议:${id}`)
42
+ }
43
+ return found
44
+ }
45
+
46
+ /** 构造适配器(当前实现无状态,直接包一层满足 ImageProvider 契约)。 */
47
+ export function createProvider(id: string): ImageProvider {
48
+ const entry = protocolOf(id)
49
+ return {
50
+ id: entry.id,
51
+ endpoints: entry.endpoints,
52
+ generate: (request, signal) => entry.generate(request, signal),
53
+ }
54
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 图像生成 provider 抽象:协议无关的请求/结果模型 + 适配器契约。
3
+ * 首批实现 openai-images;后续协议经 ImageProviderRegistry 挂载,不改核心。
4
+ * @module image-studio/provider/types
5
+ */
6
+
7
+ /** 生图端点(官方 Images API 两类;新增协议可扩展联合)。 */
8
+ export type ImageEndpoint = 'generation' | 'edit'
9
+
10
+ /** 提供商预设经用户层配置实例化后的请求基础(协议、地址、模型、鉴权)。 */
11
+ export interface ProviderTarget {
12
+ /** 协议 id(provider registry 键,首批 'openai-images')。 */
13
+ protocol: string
14
+ /** API 基础地址(如 https://api.openai.com/v1,兼容中转填中转地址)。 */
15
+ baseUrl: string
16
+ /** 模型 id(gpt-image-1 等)。 */
17
+ model: string
18
+ /** 已 resolve 的 API key(每次任务即时 resolve,不缓存)。 */
19
+ apiKey: string
20
+ /** 额外请求头(部分中转要求自定义头)。 */
21
+ extraHeaders?: Record<string, string>
22
+ /** HTTP 代理(空 = 直连)。 */
23
+ proxy: string
24
+ /** 单请求超时毫秒。 */
25
+ timeoutMs: number
26
+ }
27
+
28
+ /** 输入图片(图生图):宿主持有的字节数据 + MIME。 */
29
+ export interface InputImage {
30
+ data: Uint8Array
31
+ mime: string
32
+ }
33
+
34
+ /** 协议无关的归一化生图请求(参数面已按目录裁剪完毕)。 */
35
+ export interface NormalizedRequest {
36
+ target: ProviderTarget
37
+ endpoint: ImageEndpoint
38
+ prompt: string
39
+ /** 文生图张数(edits 端点同样合法)。 */
40
+ n: number
41
+ /** 图生图输入图(edit 时 ≥1;generation 恒空)。 */
42
+ images: InputImage[]
43
+ /** 可选 PNG mask(仅 edit;与 images[0] 配对)。 */
44
+ mask: InputImage | null
45
+ /** 除 prompt/n/image/mask 外的协议参数(key → 已验证值,仅含启用项)。 */
46
+ params: Record<string, unknown>
47
+ }
48
+
49
+ /** 单张结果图。 */
50
+ export interface GeneratedImage {
51
+ /** 图片字节(宿主已把 url/b64 统一拉取解码)。 */
52
+ data: Uint8Array
53
+ mime: string
54
+ /** 上游改写后的提示词(部分模型返回)。 */
55
+ revisedPrompt: string | null
56
+ }
57
+
58
+ /** 协议适配器解析出的结果。 */
59
+ export interface ProviderResult {
60
+ images: GeneratedImage[]
61
+ /** 上游原始 created 时间戳(秒),缺省 null。 */
62
+ createdAt: number | null
63
+ }
64
+
65
+ /** 适配器向上抛的结构化错误:HTTP 状态 + 上游响应摘要。 */
66
+ export class ProviderError extends Error {
67
+ readonly status: number
68
+ readonly upstreamBody: string
69
+
70
+ constructor(status: number, upstreamBody: string, message?: string) {
71
+ super(message ?? `上游返回 HTTP ${status}`)
72
+ this.name = 'ProviderError'
73
+ this.status = status
74
+ this.upstreamBody = upstreamBody
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 协议适配器契约:registry 按协议 id 构造;适配器只关心
80
+ * NormalizedRequest → HTTP 调用 → ProviderResult 的翻译。
81
+ */
82
+ export interface ImageProvider {
83
+ readonly id: string
84
+ readonly endpoints: readonly ImageEndpoint[]
85
+ generate(request: NormalizedRequest, signal: AbortSignal): Promise<ProviderResult>
86
+ }