feat: редактирование фото через xAI Grok Imagine API (/v1/images/edits) вместо Codex CLI

EDIT_ENGINE=grok по умолчанию, XAI_API_KEY/XAI_BASE_URL/XAI_IMAGE_MODEL/XAI_TIMEOUT_MS;
Codex остаётся для подсказок и как fallback (EDIT_ENGINE=codex).
This commit is contained in:
2026-11-04 19:01:50 +07:00
parent b4ea8df840
commit bea2c61216
9 changed files with 345 additions and 3 deletions

60
test/grok-edit.test.js Normal file
View File

@@ -0,0 +1,60 @@
"use strict";
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const { XaiImageClient } = require("../src/core/xai/client");
const { buildGrokEditPrompt } = require("../src/core/engines/grok-edit-engine");
const { loadConfig } = require("../src/config");
describe("xAI Grok image edit", () => {
test("sends JSON to /images/edits and decodes b64_json", async () => {
let captured;
const fetchImpl = async (url, opts) => {
captured = { url, opts, body: JSON.parse(opts.body) };
return new Response(JSON.stringify({ data: [{ b64_json: Buffer.from("JPEGDATA").toString("base64"), mime_type: "image/jpeg" }] }), { status: 200 });
};
const c = new XaiImageClient({ apiKey: "k", baseUrl: "https://api.x.ai/v1/", fetchImpl });
const r = await c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a"), mimetype: "image/png" }] });
assert.equal(captured.url, "https://api.x.ai/v1/images/edits");
assert.equal(captured.opts.headers.Authorization, "Bearer k");
assert.equal(captured.body.model, "grok-imagine-image-2.0");
assert.equal(captured.body.response_format, "b64_json");
assert.equal(captured.body.image.type, "image_url");
assert.ok(captured.body.image.url.startsWith("data:image/png;base64,"));
assert.equal(r.buffer.toString(), "JPEGDATA");
});
test("uses images[] for multiple inputs and surfaces API errors", async () => {
let body;
const fetchImpl = async (_u, o) => {
body = JSON.parse(o.body);
return new Response(JSON.stringify({ error: "bad" }), { status: 400 });
};
const c = new XaiImageClient({ apiKey: "k", fetchImpl });
const img = { buffer: Buffer.from("a"), mimetype: "image/jpeg" };
await assert.rejects(c.edit({ prompt: "p", images: [img, img] }), /Grok API 400: bad/);
assert.equal(body.images.length, 2);
assert.equal(body.image, undefined);
});
test("fails clearly without API key", async () => {
const c = new XaiImageClient({ apiKey: "" });
await assert.rejects(c.edit({ prompt: "p", images: [{ buffer: Buffer.from("a") }] }), /XAI_API_KEY/);
});
test("prompt mentions reference region", () => {
const t = buildGrokEditPrompt({ prompt: "add hat", hasReference: true, region: { x: 1, y: 2, w: 3, h: 4 } });
assert.match(t, /add hat/);
assert.match(t, /x=1, y=2, width=3, height=4/);
});
test("config defaults and key is not in sanitized output", () => {
const cfg = loadConfig({ XAI_API_KEY: "secret" });
assert.equal(cfg.EDIT_ENGINE, "grok");
assert.equal(cfg.XAI_IMAGE_MODEL, "grok-imagine-image-2.0");
const s = cfg.getSanitized();
assert.equal(s.XAI_API_KEY, undefined);
assert.equal(s.XAI_API_KEY_SET, true);
assert.throws(() => loadConfig({ EDIT_ENGINE: "foo" }));
});
});