Skip to content

Instantly share code, notes, and snippets.

@CodeBoy2006
Last active October 3, 2025 11:51
Show Gist options
  • Select an option

  • Save CodeBoy2006/4f23d42167a184bc94db44cb8ab0c34e to your computer and use it in GitHub Desktop.

Select an option

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.
// 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"})`);
@CodeBoy2006
Copy link
Author

核心功能

  • 兼容两种上游端点address 中是 /v1/chat/completions 就自动发送 messages 数组;是 /v1/completions 就发送 prompt 字符串,解决了 messages is empty 的问题
  • URL 传参:严格遵循 ?address=<完整上游 URL>$<客户端请求路径> 格式。
  • 功能:将上游返回的 Markdown 图片转为 OpenAI /v1/images/generations 规范的 base64 响应。
  • 透传:透传 Authorization,同时支持调用方直接传 messages 数组以覆盖 prompt
  • 调试:保留了极为详细的结构化日志(DEBUG=1 开启),方便排查。

运行

# 默认开启详细日志
deno run -A completions2images.ts
#
PORT=8787 DEBUG=1 deno run -A completions2images.ts
# 关闭日志
DEBUG=0 deno run -A completions2images.ts

调用示例

1. 上游是 /v1/chat/completions(推荐)

curl -X POST \
  'http://localhost:8787/?address=https://api.example.com/api/v1/chat/completions$/v1/images/generations' \
  -H 'Authorization: Bearer sk-xxx' \
  -H 'Content-Type: application/json' \
  -d '{ "model": "Qwen-Image", "prompt": "a cute corgi sticker", "n": 2 }'

2. 上游是 /v1/completions

curl -X POST \
  'http://localhost:8787/?address=https://api.example.com/v1/completions$/v1/images/generations' \
  -H 'Authorization: Bearer sk-xxx' \
  -H 'Content-Type: application/json' \
  -d '{ "model": "dall-e-3", "prompt": "a cute corgi sticker", "n": 2 }'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment