| 1 | import log from 'electron-log/main.js' |
| 2 | import type { |
| 3 | ImageGenerationProviderAdapter, |
| 4 | ImageGenerationResult, |
| 5 | ResolvedImageModelConfig |
| 6 | } from '../types' |
| 7 | import { collectImageResults, joinUrl, readRecord, readString } from './utils' |
| 8 | |
| 9 | const DEFAULT_BASE_URL = 'https://api.openai.com/v1' |
| 10 | const DEFAULT_MODEL = 'gpt-image-1' |
| 11 | const LOG_TAG = 'openai-chat-completions' |
| 12 | |
| 13 | type ChatMessage = { |
| 14 | role: 'system' | 'user' |
| 15 | content: string |
| 16 | } |
| 17 | |
| 18 | const toErrorMessage = (error: unknown): string => |
| 19 | error instanceof Error ? error.message : String(error) |
| 20 | |
| 21 | const resolveBaseUrl = (config: ResolvedImageModelConfig): string => |
| 22 | (readString(config.modelConfig, 'baseUrl') || DEFAULT_BASE_URL).replace(/\/+$/, '') |
| 23 | |
| 24 | const buildEndpoint = (config: ResolvedImageModelConfig): string => { |
| 25 | const baseUrl = readString(config.modelConfig, 'baseUrl') || DEFAULT_BASE_URL |
| 26 | const normalized = baseUrl.replace(/\/+$/, '') |
| 27 | if (/\/chat\/completions$/i.test(normalized)) return normalized |
| 28 | try { |
| 29 | const url = new URL(normalized) |
| 30 | const path = url.pathname.replace(/\/+$/, '') |
| 31 | if (!path) return joinUrl(normalized, '/v1/chat/completions') |
| 32 | } catch { |
| 33 | // Keep the fallback path below for non-standard but still fetchable base URLs. |
| 34 | } |
| 35 | return joinUrl(normalized, '/chat/completions') |
| 36 | } |
| 37 | |
| 38 | const trimResponseText = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 500) |
| 39 | |
| 40 | const readChatJsonResponse = async (response: Response): Promise<unknown> => { |
| 41 | const contentType = response.headers.get('content-type') || '' |
| 42 | const text = await response.text() |
| 43 | const preview = trimResponseText(text) |
| 44 | if (!response.ok) { |
| 45 | throw new Error( |
| 46 | `OpenAI Chat Completions failed (${response.status}, ${contentType || 'unknown content-type'}): ${ |
| 47 | preview || 'empty response' |
| 48 | }` |
| 49 | ) |
| 50 | } |
| 51 | try { |
| 52 | return JSON.parse(text) |
| 53 | } catch { |
| 54 | throw new Error( |
| 55 | `OpenAI Chat Completions returned invalid JSON (${response.status}, ${ |
| 56 | contentType || 'unknown content-type' |
| 57 | }): ${preview || 'empty response'}` |
| 58 | ) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | const collectMarkdownImageUrls = (content: string): string[] => { |
| 63 | const urls: string[] = [] |
| 64 | for (const match of content.matchAll(/!\[[^\]]*]\(([^)]+)\)/g)) { |
| 65 | const url = match[1]?.trim() |
| 66 | if (url) urls.push(url) |
| 67 | } |
| 68 | for (const match of content.matchAll(/data:image\/[^;,]+;base64,[A-Za-z0-9+/=]+/g)) { |
| 69 | urls.push(match[0]) |
| 70 | } |
| 71 | const trimmed = content.trim() |
| 72 | if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) { |
| 73 | urls.push(trimmed) |
| 74 | } |
| 75 | return urls |
| 76 | } |
| 77 | |
| 78 | const pushCandidateFromImageUrl = (value: unknown, candidates: unknown[]): boolean => { |
| 79 | if (typeof value === 'string' && value.trim()) { |
| 80 | candidates.push(value.trim()) |
| 81 | return true |
| 82 | } |
| 83 | const record = readRecord(value) |
| 84 | const url = readString(record, 'url') |
| 85 | if (url) { |
| 86 | candidates.push(url) |
| 87 | return true |
| 88 | } |
| 89 | return false |
| 90 | } |
| 91 | |
| 92 | const collectImageCandidates = (value: unknown, candidates: unknown[], depth = 0): void => { |
| 93 | if (depth > 8 || value == null) return |
| 94 | |
| 95 | if (typeof value === 'string') { |
| 96 | candidates.push(...collectMarkdownImageUrls(value)) |
| 97 | return |
| 98 | } |
| 99 | |
| 100 | if (Array.isArray(value)) { |
| 101 | for (const item of value) collectImageCandidates(item, candidates, depth + 1) |
| 102 | return |
| 103 | } |
| 104 | |
| 105 | const record = readRecord(value) |
| 106 | if (Object.keys(record).length === 0) return |
| 107 | |
| 108 | if (pushCandidateFromImageUrl(record.image_url, candidates)) return |
| 109 | |
| 110 | const url = readString(record, 'url') |
| 111 | const b64Json = readString(record, 'b64_json') |
| 112 | const base64 = readString(record, 'base64') |
| 113 | const data = readString(record, 'data') |
| 114 | if (url) candidates.push(url) |
| 115 | if (b64Json) candidates.push(b64Json) |
| 116 | if (base64) candidates.push(base64) |
| 117 | if (/^data:image\//i.test(data)) candidates.push(data) |
| 118 | |
| 119 | for (const key of [ |
| 120 | 'content', |
| 121 | 'additional_kwargs', |
| 122 | 'response_metadata', |
| 123 | 'tool_calls', |
| 124 | 'choices', |
| 125 | 'message', |
| 126 | 'output', |
| 127 | 'images', |
| 128 | 'results' |
| 129 | ]) { |
| 130 | if (key in record) collectImageCandidates(record[key], candidates, depth + 1) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | const collectMessageImageResults = async ( |
| 135 | payload: unknown, |
| 136 | signal?: AbortSignal |
| 137 | ): Promise<ImageGenerationResult[]> => { |
| 138 | const candidates: unknown[] = [] |
| 139 | collectImageCandidates(payload, candidates) |
| 140 | return collectImageResults({ data: candidates }, signal) |
| 141 | } |
| 142 | |
| 143 | const buildMessages = (config: ResolvedImageModelConfig, prompt: string): ChatMessage[] => { |
| 144 | const systemPrompt = readString(config.modelConfig, 'systemPrompt') |
| 145 | return [ |
| 146 | ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []), |
| 147 | { role: 'user', content: prompt } |
| 148 | ] |
| 149 | } |
| 150 | |
| 151 | export const openAiChatCompletionsAdapter: ImageGenerationProviderAdapter = { |
| 152 | async generate(config, input) { |
| 153 | const startedAt = Date.now() |
| 154 | const baseUrl = resolveBaseUrl(config) |
| 155 | const endpoint = buildEndpoint(config) |
| 156 | const model = readString(config.modelConfig, 'model') || DEFAULT_MODEL |
| 157 | const apiKey = readString(config.modelConfig, 'apiKey') || readString(config.modelConfig, 'api_key') |
| 158 | if (!model) throw new Error('OpenAI-compatible Chat Completions image model is required') |
| 159 | if (!apiKey) throw new Error('OpenAI-compatible API key is required') |
| 160 | |
| 161 | const headers = readRecord(config.modelConfig.headers) as Record<string, string> |
| 162 | const modelKwargs = readRecord(config.modelConfig.modelKwargs) |
| 163 | const systemPrompt = readString(config.modelConfig, 'systemPrompt') |
| 164 | const messages = buildMessages(config, input.prompt) |
| 165 | const count = Math.max(1, input.count) |
| 166 | |
| 167 | log.info(`[images:${LOG_TAG}] generation start`, { |
| 168 | configId: config.id, |
| 169 | configName: config.name, |
| 170 | model, |
| 171 | baseUrl, |
| 172 | endpoint, |
| 173 | count, |
| 174 | promptLength: input.prompt.length, |
| 175 | hasSystemPrompt: Boolean(systemPrompt), |
| 176 | modelKwargsKeys: Object.keys(modelKwargs).sort() |
| 177 | }) |
| 178 | |
| 179 | try { |
| 180 | const response = await fetch(endpoint, { |
| 181 | method: 'POST', |
| 182 | signal: input.signal, |
| 183 | headers: { |
| 184 | accept: 'application/json', |
| 185 | authorization: `Bearer ${apiKey}`, |
| 186 | 'content-type': 'application/json', |
| 187 | ...headers |
| 188 | }, |
| 189 | body: JSON.stringify({ |
| 190 | model, |
| 191 | messages, |
| 192 | n: count, |
| 193 | stream: false, |
| 194 | ...modelKwargs |
| 195 | }) |
| 196 | }) |
| 197 | const payload = await readChatJsonResponse(response) |
| 198 | const responseRecord = readRecord(payload) |
| 199 | log.info(`[images:${LOG_TAG}] request completed`, { |
| 200 | model, |
| 201 | status: response.status, |
| 202 | choiceCount: Array.isArray(responseRecord.choices) ? responseRecord.choices.length : 0, |
| 203 | responseKeys: Object.keys(responseRecord).sort(), |
| 204 | elapsedMs: Date.now() - startedAt |
| 205 | }) |
| 206 | const results = await collectMessageImageResults(payload, input.signal) |
| 207 | if (results.length === 0) { |
| 208 | throw new Error('OpenAI-compatible Chat Completions returned no images') |
| 209 | } |
| 210 | log.info(`[images:${LOG_TAG}] generation completed`, { |
| 211 | model, |
| 212 | resultCount: results.length, |
| 213 | elapsedMs: Date.now() - startedAt |
| 214 | }) |
| 215 | return results.slice(0, count) |
| 216 | } catch (error) { |
| 217 | log.error(`[images:${LOG_TAG}] generation failed`, { |
| 218 | model, |
| 219 | message: toErrorMessage(error), |
| 220 | elapsedMs: Date.now() - startedAt |
| 221 | }) |
| 222 | throw error |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 |