From 00e74ab071d925cdfa7349f82947177532f3ff95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Tue, 24 Mar 2026 19:19:49 -0500 Subject: [PATCH] feat(baoyu-image-gen): improve Azure OpenAI provider with flexible endpoint parsing and deployment resolution --- CLAUDE.md | 2 +- README.md | 7 +- README.zh.md | 7 +- skills/baoyu-image-gen/SKILL.md | 22 +- .../references/config/first-time-setup.md | 35 ++++ .../references/config/preferences-schema.md | 11 +- skills/baoyu-image-gen/scripts/main.test.ts | 32 +++ skills/baoyu-image-gen/scripts/main.ts | 7 +- .../scripts/providers/azure.test.ts | 188 ++++++++++++++++++ .../scripts/providers/azure.ts | 104 ++++++++-- 10 files changed, 385 insertions(+), 30 deletions(-) create mode 100644 skills/baoyu-image-gen/scripts/providers/azure.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6da0142..1b47b8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Execute: `${BUN_X} skills//scripts/main.ts [options]` - **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`) - **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md) -- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md +- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md - **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh) ## Security diff --git a/README.md b/README.md index 2c461f1..a48b9b9 100644 --- a/README.md +++ b/README.md @@ -663,7 +663,7 @@ AI-powered generation backends. #### baoyu-image-gen -AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets. +AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets. ```bash # Basic generation (auto-detect provider) @@ -678,6 +678,9 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu # Specific provider /baoyu-image-gen --prompt "A cat" --image cat.png --provider openai +# Azure OpenAI (model = deployment name) +/baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5 + # OpenRouter /baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter @@ -693,7 +696,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu # Seedream (豆包) /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream -# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0) +# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0) /baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png ``` diff --git a/README.zh.md b/README.zh.md index f5f58fe..87a75f2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -663,7 +663,7 @@ AI 驱动的生成后端。 #### baoyu-image-gen -基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。 +基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。 ```bash # 基础生成(自动检测服务商) @@ -678,6 +678,9 @@ AI 驱动的生成后端。 # 指定服务商 /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai +# Azure OpenAI(model 为部署名称) +/baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5 + # OpenRouter /baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter @@ -693,7 +696,7 @@ AI 驱动的生成后端。 # 豆包(Seedream) /baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream -# 带参考图(Google、OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0) +# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0) /baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png ``` diff --git a/skills/baoyu-image-gen/SKILL.md b/skills/baoyu-image-gen/SKILL.md index 0eb908e..9539036 100644 --- a/skills/baoyu-image-gen/SKILL.md +++ b/skills/baoyu-image-gen/SKILL.md @@ -1,6 +1,6 @@ --- name: baoyu-image-gen -description: AI image generation with OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images. +description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images. version: 1.56.3 metadata: openclaw: @@ -13,7 +13,7 @@ metadata: # Image Generation (AI SDK) -Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers. +Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers. ## Script Directory @@ -74,12 +74,15 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k # From prompt files ${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png -# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0) +# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0) ${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png # With reference images (explicit provider/model) ${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png +# Azure OpenAI (model means deployment name) +${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5 + # OpenRouter (recommended default model) ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter @@ -147,13 +150,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi | `--image ` | Output image path (required in single-image mode) | | `--batchfile ` | JSON batch file for multi-image generation | | `--jobs ` | Worker count for batch mode (default: auto, max from config, built-in default 10) | -| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) | -| `--model `, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) | +| `--provider google\|openai\|azure\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) | +| `--model `, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) | | `--ar ` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) | | `--size ` | Size (e.g., `1024x1024`) | | `--quality normal\|2k` | Quality preset (default: `2k`) | | `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) | -| `--ref ` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, Replicate, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 | +| `--ref ` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 | | `--n ` | Number of images | | `--json` | JSON output | @@ -162,6 +165,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi | Variable | Description | |----------|-------------| | `OPENAI_API_KEY` | OpenAI API key | +| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | | `OPENROUTER_API_KEY` | OpenRouter API key | | `GOOGLE_API_KEY` | Google API key | | `DASHSCOPE_API_KEY` | DashScope API key (阿里云) | @@ -170,6 +174,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi | `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key | | `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key | | `OPENAI_IMAGE_MODEL` | OpenAI model override | +| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | +| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible alias for Azure default deployment/model name | | `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) | | `GOOGLE_IMAGE_MODEL` | Google model override | | `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) | @@ -177,6 +183,8 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi | `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) | | `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) | | `OPENAI_BASE_URL` | Custom OpenAI endpoint | +| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint | +| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) | | `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `https://openrouter.ai/api/v1`) | | `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution | | `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | @@ -201,6 +209,8 @@ Model priority (highest → lowest), applies to all providers: 3. Env var: `_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`) 4. Built-in default +For Azure, `--model` / `default_model.azure` should be the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var, and `AZURE_OPENAI_IMAGE_MODEL` remains as a backward-compatible alias. + **EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins. **Agent MUST display model info** before each generation: diff --git a/skills/baoyu-image-gen/references/config/first-time-setup.md b/skills/baoyu-image-gen/references/config/first-time-setup.md index 78489fe..46aa746 100644 --- a/skills/baoyu-image-gen/references/config/first-time-setup.md +++ b/skills/baoyu-image-gen/references/config/first-time-setup.md @@ -47,6 +47,8 @@ options: description: "Gemini multimodal - high quality, reference images, flexible sizes" - label: "OpenAI" description: "GPT Image - consistent quality, reliable output" + - label: "Azure OpenAI" + description: "Azure-hosted GPT Image deployments with resource-specific routing" - label: "OpenRouter" description: "Router for Gemini/FLUX/OpenAI-compatible image models" - label: "DashScope" @@ -87,6 +89,20 @@ options: description: "Strong text-to-image quality through OpenRouter" ``` +### Question 2c: Default Azure Deployment + +Only show if user selected Azure OpenAI. + +```yaml +header: "Azure Deploy" +question: "Default Azure image deployment name?" +options: + - label: "gpt-image-1.5 (Recommended)" + description: "Best default if your Azure deployment uses the same name" + - label: "gpt-image-1" + description: "Previous GPT Image deployment name" +``` + ### Question 3: Default Quality ```yaml @@ -130,6 +146,7 @@ default_image_size: null default_model: google: [selected google model or null] openai: null + azure: [selected azure deployment or null] openrouter: [selected openrouter model or null] dashscope: null replicate: null @@ -166,6 +183,23 @@ options: description: "Previous generation GPT Image model" ``` +### Azure Deployment Selection + +```yaml +header: "Azure Deploy" +question: "Choose a default Azure image deployment name?" +options: + - label: "gpt-image-1.5 (Recommended)" + description: "Use when your Azure deployment name matches the GPT-image-1.5 model" + - label: "gpt-image-1" + description: "Use when your Azure deployment name matches GPT-image-1" +``` + +Notes for Azure setup: + +- In `baoyu-image-gen`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family. +- If the deployment name is custom, save that exact deployment name in `default_model.azure`. + ### OpenRouter Model Selection ```yaml @@ -230,6 +264,7 @@ After user selects a model: default_model: google: [value or null] openai: [value or null] + azure: [value or null] openrouter: [value or null] dashscope: [value or null] replicate: [value or null] diff --git a/skills/baoyu-image-gen/references/config/preferences-schema.md b/skills/baoyu-image-gen/references/config/preferences-schema.md index ad2c045..cf2d497 100644 --- a/skills/baoyu-image-gen/references/config/preferences-schema.md +++ b/skills/baoyu-image-gen/references/config/preferences-schema.md @@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences --- version: 1 -default_provider: null # google|openai|openrouter|dashscope|replicate|null (null = auto-detect) +default_provider: null # google|openai|azure|openrouter|dashscope|replicate|null (null = auto-detect) default_quality: null # normal|2k|null (null = use default: 2k) @@ -22,6 +22,7 @@ default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides qualit default_model: google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview" openai: null # e.g., "gpt-image-1.5", "gpt-image-1" + azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod" openrouter: null # e.g., "google/gemini-3.1-flash-image-preview" dashscope: null # e.g., "qwen-image-2.0-pro" replicate: null # e.g., "google/nano-banana-pro" @@ -38,6 +39,9 @@ batch: openai: concurrency: 3 start_interval_ms: 1100 + azure: + concurrency: 3 + start_interval_ms: 1100 openrouter: concurrency: 3 start_interval_ms: 1100 @@ -58,6 +62,7 @@ batch: | `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) | | `default_model.google` | string\|null | null | Google default model | | `default_model.openai` | string\|null | null | OpenAI default model | +| `default_model.azure` | string\|null | null | Azure default deployment name | | `default_model.openrouter` | string\|null | null | OpenRouter default model | | `default_model.dashscope` | string\|null | null | DashScope default model | | `default_model.replicate` | string\|null | null | Replicate default model | @@ -87,6 +92,7 @@ default_image_size: 2K default_model: google: "gemini-3-pro-image-preview" openai: "gpt-image-1.5" + azure: "gpt-image-1.5" openrouter: "google/gemini-3.1-flash-image-preview" dashscope: "qwen-image-2.0-pro" replicate: "google/nano-banana-pro" @@ -96,6 +102,9 @@ batch: replicate: concurrency: 5 start_interval_ms: 700 + azure: + concurrency: 3 + start_interval_ms: 1100 openrouter: concurrency: 3 start_interval_ms: 1100 diff --git a/skills/baoyu-image-gen/scripts/main.test.ts b/skills/baoyu-image-gen/scripts/main.test.ts index d886479..4e4740d 100644 --- a/skills/baoyu-image-gen/scripts/main.test.ts +++ b/skills/baoyu-image-gen/scripts/main.test.ts @@ -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, diff --git a/skills/baoyu-image-gen/scripts/main.ts b/skills/baoyu-image-gen/scripts/main.ts index 38591c5..9ff722a 100644 --- a/skills/baoyu-image-gen/scripts/main.ts +++ b/skills/baoyu-image-gen/scripts/main.ts @@ -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__CONCURRENCY Override provider concurrency diff --git a/skills/baoyu-image-gen/scripts/providers/azure.test.ts b/skills/baoyu-image-gen/scripts/providers/azure.test.ts new file mode 100644 index 0000000..298ac64 --- /dev/null +++ b/skills/baoyu-image-gen/scripts/providers/azure.test.ts @@ -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, +): void { + const previous = new Map(); + 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 { + 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 { + 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; + 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); +}); diff --git a/skills/baoyu-image-gen/scripts/providers/azure.ts b/skills/baoyu-image-gen/scripts/providers/azure.ts index a3a9732..ab39917 100644 --- a/skills/baoyu-image-gen/scripts/providers/azure.ts +++ b/skills/baoyu-image-gen/scripts/providers/azure.ts @@ -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 , 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 { 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 { + 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 { - const body: Record = { prompt, size, n: 1 }; + const body: Record = { + 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(),