feat(baoyu-image-gen): improve Azure OpenAI provider with flexible endpoint parsing and deployment resolution

This commit is contained in:
Jim Liu 宝玉
2026-03-24 19:19:49 -05:00
parent 1653b8544b
commit 00e74ab071
10 changed files with 385 additions and 30 deletions
@@ -123,6 +123,7 @@ default_image_size: 2K
default_model:
google: gemini-3-pro-image-preview
openai: gpt-image-1.5
azure: image-prod
batch:
max_workers: 8
provider_limits:
@@ -131,6 +132,9 @@ batch:
start_interval_ms: 900
openai:
concurrency: 4
azure:
concurrency: 1
start_interval_ms: 1500
`;
const config = parseSimpleYaml(yaml);
@@ -142,6 +146,7 @@ batch:
assert.equal(config.default_image_size, "2K");
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
assert.equal(config.default_model?.openai, "gpt-image-1.5");
assert.equal(config.default_model?.azure, "image-prod");
assert.equal(config.batch?.max_workers, 8);
assert.deepEqual(config.batch?.provider_limits?.google, {
concurrency: 2,
@@ -150,6 +155,10 @@ batch:
assert.deepEqual(config.batch?.provider_limits?.openai, {
concurrency: 4,
});
assert.deepEqual(config.batch?.provider_limits?.azure, {
concurrency: 1,
start_interval_ms: 1500,
});
});
test("mergeConfig only fills values missing from CLI args", () => {
@@ -203,6 +212,8 @@ test("detectProvider selects an available ref-capable provider for reference-ima
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: "openai-key",
AZURE_OPENAI_API_KEY: null,
AZURE_OPENAI_BASE_URL: null,
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
REPLICATE_API_TOKEN: null,
@@ -216,6 +227,27 @@ test("detectProvider selects an available ref-capable provider for reference-ima
);
});
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
OPENAI_API_KEY: null,
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
OPENROUTER_API_KEY: null,
DASHSCOPE_API_KEY: null,
REPLICATE_API_TOKEN: null,
JIMENG_ACCESS_KEY_ID: null,
JIMENG_SECRET_ACCESS_KEY: null,
ARK_API_KEY: null,
});
assert.equal(detectProvider(makeArgs()), "azure");
assert.equal(
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
"azure",
);
});
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
useEnv(t, {
GOOGLE_API_KEY: null,
+4 -3
View File
@@ -133,9 +133,10 @@ Environment variables:
REPLICATE_BASE_URL Custom Replicate endpoint
JIMENG_BASE_URL Custom Jimeng endpoint
AZURE_OPENAI_API_KEY Azure OpenAI API key
AZURE_OPENAI_BASE_URL Azure OpenAI deployment endpoint
AZURE_API_VERSION Azure API version (default: 2024-02-01)
AZURE_OPENAI_IMAGE_MODEL Default Azure model (gpt-image-1.5)
AZURE_OPENAI_BASE_URL Azure OpenAI resource or deployment endpoint
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-1.5)
SEEDREAM_BASE_URL Custom Seedream endpoint
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test, { type TestContext } from "node:test";
import type { CliArgs } from "../types.ts";
import {
generateImage,
getDefaultModel,
parseAzureBaseURL,
validateArgs,
} from "./azure.ts";
function useEnv(
t: TestContext,
values: Record<string, string | null>,
): void {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(values)) {
previous.set(key, process.env[key]);
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
t.after(() => {
for (const [key, value] of previous.entries()) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
prompt: null,
promptFiles: [],
imagePath: null,
provider: null,
model: null,
aspectRatio: null,
size: null,
quality: null,
imageSize: null,
referenceImages: [],
n: 1,
batchFile: null,
jobs: null,
json: false,
help: false,
...overrides,
};
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("Azure endpoint parsing and default deployment selection follow env precedence", (t) => {
assert.deepEqual(parseAzureBaseURL("https://example.openai.azure.com"), {
resourceBaseURL: "https://example.openai.azure.com/openai",
deployment: null,
});
assert.deepEqual(
parseAzureBaseURL("https://example.openai.azure.com/openai/deployments/from-url"),
{
resourceBaseURL: "https://example.openai.azure.com/openai",
deployment: "from-url",
},
);
useEnv(t, {
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/from-url",
AZURE_OPENAI_DEPLOYMENT: "explicit-deploy",
AZURE_OPENAI_IMAGE_MODEL: "env-fallback",
});
assert.equal(getDefaultModel(), "explicit-deploy");
});
test("Azure validateArgs rejects unsupported edit input formats before the API call", () => {
assert.doesNotThrow(() =>
validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.png", "photo.jpeg"] })),
);
assert.throws(
() => validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.webp"] })),
/PNG or JPG\/JPEG/,
);
});
test("Azure image generation routes model to deployment and sends mapped quality", async (t) => {
useEnv(t, {
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/default-deploy",
AZURE_API_VERSION: null,
AZURE_OPENAI_DEPLOYMENT: null,
AZURE_OPENAI_IMAGE_MODEL: null,
});
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ url: string; body: string }> = [];
globalThis.fetch = async (input, init) => {
calls.push({
url: String(input),
body: String(init?.body ?? ""),
});
return Response.json({
data: [{ b64_json: Buffer.from("azure-image").toString("base64") }],
});
};
const bytes = await generateImage(
"A calm lake at sunset",
"custom-deploy",
makeArgs({ quality: "normal" }),
);
assert.equal(Buffer.from(bytes).toString("utf8"), "azure-image");
assert.equal(
calls[0]?.url,
"https://example.openai.azure.com/openai/deployments/custom-deploy/images/generations?api-version=2025-04-01-preview",
);
const body = JSON.parse(calls[0]!.body) as Record<string, string>;
assert.equal(body.quality, "medium");
assert.equal(body.size, "1024x1024");
});
test("Azure image edits include quality in multipart requests", async (t) => {
const root = await makeTempDir("baoyu-image-gen-azure-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
const pngPath = path.join(root, "ref.png");
const jpgPath = path.join(root, "ref.jpg");
await fs.writeFile(pngPath, "png-bytes");
await fs.writeFile(jpgPath, "jpg-bytes");
useEnv(t, {
AZURE_OPENAI_API_KEY: "azure-key",
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
AZURE_API_VERSION: "2025-04-01-preview",
AZURE_OPENAI_DEPLOYMENT: null,
AZURE_OPENAI_IMAGE_MODEL: null,
});
const originalFetch = globalThis.fetch;
t.after(() => {
globalThis.fetch = originalFetch;
});
const calls: Array<{ url: string; form: FormData }> = [];
globalThis.fetch = async (input, init) => {
calls.push({
url: String(input),
form: init?.body as FormData,
});
return Response.json({
data: [{ b64_json: Buffer.from("edited-image").toString("base64") }],
});
};
const bytes = await generateImage(
"Add warm lighting",
"edit-deploy",
makeArgs({
quality: "2k",
referenceImages: [pngPath, jpgPath],
}),
);
assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image");
assert.equal(
calls[0]?.url,
"https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview",
);
assert.equal(calls[0]?.form.get("quality"), "high");
assert.equal(calls[0]?.form.get("size"), "1024x1024");
assert.equal(calls[0]?.form.getAll("image[]").length, 2);
});
@@ -1,22 +1,62 @@
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { CliArgs } from "../types";
import { getOpenAISize, parseAspectRatio, getMimeType, extractImageFromResponse } from "./openai";
import { getOpenAISize, extractImageFromResponse } from "./openai.ts";
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
type AzureEndpoint = {
resourceBaseURL: string;
deployment: string | null;
};
const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview";
const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
export function parseAzureBaseURL(url: string): AzureEndpoint {
const parsed = new URL(url);
const trimmedPath = parsed.pathname.replace(/\/+$/, "");
const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/);
if (deploymentMatch) {
parsed.pathname = `${deploymentMatch[1] || ""}/openai`;
return {
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
deployment: decodeURIComponent(deploymentMatch[2]!),
};
}
parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`;
return {
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
deployment: null,
};
}
export function getDefaultModel(): string {
const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim();
if (explicitDeployment) return explicitDeployment;
const baseURL = process.env.AZURE_OPENAI_BASE_URL;
if (baseURL) {
try {
const { deployment } = parseAzureBaseURL(baseURL);
if (deployment) return deployment;
} catch {
// Ignore invalid URLs here so the required-env check can raise the user-facing error later.
}
}
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
}
function getBaseURL(): string {
function getEndpoint(): AzureEndpoint {
const url = process.env.AZURE_OPENAI_BASE_URL;
if (!url) {
throw new Error(
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure deployment endpoint, e.g.: https://your-resource.openai.azure.com/openai/deployments/your-deployment"
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment"
);
}
return url.replace(/\/+$/, "");
return parseAzureBaseURL(url);
}
function getApiKey(): string {
@@ -30,40 +70,72 @@ function getApiKey(): string {
}
function getApiVersion(): string {
return process.env.AZURE_API_VERSION || "2024-02-01";
return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION;
}
function buildURL(pathSuffix: string): string {
return `${getBaseURL()}${pathSuffix}?api-version=${getApiVersion()}`;
function getDeployment(model: string): string {
const deployment = model.trim();
if (!deployment) {
throw new Error(
"Azure deployment name is required. Use --model <deployment>, AZURE_OPENAI_DEPLOYMENT, AZURE_OPENAI_IMAGE_MODEL, or embed the deployment in AZURE_OPENAI_BASE_URL."
);
}
return deployment;
}
function buildURL(deployment: string, pathSuffix: string): string {
const { resourceBaseURL } = getEndpoint();
return `${resourceBaseURL}/deployments/${encodeURIComponent(deployment)}${pathSuffix}?api-version=${getApiVersion()}`;
}
function authHeaders(): Record<string, string> {
return { "api-key": getApiKey() };
}
function getAzureQuality(quality: CliArgs["quality"]): "medium" | "high" {
return quality === "2k" ? "high" : "medium";
}
export function validateArgs(_model: string, args: CliArgs): void {
for (const refPath of args.referenceImages) {
const ext = path.extname(refPath).toLowerCase();
if (!AZURE_EDIT_IMAGE_EXTENSIONS.has(ext)) {
throw new Error(
`Azure OpenAI reference images must be PNG or JPG/JPEG. Unsupported file: ${refPath}`
);
}
}
}
export async function generateImage(
prompt: string,
model: string,
args: CliArgs
): Promise<Uint8Array> {
const deployment = getDeployment(model);
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
if (args.referenceImages.length > 0) {
return generateWithAzureEdits(prompt, model, size, args.referenceImages, args.quality);
return generateWithAzureEdits(prompt, deployment, size, args.referenceImages, args.quality);
}
return generateWithAzureGenerations(prompt, model, size, args.quality);
return generateWithAzureGenerations(prompt, deployment, size, args.quality);
}
async function generateWithAzureGenerations(
prompt: string,
model: string,
deployment: string,
size: string,
quality: CliArgs["quality"]
): Promise<Uint8Array> {
const body: Record<string, any> = { prompt, size, n: 1 };
const body: Record<string, any> = {
prompt,
size,
n: 1,
quality: getAzureQuality(quality),
};
const res = await fetch(buildURL("/images/generations"), {
const res = await fetch(buildURL(deployment, "/images/generations"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -83,7 +155,7 @@ async function generateWithAzureGenerations(
async function generateWithAzureEdits(
prompt: string,
model: string,
deployment: string,
size: string,
referenceImages: string[],
quality: CliArgs["quality"]
@@ -91,16 +163,18 @@ async function generateWithAzureEdits(
const form = new FormData();
form.append("prompt", prompt);
form.append("size", size);
form.append("n", "1");
form.append("quality", getAzureQuality(quality));
for (const refPath of referenceImages) {
const bytes = await readFile(refPath);
const filename = path.basename(refPath);
const mimeType = getMimeType(filename);
const mimeType = path.extname(filename).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
const blob = new Blob([bytes], { type: mimeType });
form.append("image[]", blob, filename);
}
const res = await fetch(buildURL("/images/edits"), {
const res = await fetch(buildURL(deployment, "/images/edits"), {
method: "POST",
headers: {
...authHeaders(),