Last active
October 3, 2025 11:51
-
-
Save CodeBoy2006/4f23d42167a184bc94db44cb8ab0c34e to your computer and use it in GitHub Desktop.
Deno single-file gateway that converts a "markdown-image via /v1(completions)" upstream into an OpenAI-compatible /v1/images/generations (b64_json) response.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Deno single-file gateway that converts a "markdown-image via /v1(chat|)completions" upstream | |
| // into an OpenAI-compatible /v1/images/generations (b64_json) response. | |
| // | |
| // ✅ Simplified for "prompt-only" inputs: always converts `prompt` to `messages` for chat endpoints. | |
| // ✅ URL passing: POST http://localhost:8787/?address=https://upstream.example/v1/chat/completions$/v1/images/generations | |
| // ✅ Works even if real upstream path is nested in query: ?address=https://rev-proxy/?address=https://api.example/v1/chat/completions | |
| // ✅ Forwards Authorization, parses markdown URLs, downloads images, returns base64 | |
| // ✅ Zero deps; uses btoa for base64 | |
| // ✅ Extremely detailed debugging with structured logs (DEBUG=1 by default) | |
| /// ---------- Debug / Log Utilities ---------- | |
| const DEBUG = (Deno.env.get("DEBUG") ?? "1") !== "0"; | |
| const ECHO_DEBUG = (Deno.env.get("ECHO_DEBUG") ?? "0") === "1"; | |
| function newReqId(): string { | |
| return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; | |
| } | |
| function maskBearer(val?: string | null): string | null { | |
| if (!val) return null; | |
| const m = val.match(/^Bearer\s+(.+)$/i); | |
| if (!m) return val; | |
| const token = m[1]; | |
| if (token.length <= 10) return `Bearer ${"*".repeat(token.length)}`; | |
| return `Bearer ${token.slice(0, 6)}...${token.slice(-4)}`; | |
| } | |
| function headerSnapshot(headers: Headers) { | |
| const h: Record<string, string> = {}; | |
| for (const [k, v] of headers.entries()) { | |
| const key = k.toLowerCase(); | |
| if (key === "authorization") h[key] = maskBearer(v) ?? ""; | |
| else if (key === "cookie") h[key] = "[redacted]"; | |
| else h[key] = v; | |
| } | |
| return h; | |
| } | |
| function safeJsonPreview(obj: unknown, max = 500): string { | |
| try { | |
| const s = JSON.stringify(obj); | |
| return s.length > max ? s.slice(0, max) + `...(+${s.length - max} chars)` : s; | |
| } catch { | |
| return "[unserializable]"; | |
| } | |
| } | |
| function safeTextPreview(s: string, max = 500): string { | |
| return s.length > max ? s.slice(0, max) + `...(+${s.length - max} chars)` : s; | |
| } | |
| function debugLog(reqId: string, stage: string, data: Record<string, unknown> = {}) { | |
| if (!DEBUG) return; | |
| console.log(JSON.stringify({ time: new Date().toISOString(), level: "debug", reqId, stage, ...data })); | |
| } | |
| function info(reqId: string, msg: string, extra: Record<string, unknown> = {}) { | |
| if (!DEBUG) return; | |
| console.log(JSON.stringify({ time: new Date().toISOString(), level: "info", reqId, msg, ...extra })); | |
| } | |
| function err(reqId: string, msg: string, extra: Record<string, unknown> = {}) { | |
| console.error(JSON.stringify({ time: new Date().toISOString(), level: "error", reqId, msg, ...extra })); | |
| } | |
| /// ---------- HTTP Helpers ---------- | |
| function corsHeaders(origin?: string) { | |
| return { | |
| "Access-Control-Allow-Origin": origin ?? "*", | |
| "Access-Control-Allow-Headers": "authorization,content-type", | |
| "Access-Control-Allow-Methods": "GET,POST,OPTIONS", | |
| }; | |
| } | |
| function jsonResponse(body: unknown, init: ResponseInit = {}) { | |
| const headers = new Headers(init.headers); | |
| if (!headers.has("content-type")) { | |
| headers.set("content-type", "application/json; charset=utf-8"); | |
| } | |
| return new Response(JSON.stringify(body), { ...init, headers }); | |
| } | |
| function openAIError( | |
| reqId: string, | |
| message: string, | |
| code: string | null = null, | |
| status = 400, | |
| type = "invalid_request_error", | |
| extra: Record<string, unknown> = {}, | |
| ) { | |
| if (DEBUG) err(reqId, `openAIError: ${message}`, { code, type, status, ...extra }); | |
| const body: any = { error: { message, type, param: null, code } }; | |
| if (ECHO_DEBUG) { | |
| body.debug = { reqId, status, code, type, note: "ECHO_DEBUG is enabled" }; | |
| } | |
| return jsonResponse(body, { status }); | |
| } | |
| /// ---------- Core Utils ---------- | |
| function bytesToBase64(bytes: Uint8Array): string { | |
| let bin = ""; | |
| const chunk = 0x8000; // 32KB | |
| for (let i = 0; i < bytes.length; i += chunk) { | |
| const sub = bytes.subarray(i, i + chunk); | |
| bin += String.fromCharCode(...sub); | |
| } | |
| return btoa(bin); | |
| } | |
| function extractCompletionText(j: any): string { | |
| if (!j || !Array.isArray(j.choices) || j.choices.length === 0) return ""; | |
| const parts: string[] = []; | |
| for (const c of j.choices) { | |
| if (typeof c?.text === "string") parts.push(c.text); // /v1/completions | |
| else if (typeof c?.message?.content === "string") parts.push(c.message.content); // /v1/chat/completions | |
| } | |
| return parts.join("\n\n").trim(); | |
| } | |
| function extractImageUrls(markdown: string): string[] { | |
| const s = new Set<string>(); | |
| [ | |
| /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, | |
| /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi, | |
| /\bhttps?:\/\/[^\s)'"`]+?\.(?:png|jpe?g|webp|gif|svg)(?:\?[^\s)'"`]*)?/gi, | |
| /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, | |
| ].forEach((re) => { | |
| for (const m of markdown.matchAll(re)) { | |
| const u = m[1]?.trim() || m[0]?.trim(); | |
| if (u) s.add(u); | |
| } | |
| }); | |
| return Array.from(s).filter((u) => /^https?:\/\//.test(u) || u.startsWith("data:")); | |
| } | |
| async function urlToBase64(reqId: string, u: string): Promise<{ b64: string; meta: Record<string, unknown> }> { | |
| const t0 = performance.now(); | |
| if (u.startsWith("data:")) { | |
| const idx = u.indexOf(","); | |
| if (idx < 0) throw new Error("Invalid data URL."); | |
| const payload = u.slice(idx + 1); | |
| const meta = { kind: "data-url", size_b64: payload.length, duration_ms: Math.round(performance.now() - t0) }; | |
| debugLog(reqId, "image.data_url", meta); | |
| return { b64: payload, meta }; | |
| } | |
| debugLog(reqId, "image.fetch.begin", { url: u }); | |
| const resp = await fetch(u); | |
| if (!resp.ok) throw new Error(`Failed to fetch image: ${u} (status ${resp.status})`); | |
| const bytes = new Uint8Array(await resp.arrayBuffer()); | |
| const b64 = bytesToBase64(bytes); | |
| const meta = { | |
| kind: "http", | |
| status: resp.status, | |
| url: u, | |
| content_type: resp.headers.get("content-type") ?? "", | |
| bytes: bytes.byteLength, | |
| duration_ms: Math.round(performance.now() - t0), | |
| }; | |
| debugLog(reqId, "image.fetch.done", meta); | |
| return { b64, meta }; | |
| } | |
| function resolveUpstreamPathname(address: string): string { | |
| try { | |
| const u = new URL(address); | |
| let pathname = u.pathname || "/"; | |
| const inner = u.searchParams.get("address"); | |
| if (inner) { | |
| try { | |
| const iu = new URL(inner); | |
| if (iu.pathname && iu.pathname !== "/") pathname = iu.pathname; | |
| } catch { /* ignore */ } | |
| } | |
| return pathname; | |
| } catch { | |
| return "/"; | |
| } | |
| } | |
| function isChatAddress(address: string): boolean { | |
| const pn = resolveUpstreamPathname(address); | |
| return /\/chat\/completions\b/.test(pn); | |
| } | |
| /** | |
| * ---------- 🆕 简化版 Payload 构造器 (核心简化) ---------- | |
| * 根据你的要求,这个版本只处理 `prompt` 输入。 | |
| */ | |
| function buildUpstreamPayload(address: string, imagesBody: any) { | |
| const { | |
| model = "gpt-4o-mini", | |
| temperature = 0.2, | |
| top_p, | |
| frequency_penalty, | |
| presence_penalty, | |
| stop, | |
| max_tokens, | |
| prompt, // 只关心 prompt | |
| } = imagesBody ?? {}; | |
| // 统一检查 prompt,因为现在它总是必需的 | |
| if (typeof prompt !== "string" || !prompt.trim()) { | |
| throw new Error("A non-empty `prompt` field is required in the request body."); | |
| } | |
| const base: Record<string, unknown> = { model, temperature, stream: false }; | |
| if (typeof top_p === "number") base.top_p = top_p; | |
| if (typeof frequency_penalty === "number") base.frequency_penalty = frequency_penalty; | |
| if (typeof presence_penalty === "number") base.presence_penalty = presence_penalty; | |
| if (typeof max_tokens === "number") base.max_tokens = max_tokens; | |
| if (Array.isArray(stop) || typeof stop === "string") base.stop = stop; | |
| if (isChatAddress(address)) { | |
| // 目标是 chat 接口,总是将 prompt 转换为 messages | |
| return { ...base, messages: [{ role: "user", content: prompt }] }; | |
| } else { | |
| // 目标是 completions 接口,直接使用 prompt | |
| return { ...base, prompt }; | |
| } | |
| } | |
| function parseDollarStyle(requestUrlString: string): { address: string | null; extraPath: string } { | |
| const dollarIndex = requestUrlString.indexOf("$"); | |
| let baseUrlString = requestUrlString, extraPath = ""; | |
| if (dollarIndex !== -1) { | |
| baseUrlString = requestUrlString.substring(0, dollarIndex); | |
| extraPath = requestUrlString.substring(dollarIndex + 1); | |
| } | |
| const base = new URL(baseUrlString); | |
| const address = base.searchParams.get("address"); | |
| return { address, extraPath }; | |
| } | |
| /// ---------- Server ---------- | |
| const PORT = Number(Deno.env.get("PORT") ?? 8787); | |
| Deno.serve({ port: PORT }, async (req) => { | |
| const reqId = newReqId(); | |
| const origin = req.headers.get("origin") ?? "*"; | |
| const tReq0 = performance.now(); | |
| debugLog(reqId, "request.begin", { method: req.method, url: req.url, headers: headerSnapshot(req.headers) }); | |
| if (req.method === "OPTIONS") { | |
| info(reqId, "cors.preflight"); | |
| return new Response(null, { headers: corsHeaders(origin) }); | |
| } | |
| const { address, extraPath } = parseDollarStyle(req.url); | |
| debugLog(reqId, "url.parse", { address, extraPath }); | |
| if (!address) { | |
| return openAIError(reqId, "Missing target address. Expected: ?address=...$/...", "missing_address"); | |
| } | |
| const isImagesRoute = | |
| req.method === "POST" && | |
| (extraPath.split("?")[0] === "/v1/images/generations" || extraPath.split("?")[0] === "/v1/images"); | |
| if (!isImagesRoute) { | |
| return openAIError(reqId, "Route not found. Use POST with path /v1/images/generations after '$'", "route_not_found", 404); | |
| } | |
| let bodyJson: any; | |
| try { | |
| bodyJson = await req.json(); | |
| } catch (e) { | |
| return openAIError(reqId, "Invalid JSON body.", "invalid_json", 400, "invalid_request_error", { error: String(e) }); | |
| } | |
| debugLog(reqId, "request.body.parsed", { preview: safeJsonPreview(bodyJson) }); | |
| const auth = req.headers.get("authorization"); | |
| if (!auth || !/^Bearer\s+\S+/.test(auth)) { | |
| return openAIError(reqId, "Missing Authorization Bearer token.", "unauthorized", 401, "authentication_error"); | |
| } | |
| const chatLike = isChatAddress(address); | |
| debugLog(reqId, "upstream.detect", { | |
| resolved_path: resolveUpstreamPathname(address), | |
| is_chat: chatLike, | |
| }); | |
| let payload: Record<string, unknown>; | |
| try { | |
| payload = buildUpstreamPayload(address, bodyJson); | |
| } catch (e) { | |
| return openAIError(reqId, (e as Error).message, "invalid_request", 400); | |
| } | |
| debugLog(reqId, "upstream.payload", { to: address, payload_preview: safeJsonPreview(payload) }); | |
| const tUp0 = performance.now(); | |
| let upstreamResp: Response; | |
| try { | |
| upstreamResp = await fetch(address, { | |
| method: "POST", | |
| headers: { "content-type": "application/json", "authorization": auth }, | |
| body: JSON.stringify(payload), | |
| }); | |
| } catch (e) { | |
| return openAIError(reqId, `Failed to reach target: ${(e as Error).message}`, "upstream_unreachable", 502, "upstream_error"); | |
| } | |
| const tUp1 = performance.now(); | |
| debugLog(reqId, "upstream.response.head", { status: upstreamResp.status, ok: upstreamResp.ok, duration_ms: Math.round(tUp1 - tUp0) }); | |
| if (!upstreamResp.ok) { | |
| let detail: any = null; | |
| try { detail = await upstreamResp.json(); } catch { /* ignore */ } | |
| const message = detail?.error?.message || detail?.message || `Upstream error ${upstreamResp.status}`; | |
| const code = detail?.error?.code || null; | |
| return openAIError(reqId, `Target error: ${message}`, code, upstreamResp.status, detail?.error?.type ?? "upstream_error", { upstream_detail_preview: safeJsonPreview(detail) }); | |
| } | |
| const upstreamJson = await upstreamResp.json(); | |
| const text = extractCompletionText(upstreamJson); | |
| if (!text) { | |
| return openAIError(reqId, "Upstream returned no text content in `choices`.", "empty_upstream_text", 502, "upstream_error"); | |
| } | |
| debugLog(reqId, "markdown.text", { preview: safeTextPreview(text) }); | |
| const urls = extractImageUrls(text); | |
| if (urls.length === 0) { | |
| return openAIError(reqId, "No image URLs found in upstream markdown content.", "no_image_urls", 502, "upstream_error"); | |
| } | |
| info(reqId, "image.urls.extracted", { count: urls.length, urls }); | |
| const n = typeof bodyJson?.n === "number" && bodyJson.n > 0 ? Math.floor(bodyJson.n) : undefined; | |
| const selected = typeof n === "number" ? urls.slice(0, n) : urls; | |
| debugLog(reqId, "image.urls.selected", { n: n ?? "all", count: selected.length, urls: selected }); | |
| const tImg0 = performance.now(); | |
| let b64List: string[] = []; | |
| const imageMetas: any[] = []; | |
| try { | |
| for (const u of selected) { | |
| const { b64, meta } = await urlToBase64(reqId, u); | |
| b64List.push(b64); | |
| imageMetas.push(meta); | |
| } | |
| } catch (e) { | |
| return openAIError(reqId, `Failed to fetch at least one image: ${(e as Error).message}`, "image_fetch_failed", 502, "upstream_error"); | |
| } | |
| info(reqId, "image.download.complete", { images: b64List.length, total_ms: Math.round(performance.now() - tImg0), metas: imageMetas }); | |
| const created = Math.floor(Date.now() / 1000); | |
| const data = b64List.map((b64) => ({ b64_json: b64 })); | |
| const respBody: any = { created, data }; | |
| if (ECHO_DEBUG) { | |
| respBody.debug = { reqId, total_ms: Math.round(performance.now() - tReq0), upstream_url: address, route: extraPath, is_chat: chatLike }; | |
| } | |
| info(reqId, "response.ready", { status: 200, image_count: data.length, total_ms: Math.round(performance.now() - tReq0) }); | |
| return jsonResponse(respBody, { headers: { ...corsHeaders(origin), "x-request-id": reqId } }); | |
| }); | |
| console.log(`AI Image Gateway listening on http://localhost:${PORT} (DEBUG=${DEBUG ? "on" : "off"}, ECHO_DEBUG=${ECHO_DEBUG ? "on" : "off"})`); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
核心功能
address中是/v1/chat/completions就自动发送messages数组;是/v1/completions就发送prompt字符串,解决了messages is empty的问题。?address=<完整上游 URL>$<客户端请求路径>格式。/v1/images/generations规范的 base64 响应。Authorization,同时支持调用方直接传messages数组以覆盖prompt。DEBUG=1开启),方便排查。运行
调用示例
1. 上游是
/v1/chat/completions(推荐)2. 上游是
/v1/completions