Spaces:
Running
Running
Deploy 3203c24 to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@3203c24f5434c569bbfec2fb425430d3e45fcbc3
- public/hf-space-deploy-marker.json +3 -3
- scripts/agent-skill-scripts.test.mjs +281 -27
- scripts/command-center.test.mjs +37 -0
- skills/gpt-image-playground-agent/SKILL.md +15 -15
- skills/gpt-image-playground-agent/references/api.md +34 -19
- skills/gpt-image-playground-agent/scripts/generate-image.mjs +121 -40
- skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs +1 -0
- skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs +1 -0
- src/app/api/agent/agent-routes.test.ts +57 -1
- src/app/api/agent/image-requests/route.ts +9 -0
- src/app/api/agent/jobs/images/generate/route.ts +5 -92
- src/app/api/deploy-marker/route.ts +1 -1
- src/lib/agent-api-contracts.test.ts +22 -5
- src/lib/agent-api-contracts.ts +40 -10
- src/lib/agent-api-paths.mjs +1 -0
- src/lib/agent-file-utils.test.ts +22 -2
- src/lib/agent-file-utils.ts +43 -8
- src/lib/agent-generate-job-route.ts +101 -0
- src/lib/agent-image-service.ts +1 -1
- src/lib/agent-job-service.ts +3 -1
- src/lib/agent-openapi.ts +70 -3
- src/lib/image-service.ts +6 -5
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-06-
|
| 5 |
-
"deploy_id": "
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
+
"local_sha": "3203c24f5434c569bbfec2fb425430d3e45fcbc3",
|
| 4 |
+
"created_at": "2026-06-23T13:55:49.360Z",
|
| 5 |
+
"deploy_id": "dea5a9f7-24bf-4297-a091-f730901c220a"
|
| 6 |
}
|
scripts/agent-skill-scripts.test.mjs
CHANGED
|
@@ -18,6 +18,28 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
| 18 |
const skillRoot = join(repoRoot, 'skills/gpt-image-playground-agent');
|
| 19 |
const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts');
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
describe('Agent skill script argument validation', () => {
|
| 22 |
it('rejects invalid generate numeric options before dry-run output', () => {
|
| 23 |
const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
|
|
@@ -583,20 +605,249 @@ describe('Agent skill script argument validation', () => {
|
|
| 583 |
assert.equal(result.stderr.trim(), '');
|
| 584 |
});
|
| 585 |
|
| 586 |
-
it('prints
|
| 587 |
const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']);
|
| 588 |
|
| 589 |
assert.equal(result.status, 0);
|
| 590 |
const body = JSON.parse(result.stdout);
|
| 591 |
-
assert.equal(body.endpoint, 'http://localhost:4783/api/
|
| 592 |
-
assert.equal(body.routing_guidance.recommended_endpoint, '/api/
|
| 593 |
-
assert.equal(body.routing_guidance.transport, '
|
| 594 |
-
assert.equal(body.routing_guidance.
|
| 595 |
-
assert.
|
| 596 |
-
assert.equal(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
assert.equal(result.stderr.trim(), '');
|
| 598 |
});
|
| 599 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
it('expands generate presets into concrete dry-run request fields', () => {
|
| 601 |
const agentResult = runSkillScript('generate-image.mjs', ['--preset', '4k-agent-nonstream', 'prompt']);
|
| 602 |
assert.equal(agentResult.status, 0);
|
|
@@ -616,7 +867,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 616 |
assert.equal(upstreamSseBody.request.partial_images, 2);
|
| 617 |
});
|
| 618 |
|
| 619 |
-
it('does not automatically fall back after
|
| 620 |
const requests = [];
|
| 621 |
await withServer(
|
| 622 |
(request, response) => {
|
|
@@ -649,7 +900,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 649 |
async (baseUrl) => {
|
| 650 |
const result = await runSkillScriptAsync(
|
| 651 |
'generate-image.mjs',
|
| 652 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 653 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 654 |
);
|
| 655 |
|
|
@@ -698,7 +949,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 698 |
async (baseUrl) => {
|
| 699 |
const result = await runSkillScriptAsync(
|
| 700 |
'generate-image.mjs',
|
| 701 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'invalid-quality', 'prompt'],
|
| 702 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 703 |
);
|
| 704 |
|
|
@@ -744,7 +995,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 744 |
async (baseUrl) => {
|
| 745 |
const result = await runSkillScriptAsync(
|
| 746 |
'generate-image.mjs',
|
| 747 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 748 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 749 |
);
|
| 750 |
|
|
@@ -786,7 +1037,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 786 |
async (baseUrl) => {
|
| 787 |
const result = await runSkillScriptAsync(
|
| 788 |
'generate-image.mjs',
|
| 789 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 790 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 791 |
);
|
| 792 |
|
|
@@ -834,7 +1085,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 834 |
async (baseUrl) => {
|
| 835 |
const result = await runSkillScriptAsync(
|
| 836 |
'generate-image.mjs',
|
| 837 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 838 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 839 |
);
|
| 840 |
|
|
@@ -873,7 +1124,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 873 |
async (baseUrl) => {
|
| 874 |
const result = await runSkillScriptAsync(
|
| 875 |
'generate-image.mjs',
|
| 876 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 877 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 878 |
);
|
| 879 |
|
|
@@ -922,7 +1173,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 922 |
async (baseUrl) => {
|
| 923 |
const result = await runSkillScriptAsync(
|
| 924 |
'generate-image.mjs',
|
| 925 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 926 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 927 |
);
|
| 928 |
|
|
@@ -1131,7 +1382,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1131 |
async (baseUrl) => {
|
| 1132 |
const result = await runSkillScriptAsync(
|
| 1133 |
'generate-image.mjs',
|
| 1134 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1135 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1136 |
);
|
| 1137 |
|
|
@@ -1268,7 +1519,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1268 |
async (baseUrl) => {
|
| 1269 |
const result = await runSkillScriptAsync(
|
| 1270 |
'generate-image.mjs',
|
| 1271 |
-
['--allow-billable', '--idempotency-key', 'diag-generate-key', '--size', '1024x1024', 'prompt'],
|
| 1272 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1273 |
);
|
| 1274 |
|
|
@@ -1336,7 +1587,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1336 |
async (baseUrl) => {
|
| 1337 |
const result = await runSkillScriptAsync(
|
| 1338 |
'generate-image.mjs',
|
| 1339 |
-
['--allow-billable', '--idempotency-key', 'path-diag-key', '--size', '1024x1024', 'prompt'],
|
| 1340 |
{ GPT_IMAGE_PLAYGROUND_URL: `${baseUrl}/playground` }
|
| 1341 |
);
|
| 1342 |
|
|
@@ -1401,7 +1652,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1401 |
async (baseUrl) => {
|
| 1402 |
const result = await runSkillScriptAsync(
|
| 1403 |
'generate-image.mjs',
|
| 1404 |
-
['--allow-billable', '--idempotency-key', 'terminal-diag-key', '--size', '1024x1024', 'prompt'],
|
| 1405 |
{
|
| 1406 |
GPT_IMAGE_AGENT_MAX_ATTEMPTS: '1',
|
| 1407 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl
|
|
@@ -1443,7 +1694,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1443 |
async (baseUrl) => {
|
| 1444 |
const result = await runSkillScriptAsync(
|
| 1445 |
'generate-image.mjs',
|
| 1446 |
-
['--allow-billable', '--idempotency-key', 'diag-unavailable-key', '--size', '1024x1024', 'prompt'],
|
| 1447 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1448 |
);
|
| 1449 |
|
|
@@ -1557,7 +1808,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1557 |
async (baseUrl) => {
|
| 1558 |
const result = await runSkillScriptAsync(
|
| 1559 |
'generate-image.mjs',
|
| 1560 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1561 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1562 |
);
|
| 1563 |
|
|
@@ -1616,6 +1867,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1616 |
'generate-image.mjs',
|
| 1617 |
[
|
| 1618 |
'--allow-billable',
|
|
|
|
| 1619 |
'--size',
|
| 1620 |
'3072x2048',
|
| 1621 |
'--quality',
|
|
@@ -1785,7 +2037,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1785 |
async (baseUrl) => {
|
| 1786 |
const result = await runSkillScriptAsync(
|
| 1787 |
'generate-image.mjs',
|
| 1788 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1789 |
{
|
| 1790 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 1791 |
GPT_IMAGE_APP_PASSWORD_HASH: 'hash-for-page-sse'
|
|
@@ -1837,7 +2089,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1837 |
async (baseUrl) => {
|
| 1838 |
const result = await runSkillScriptAsync(
|
| 1839 |
'generate-image.mjs',
|
| 1840 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1841 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1842 |
);
|
| 1843 |
|
|
@@ -1892,7 +2144,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1892 |
async (baseUrl) => {
|
| 1893 |
const result = await runSkillScriptAsync(
|
| 1894 |
'generate-image.mjs',
|
| 1895 |
-
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1896 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1897 |
);
|
| 1898 |
|
|
@@ -1909,7 +2161,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1909 |
);
|
| 1910 |
});
|
| 1911 |
|
| 1912 |
-
it('
|
| 1913 |
const requests = [];
|
| 1914 |
let agentRequestBody = '';
|
| 1915 |
await withServer(
|
|
@@ -1959,6 +2211,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 1959 |
'generate-image.mjs',
|
| 1960 |
[
|
| 1961 |
'--allow-billable',
|
|
|
|
| 1962 |
'--size',
|
| 1963 |
'3072x2048',
|
| 1964 |
'--quality',
|
|
@@ -3081,8 +3334,9 @@ describe('Agent skill script argument validation', () => {
|
|
| 3081 |
]);
|
| 3082 |
assert.equal(disabledAutoPageSse.status, 0);
|
| 3083 |
const disabledAutoPageSseBody = JSON.parse(disabledAutoPageSse.stdout);
|
| 3084 |
-
assert.equal(disabledAutoPageSseBody.endpoint, 'http://localhost:4783/api/agent/
|
| 3085 |
-
assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/
|
|
|
|
| 3086 |
assert.equal(disabledAutoPageSse.stderr.trim(), '');
|
| 3087 |
|
| 3088 |
const disabledPageSse = runSkillScript('generate-image.mjs', [
|
|
|
|
| 18 |
const skillRoot = join(repoRoot, 'skills/gpt-image-playground-agent');
|
| 19 |
const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts');
|
| 20 |
|
| 21 |
+
function agentGenerateCapabilities(extra = {}) {
|
| 22 |
+
return {
|
| 23 |
+
orchestration: {
|
| 24 |
+
supported: true,
|
| 25 |
+
transport_selection: 'server_owned',
|
| 26 |
+
endpoint: '/api/agent/image-requests'
|
| 27 |
+
},
|
| 28 |
+
agent_jobs: { supported: true, mode: 'job_polling' },
|
| 29 |
+
limits: {
|
| 30 |
+
generate_images: { min: 1, max: 4 },
|
| 31 |
+
partial_images: { min: 0, max: 4 },
|
| 32 |
+
partial_images_by_backend: {
|
| 33 |
+
'images-api': { min: 0, max: 4 },
|
| 34 |
+
'responses-image-generation': { min: 1, max: 3 }
|
| 35 |
+
},
|
| 36 |
+
upload_images: { max: 8 }
|
| 37 |
+
},
|
| 38 |
+
defaults: { partial_images: 2 },
|
| 39 |
+
...extra
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
describe('Agent skill script argument validation', () => {
|
| 44 |
it('rejects invalid generate numeric options before dry-run output', () => {
|
| 45 |
const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
|
|
|
|
| 605 |
assert.equal(result.stderr.trim(), '');
|
| 606 |
});
|
| 607 |
|
| 608 |
+
it('prints server orchestration guidance for high-resolution generate dry-runs', () => {
|
| 609 |
const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']);
|
| 610 |
|
| 611 |
assert.equal(result.status, 0);
|
| 612 |
const body = JSON.parse(result.stdout);
|
| 613 |
+
assert.equal(body.endpoint, 'http://localhost:4783/api/agent/image-requests');
|
| 614 |
+
assert.equal(body.routing_guidance.recommended_endpoint, '/api/agent/image-requests');
|
| 615 |
+
assert.equal(body.routing_guidance.transport, 'server_orchestrated');
|
| 616 |
+
assert.equal(body.routing_guidance.result_mode, 'job_polling');
|
| 617 |
+
assert.match(body.routing_guidance.reason, /服务端负责选择内部执行路径/);
|
| 618 |
+
assert.equal(result.stderr.trim(), '');
|
| 619 |
+
});
|
| 620 |
+
|
| 621 |
+
it('prints server orchestration guidance for remote HTTPS small generate dry-runs', () => {
|
| 622 |
+
const result = runSkillScript('generate-image.mjs', ['--base-url', 'https://space.example.test', 'prompt']);
|
| 623 |
+
|
| 624 |
+
assert.equal(result.status, 0);
|
| 625 |
+
const body = JSON.parse(result.stdout);
|
| 626 |
+
assert.equal(body.endpoint, 'https://space.example.test/api/agent/image-requests');
|
| 627 |
+
assert.equal(body.routing_guidance.recommended_endpoint, '/api/agent/image-requests');
|
| 628 |
+
assert.equal(body.routing_guidance.transport, 'server_orchestrated');
|
| 629 |
+
assert.equal(body.routing_guidance.strength, 'recommended');
|
| 630 |
+
assert.match(body.routing_guidance.reason, /只提交生成意图/);
|
| 631 |
assert.equal(result.stderr.trim(), '');
|
| 632 |
});
|
| 633 |
|
| 634 |
+
it('uses the server orchestration endpoint for default billable generate requests', async () => {
|
| 635 |
+
const requests = [];
|
| 636 |
+
let imageRequestBody = '';
|
| 637 |
+
await withServer(
|
| 638 |
+
async (request, response) => {
|
| 639 |
+
requests.push({ method: request.method, url: request.url });
|
| 640 |
+
if (request.url === '/api/agent/capabilities') {
|
| 641 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 642 |
+
response.end(JSON.stringify(agentGenerateCapabilities()));
|
| 643 |
+
return;
|
| 644 |
+
}
|
| 645 |
+
if (request.url === '/api/agent/image-requests') {
|
| 646 |
+
imageRequestBody = await readRequestText(request);
|
| 647 |
+
response.writeHead(202, { 'content-type': 'application/json', 'retry-after': '1' });
|
| 648 |
+
response.end(
|
| 649 |
+
JSON.stringify({
|
| 650 |
+
job: {
|
| 651 |
+
id: 'job-orchestrated-1',
|
| 652 |
+
state: 'running',
|
| 653 |
+
result_url: '/api/agent/jobs/job-orchestrated-1/result',
|
| 654 |
+
retry_after_seconds: 1
|
| 655 |
+
}
|
| 656 |
+
})
|
| 657 |
+
);
|
| 658 |
+
return;
|
| 659 |
+
}
|
| 660 |
+
if (request.url === '/api/agent/jobs/job-orchestrated-1/result') {
|
| 661 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 662 |
+
response.end(
|
| 663 |
+
JSON.stringify({
|
| 664 |
+
request_id: 'job-orchestrated-1',
|
| 665 |
+
idempotency_key: 'orchestrated-key',
|
| 666 |
+
cached: false,
|
| 667 |
+
images: [
|
| 668 |
+
{
|
| 669 |
+
id: 'artifact-orchestrated-1',
|
| 670 |
+
filename: 'orchestrated.webp',
|
| 671 |
+
content_url: '/api/agent/artifacts/artifact-orchestrated-1/content',
|
| 672 |
+
width: 1254,
|
| 673 |
+
height: 1254
|
| 674 |
+
}
|
| 675 |
+
],
|
| 676 |
+
execution: {
|
| 677 |
+
transport: 'agent_job_polling',
|
| 678 |
+
endpoint: '/api/agent/image-requests',
|
| 679 |
+
route_mode: 'job',
|
| 680 |
+
image_backend: 'images-api',
|
| 681 |
+
stream_mode: 'non_stream',
|
| 682 |
+
streaming_strategy: 'off'
|
| 683 |
+
},
|
| 684 |
+
timing: { server_elapsed_ms: 1234 }
|
| 685 |
+
})
|
| 686 |
+
);
|
| 687 |
+
return;
|
| 688 |
+
}
|
| 689 |
+
if (request.url === '/api/agent/images/generate' || request.url === '/api/images') {
|
| 690 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 691 |
+
response.end(JSON.stringify({ error: 'unexpected client-selected route' }));
|
| 692 |
+
return;
|
| 693 |
+
}
|
| 694 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 695 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 696 |
+
},
|
| 697 |
+
async (baseUrl) => {
|
| 698 |
+
const result = await runSkillScriptAsync(
|
| 699 |
+
'generate-image.mjs',
|
| 700 |
+
[
|
| 701 |
+
'--allow-billable',
|
| 702 |
+
'--idempotency-key',
|
| 703 |
+
'orchestrated-key',
|
| 704 |
+
'--size',
|
| 705 |
+
'3072x2048',
|
| 706 |
+
'--streaming-strategy',
|
| 707 |
+
'off',
|
| 708 |
+
'prompt'
|
| 709 |
+
],
|
| 710 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 711 |
+
);
|
| 712 |
+
|
| 713 |
+
assert.equal(result.status, 0);
|
| 714 |
+
assert.equal(result.stderr.trim(), '');
|
| 715 |
+
const body = JSON.parse(result.stdout);
|
| 716 |
+
assert.deepEqual(body.routing, {
|
| 717 |
+
transport: 'server_orchestrated',
|
| 718 |
+
endpoint: '/api/agent/image-requests'
|
| 719 |
+
});
|
| 720 |
+
assert.equal(body.summary.transport, 'agent_job_polling');
|
| 721 |
+
assert.equal(body.summary.endpoint, '/api/agent/image-requests');
|
| 722 |
+
assert.equal(body.summary.route_mode, 'job');
|
| 723 |
+
assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-orchestrated-1/content']);
|
| 724 |
+
assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 });
|
| 725 |
+
assert.deepEqual(
|
| 726 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 727 |
+
[
|
| 728 |
+
'GET /api/agent/capabilities',
|
| 729 |
+
'POST /api/agent/image-requests',
|
| 730 |
+
'GET /api/agent/jobs/job-orchestrated-1/result'
|
| 731 |
+
]
|
| 732 |
+
);
|
| 733 |
+
const requestBody = JSON.parse(imageRequestBody);
|
| 734 |
+
assert.equal(requestBody.size, '3072x2048');
|
| 735 |
+
assert.equal(requestBody.streaming_strategy, 'off');
|
| 736 |
+
}
|
| 737 |
+
);
|
| 738 |
+
});
|
| 739 |
+
|
| 740 |
+
it('checks the server orchestration endpoint during generate contract checks', async () => {
|
| 741 |
+
const requests = [];
|
| 742 |
+
await withServer(
|
| 743 |
+
(request, response) => {
|
| 744 |
+
requests.push({ method: request.method, url: request.url });
|
| 745 |
+
if (request.url === '/api/agent/capabilities') {
|
| 746 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 747 |
+
response.end(JSON.stringify(agentGenerateCapabilities()));
|
| 748 |
+
return;
|
| 749 |
+
}
|
| 750 |
+
if (
|
| 751 |
+
request.url === '/api/agent/images/generate' ||
|
| 752 |
+
request.url === '/api/agent/image-requests' ||
|
| 753 |
+
request.url === '/api/agent/jobs/images/generate'
|
| 754 |
+
) {
|
| 755 |
+
response.writeHead(400, { 'content-type': 'application/json' });
|
| 756 |
+
response.end(
|
| 757 |
+
JSON.stringify({
|
| 758 |
+
error: {
|
| 759 |
+
code: 'idempotency_key_required',
|
| 760 |
+
message: 'missing key',
|
| 761 |
+
retryable: false
|
| 762 |
+
}
|
| 763 |
+
})
|
| 764 |
+
);
|
| 765 |
+
return;
|
| 766 |
+
}
|
| 767 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 768 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 769 |
+
},
|
| 770 |
+
async (baseUrl) => {
|
| 771 |
+
const result = await runSkillScriptAsync(
|
| 772 |
+
'generate-image.mjs',
|
| 773 |
+
['--contract-check', 'contract check'],
|
| 774 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 775 |
+
);
|
| 776 |
+
|
| 777 |
+
assert.equal(result.status, 0);
|
| 778 |
+
assert.equal(result.stderr.trim(), '');
|
| 779 |
+
const body = JSON.parse(result.stdout);
|
| 780 |
+
assert.deepEqual(
|
| 781 |
+
body.checks.map((check) => check.endpoint),
|
| 782 |
+
[
|
| 783 |
+
'/api/agent/images/generate',
|
| 784 |
+
'/api/agent/image-requests',
|
| 785 |
+
'/api/agent/jobs/images/generate'
|
| 786 |
+
]
|
| 787 |
+
);
|
| 788 |
+
assert.deepEqual(
|
| 789 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 790 |
+
[
|
| 791 |
+
'GET /api/agent/capabilities',
|
| 792 |
+
'POST /api/agent/images/generate',
|
| 793 |
+
'POST /api/agent/image-requests',
|
| 794 |
+
'POST /api/agent/jobs/images/generate'
|
| 795 |
+
]
|
| 796 |
+
);
|
| 797 |
+
}
|
| 798 |
+
);
|
| 799 |
+
});
|
| 800 |
+
|
| 801 |
+
it('fails generate contract checks when the default orchestration contract is missing', async () => {
|
| 802 |
+
const requests = [];
|
| 803 |
+
await withServer(
|
| 804 |
+
(request, response) => {
|
| 805 |
+
requests.push({ method: request.method, url: request.url });
|
| 806 |
+
if (request.url === '/api/agent/capabilities') {
|
| 807 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 808 |
+
response.end(
|
| 809 |
+
JSON.stringify(agentGenerateCapabilities({ orchestration: undefined }))
|
| 810 |
+
);
|
| 811 |
+
return;
|
| 812 |
+
}
|
| 813 |
+
if (request.url === '/api/agent/images/generate') {
|
| 814 |
+
response.writeHead(400, { 'content-type': 'application/json' });
|
| 815 |
+
response.end(
|
| 816 |
+
JSON.stringify({
|
| 817 |
+
error: {
|
| 818 |
+
code: 'idempotency_key_required',
|
| 819 |
+
message: 'missing key',
|
| 820 |
+
retryable: false
|
| 821 |
+
}
|
| 822 |
+
})
|
| 823 |
+
);
|
| 824 |
+
return;
|
| 825 |
+
}
|
| 826 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 827 |
+
response.end(JSON.stringify({ error: 'unexpected contract probe' }));
|
| 828 |
+
},
|
| 829 |
+
async (baseUrl) => {
|
| 830 |
+
const result = await runSkillScriptAsync(
|
| 831 |
+
'generate-image.mjs',
|
| 832 |
+
['--contract-check', 'contract check'],
|
| 833 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 834 |
+
);
|
| 835 |
+
|
| 836 |
+
assert.equal(result.status, 1);
|
| 837 |
+
assert.equal(result.stdout.trim(), '');
|
| 838 |
+
const body = JSON.parse(result.stderr);
|
| 839 |
+
assert.equal(body.error.code, 'orchestration_required');
|
| 840 |
+
assert.deepEqual(
|
| 841 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 842 |
+
[
|
| 843 |
+
'GET /api/agent/capabilities',
|
| 844 |
+
'POST /api/agent/images/generate'
|
| 845 |
+
]
|
| 846 |
+
);
|
| 847 |
+
}
|
| 848 |
+
);
|
| 849 |
+
});
|
| 850 |
+
|
| 851 |
it('expands generate presets into concrete dry-run request fields', () => {
|
| 852 |
const agentResult = runSkillScript('generate-image.mjs', ['--preset', '4k-agent-nonstream', 'prompt']);
|
| 853 |
assert.equal(agentResult.status, 0);
|
|
|
|
| 867 |
assert.equal(upstreamSseBody.request.partial_images, 2);
|
| 868 |
});
|
| 869 |
|
| 870 |
+
it('does not automatically fall back after an explicit billable page SSE generate failure', async () => {
|
| 871 |
const requests = [];
|
| 872 |
await withServer(
|
| 873 |
(request, response) => {
|
|
|
|
| 900 |
async (baseUrl) => {
|
| 901 |
const result = await runSkillScriptAsync(
|
| 902 |
'generate-image.mjs',
|
| 903 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 904 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 905 |
);
|
| 906 |
|
|
|
|
| 949 |
async (baseUrl) => {
|
| 950 |
const result = await runSkillScriptAsync(
|
| 951 |
'generate-image.mjs',
|
| 952 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'invalid-quality', 'prompt'],
|
| 953 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 954 |
);
|
| 955 |
|
|
|
|
| 995 |
async (baseUrl) => {
|
| 996 |
const result = await runSkillScriptAsync(
|
| 997 |
'generate-image.mjs',
|
| 998 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 999 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1000 |
);
|
| 1001 |
|
|
|
|
| 1037 |
async (baseUrl) => {
|
| 1038 |
const result = await runSkillScriptAsync(
|
| 1039 |
'generate-image.mjs',
|
| 1040 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1041 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1042 |
);
|
| 1043 |
|
|
|
|
| 1085 |
async (baseUrl) => {
|
| 1086 |
const result = await runSkillScriptAsync(
|
| 1087 |
'generate-image.mjs',
|
| 1088 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1089 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1090 |
);
|
| 1091 |
|
|
|
|
| 1124 |
async (baseUrl) => {
|
| 1125 |
const result = await runSkillScriptAsync(
|
| 1126 |
'generate-image.mjs',
|
| 1127 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1128 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1129 |
);
|
| 1130 |
|
|
|
|
| 1173 |
async (baseUrl) => {
|
| 1174 |
const result = await runSkillScriptAsync(
|
| 1175 |
'generate-image.mjs',
|
| 1176 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1177 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1178 |
);
|
| 1179 |
|
|
|
|
| 1382 |
async (baseUrl) => {
|
| 1383 |
const result = await runSkillScriptAsync(
|
| 1384 |
'generate-image.mjs',
|
| 1385 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1386 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1387 |
);
|
| 1388 |
|
|
|
|
| 1519 |
async (baseUrl) => {
|
| 1520 |
const result = await runSkillScriptAsync(
|
| 1521 |
'generate-image.mjs',
|
| 1522 |
+
['--allow-billable', '--agent', '--idempotency-key', 'diag-generate-key', '--size', '1024x1024', 'prompt'],
|
| 1523 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1524 |
);
|
| 1525 |
|
|
|
|
| 1587 |
async (baseUrl) => {
|
| 1588 |
const result = await runSkillScriptAsync(
|
| 1589 |
'generate-image.mjs',
|
| 1590 |
+
['--allow-billable', '--agent', '--idempotency-key', 'path-diag-key', '--size', '1024x1024', 'prompt'],
|
| 1591 |
{ GPT_IMAGE_PLAYGROUND_URL: `${baseUrl}/playground` }
|
| 1592 |
);
|
| 1593 |
|
|
|
|
| 1652 |
async (baseUrl) => {
|
| 1653 |
const result = await runSkillScriptAsync(
|
| 1654 |
'generate-image.mjs',
|
| 1655 |
+
['--allow-billable', '--agent', '--idempotency-key', 'terminal-diag-key', '--size', '1024x1024', 'prompt'],
|
| 1656 |
{
|
| 1657 |
GPT_IMAGE_AGENT_MAX_ATTEMPTS: '1',
|
| 1658 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl
|
|
|
|
| 1694 |
async (baseUrl) => {
|
| 1695 |
const result = await runSkillScriptAsync(
|
| 1696 |
'generate-image.mjs',
|
| 1697 |
+
['--allow-billable', '--agent', '--idempotency-key', 'diag-unavailable-key', '--size', '1024x1024', 'prompt'],
|
| 1698 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1699 |
);
|
| 1700 |
|
|
|
|
| 1808 |
async (baseUrl) => {
|
| 1809 |
const result = await runSkillScriptAsync(
|
| 1810 |
'generate-image.mjs',
|
| 1811 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 1812 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1813 |
);
|
| 1814 |
|
|
|
|
| 1867 |
'generate-image.mjs',
|
| 1868 |
[
|
| 1869 |
'--allow-billable',
|
| 1870 |
+
'--page-sse',
|
| 1871 |
'--size',
|
| 1872 |
'3072x2048',
|
| 1873 |
'--quality',
|
|
|
|
| 2037 |
async (baseUrl) => {
|
| 2038 |
const result = await runSkillScriptAsync(
|
| 2039 |
'generate-image.mjs',
|
| 2040 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 2041 |
{
|
| 2042 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 2043 |
GPT_IMAGE_APP_PASSWORD_HASH: 'hash-for-page-sse'
|
|
|
|
| 2089 |
async (baseUrl) => {
|
| 2090 |
const result = await runSkillScriptAsync(
|
| 2091 |
'generate-image.mjs',
|
| 2092 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 2093 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 2094 |
);
|
| 2095 |
|
|
|
|
| 2144 |
async (baseUrl) => {
|
| 2145 |
const result = await runSkillScriptAsync(
|
| 2146 |
'generate-image.mjs',
|
| 2147 |
+
['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 2148 |
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 2149 |
);
|
| 2150 |
|
|
|
|
| 2161 |
);
|
| 2162 |
});
|
| 2163 |
|
| 2164 |
+
it('passes streaming strategy off through explicit Agent JSON generate requests', async () => {
|
| 2165 |
const requests = [];
|
| 2166 |
let agentRequestBody = '';
|
| 2167 |
await withServer(
|
|
|
|
| 2211 |
'generate-image.mjs',
|
| 2212 |
[
|
| 2213 |
'--allow-billable',
|
| 2214 |
+
'--agent',
|
| 2215 |
'--size',
|
| 2216 |
'3072x2048',
|
| 2217 |
'--quality',
|
|
|
|
| 3334 |
]);
|
| 3335 |
assert.equal(disabledAutoPageSse.status, 0);
|
| 3336 |
const disabledAutoPageSseBody = JSON.parse(disabledAutoPageSse.stdout);
|
| 3337 |
+
assert.equal(disabledAutoPageSseBody.endpoint, 'http://localhost:4783/api/agent/image-requests');
|
| 3338 |
+
assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/image-requests');
|
| 3339 |
+
assert.equal(disabledAutoPageSseBody.routing_guidance.transport, 'server_orchestrated');
|
| 3340 |
assert.equal(disabledAutoPageSse.stderr.trim(), '');
|
| 3341 |
|
| 3342 |
const disabledPageSse = runSkillScript('generate-image.mjs', [
|
scripts/command-center.test.mjs
CHANGED
|
@@ -731,6 +731,11 @@ describe('Command center scripts', () => {
|
|
| 731 |
}
|
| 732 |
},
|
| 733 |
agent_jobs: { supported: true },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
routing_rules: {
|
| 735 |
high_resolution_edit: {
|
| 736 |
conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
|
|
@@ -781,6 +786,19 @@ describe('Command center scripts', () => {
|
|
| 781 |
);
|
| 782 |
return;
|
| 783 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
if (request.url === '/proxy/api/agent/jobs/images/generate') {
|
| 785 |
response.writeHead(400, { 'content-type': 'application/json' });
|
| 786 |
response.end(
|
|
@@ -859,6 +877,11 @@ describe('Command center scripts', () => {
|
|
| 859 |
storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
|
| 860 |
agent_streaming: { page_sse: { supported: true } },
|
| 861 |
agent_jobs: { supported: true },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
routing_rules: {
|
| 863 |
high_resolution_edit: {
|
| 864 |
conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
|
|
@@ -922,6 +945,19 @@ describe('Command center scripts', () => {
|
|
| 922 |
);
|
| 923 |
return;
|
| 924 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
if (request.url === '/api/agent/jobs/images/generate') {
|
| 926 |
response.writeHead(400, { 'content-type': 'application/json' });
|
| 927 |
response.end(
|
|
@@ -955,6 +991,7 @@ describe('Command center scripts', () => {
|
|
| 955 |
assert.equal(body.summary.real_smoke_checks.agent_edit_1k, 'skipped');
|
| 956 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 957 |
assert.ok(hits.includes('/api/agent/images/generate'));
|
|
|
|
| 958 |
assert.ok(hits.includes('/api/images'));
|
| 959 |
}
|
| 960 |
);
|
|
|
|
| 731 |
}
|
| 732 |
},
|
| 733 |
agent_jobs: { supported: true },
|
| 734 |
+
orchestration: {
|
| 735 |
+
supported: true,
|
| 736 |
+
endpoint: '/api/agent/image-requests',
|
| 737 |
+
transport_selection: 'server_owned'
|
| 738 |
+
},
|
| 739 |
routing_rules: {
|
| 740 |
high_resolution_edit: {
|
| 741 |
conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
|
|
|
|
| 786 |
);
|
| 787 |
return;
|
| 788 |
}
|
| 789 |
+
if (request.url === '/proxy/api/agent/image-requests') {
|
| 790 |
+
response.writeHead(400, { 'content-type': 'application/json' });
|
| 791 |
+
response.end(
|
| 792 |
+
JSON.stringify({
|
| 793 |
+
error: {
|
| 794 |
+
code: 'idempotency_key_required',
|
| 795 |
+
message: 'missing key',
|
| 796 |
+
retryable: false
|
| 797 |
+
}
|
| 798 |
+
})
|
| 799 |
+
);
|
| 800 |
+
return;
|
| 801 |
+
}
|
| 802 |
if (request.url === '/proxy/api/agent/jobs/images/generate') {
|
| 803 |
response.writeHead(400, { 'content-type': 'application/json' });
|
| 804 |
response.end(
|
|
|
|
| 877 |
storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
|
| 878 |
agent_streaming: { page_sse: { supported: true } },
|
| 879 |
agent_jobs: { supported: true },
|
| 880 |
+
orchestration: {
|
| 881 |
+
supported: true,
|
| 882 |
+
endpoint: '/api/agent/image-requests',
|
| 883 |
+
transport_selection: 'server_owned'
|
| 884 |
+
},
|
| 885 |
routing_rules: {
|
| 886 |
high_resolution_edit: {
|
| 887 |
conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
|
|
|
|
| 945 |
);
|
| 946 |
return;
|
| 947 |
}
|
| 948 |
+
if (request.url === '/api/agent/image-requests') {
|
| 949 |
+
response.writeHead(400, { 'content-type': 'application/json' });
|
| 950 |
+
response.end(
|
| 951 |
+
JSON.stringify({
|
| 952 |
+
error: {
|
| 953 |
+
code: 'idempotency_key_required',
|
| 954 |
+
message: 'missing key',
|
| 955 |
+
retryable: false
|
| 956 |
+
}
|
| 957 |
+
})
|
| 958 |
+
);
|
| 959 |
+
return;
|
| 960 |
+
}
|
| 961 |
if (request.url === '/api/agent/jobs/images/generate') {
|
| 962 |
response.writeHead(400, { 'content-type': 'application/json' });
|
| 963 |
response.end(
|
|
|
|
| 991 |
assert.equal(body.summary.real_smoke_checks.agent_edit_1k, 'skipped');
|
| 992 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 993 |
assert.ok(hits.includes('/api/agent/images/generate'));
|
| 994 |
+
assert.ok(hits.includes('/api/agent/image-requests'));
|
| 995 |
assert.ok(hits.includes('/api/images'));
|
| 996 |
}
|
| 997 |
);
|
skills/gpt-image-playground-agent/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 当用户需要通过已部署的 GPT Image Playground 生成、编
|
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
| 7 |
|
| 8 |
-
通过用户已部署的 GPT Image Playground 生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本 Skill 内置脚本,让脚本处理 Agent API 契约、capabilities、幂等键、
|
| 9 |
|
| 10 |
Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平台。
|
| 11 |
|
|
@@ -19,7 +19,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
|
|
| 19 |
- 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。
|
| 20 |
- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
|
| 21 |
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
|
| 22 |
-
- 先用 dry-run 或 `--contract-check` 检查请求、路由
|
| 23 |
- 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游 host、脱敏请求头、重试和下一步动作;Agent JSON 失败时脚本会按幂等键做一次只读 Agent state 诊断补采样,补充 `agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status`、`request_id`、渠道和上游 host。不要再先手查 SQLite、Docker logs 或上游后台。
|
| 24 |
- 新增 probe、diagnostics、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行态 API 和 Agent API 的边界判断。
|
| 25 |
|
|
@@ -31,14 +31,14 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
|
|
| 31 |
|
| 32 |
## 路由规则
|
| 33 |
|
| 34 |
-
- 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,
|
| 35 |
- 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
|
| 36 |
-
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE
|
| 37 |
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
|
| 38 |
- 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
|
| 39 |
- 复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩 prompt 或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。
|
| 40 |
- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
|
| 41 |
-
- 普通
|
| 42 |
- 单张文生图使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时走页面端 `/api/images` SSE,因为这些是页面高级字段,不属于 Agent JSON schema。`--responses-model` 覆盖本次请求的 Responses 顶层模型;未传时使用服务端 `OPENAI_RESPONSES_API_MODEL`。该字段只影响本项目 `responses-image-generation` 路径,不改变兼容上游自身 Images API 桥接层内部选择的模型。`--responses-model` 必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`;显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 会被脚本前置拒绝。
|
| 43 |
- 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
|
| 44 |
|
|
@@ -50,13 +50,13 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
|
|
| 50 |
4. 非交互式任务无法向用户确认时,按“用户提供 URL > `GPT_IMAGE_PLAYGROUND_URL` > 默认本地探测地址”的顺序执行,并在输出里标明服务地址来源和是否只是自动发现。
|
| 51 |
5. 位于仓库根目录且用户是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。该命令只读、非计费、不写 env 文件,默认输出中文摘要;`-- --json` 输出机器可读 JSON。它会报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程是否拿到 Agent 鉴权、页面 SSE 鉴权是否可用,以及 `.env.agent.local` 是否存在私有鉴权配置。Agent CLI 默认从当前仓库根目录自动读取 `.env.agent.local`,shell 环境变量优先;如需禁用自动读取,设置 `GPT_IMAGE_AGENT_LOAD_ENV_FILE=0`。
|
| 52 |
6. 让脚本请求 `GET /api/agent/capabilities`。如果所选地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,交互式任务中向用户询问实际部署地址、端口、域名和是否需要鉴权;非交互式任务中显式失败并输出下一步动作。
|
| 53 |
-
7. 读取 capabilities 中的认证方式、模型、模型级限制、`image_transport`、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。
|
| 54 |
8. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。
|
| 55 |
-
9. 文生图使用 `POST /api/agent/
|
| 56 |
10. 图片编辑若走 Agent edit,使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段必须使用从 `image_0` 开始的连续字段,最大数量以 capabilities 的 `limits.upload_images.max` 为准;跳号、超过当前 profile 上限、`image_01` 或 `image_foo` 会被显式拒绝。该 Agent 端点同样是非流式端点;上游 SSE 字段按 `agent_streaming.upstream_sse.request_fields_by_mode.edit` 发送,不要给 Agent edit 传 `image_backend`。需要 `image_backend=responses-image-generation` 或页面表单字段 `image_streaming_strategy=responses-sse` 的 edit,一律走页面端 `/api/images` form-data SSE;脚本参数仍写作 `--streaming-strategy responses-sse`。
|
| 57 |
11. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 58 |
-
12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在
|
| 59 |
-
13. 读取 `agent_jobs`
|
| 60 |
14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。
|
| 61 |
15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
|
| 62 |
16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。
|
|
@@ -86,14 +86,14 @@ Authorization: Bearer <token>
|
|
| 86 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 87 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 88 |
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。
|
| 89 |
-
- 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。
|
| 90 |
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
| 91 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
| 92 |
- 不要把 `/api/runtime-capabilities`、`/api/feedback`、`/api/shares`、`/api/logs` 或 `/api/image-delete` 当成 Agent API。它们是页面运行态或页面工作流端点,鉴权和字段契约与 `/api/agent/*` 不同;反馈和诊断的 Agent 只读入口是 `/api/agent/page-requests/{id}/feedback`、`/api/agent/page-requests/feedback`、`/api/agent/diagnostics/page-requests/{id}` 和 `/api/agent/diagnostics/page-requests`。
|
| 93 |
|
| 94 |
## Job Polling
|
| 95 |
|
| 96 |
-
当 `agent_jobs.supported=true`
|
| 97 |
|
| 98 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 99 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
@@ -101,20 +101,20 @@ Authorization: Bearer <token>
|
|
| 101 |
|
| 102 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 103 |
|
| 104 |
-
当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新
|
| 105 |
|
| 106 |
## 可用脚本
|
| 107 |
|
| 108 |
以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 109 |
|
| 110 |
-
- `scripts/generate-image.mjs`:
|
| 111 |
- `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
|
| 112 |
- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。
|
| 113 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
|
| 114 |
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
|
| 115 |
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 116 |
|
| 117 |
-
生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成
|
| 118 |
所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
|
| 119 |
|
| 120 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
|
@@ -180,7 +180,7 @@ node "<skill-root>/scripts/convert-image-format.mjs" --format webp --quality 100
|
|
| 180 |
node "<skill-root>/scripts/convert-image-format.mjs" --format png --output ./source.png ./source.webp --overwrite
|
| 181 |
```
|
| 182 |
|
| 183 |
-
生成脚本
|
| 184 |
|
| 185 |
generate 或页面 SSE 请求包含 `image_backend` 时,`partial_images` 必须先按 `limits.partial_images_by_backend[image_backend]` 校验;capabilities 没有该字段时才退回 `limits.partial_images`。Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API/profile 范围校验;Responses backend edit 需要页面 SSE。不要把 Matsca `limits.partial_images=0..4` 误套到 `responses-image-generation`,Responses backend 当前使用自己的 `1..3` 范围。
|
| 186 |
|
|
|
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
| 7 |
|
| 8 |
+
通过用户已部署的 GPT Image Playground 生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本 Skill 内置脚本,让脚本处理 Agent API 契约、capabilities、幂等键、服务端编排入口和产物 URL。
|
| 9 |
|
| 10 |
Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平台。
|
| 11 |
|
|
|
|
| 19 |
- 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。
|
| 20 |
- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
|
| 21 |
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
|
| 22 |
+
- 先用 dry-run 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
|
| 23 |
- 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游 host、脱敏请求头、重试和下一步动作;Agent JSON 失败时脚本会按幂等键做一次只读 Agent state 诊断补采样,补充 `agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status`、`request_id`、渠道和上游 host。不要再先手查 SQLite、Docker logs 或上游后台。
|
| 24 |
- 新增 probe、diagnostics、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行态 API 和 Agent API 的边界判断。
|
| 25 |
|
|
|
|
| 31 |
|
| 32 |
## 路由规则
|
| 33 |
|
| 34 |
+
- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和 job polling。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或 job endpoint。
|
| 35 |
- 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
|
| 36 |
+
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
|
| 37 |
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
|
| 38 |
- 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
|
| 39 |
- 复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩 prompt 或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。
|
| 40 |
- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
|
| 41 |
+
- 普通单次文生图默认使用 `POST /api/agent/image-requests`。`--agent`、`--job`、`--page-sse` 是显式诊断或兼容开关:`--agent` 直连 `/api/agent/images/generate`,`--job` 直连 `/api/agent/jobs/images/generate`,`--page-sse` 直连页面端 `/api/images` SSE。不要把这些显式开关当成默认自动路由。
|
| 42 |
- 单张文生图使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时走页面端 `/api/images` SSE,因为这些是页面高级字段,不属于 Agent JSON schema。`--responses-model` 覆盖本次请求的 Responses 顶层模型;未传时使用服务端 `OPENAI_RESPONSES_API_MODEL`。该字段只影响本项目 `responses-image-generation` 路径,不改变兼容上游自身 Images API 桥接层内部选择的模型。`--responses-model` 必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`;显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 会被脚本前置拒绝。
|
| 43 |
- 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
|
| 44 |
|
|
|
|
| 50 |
4. 非交互式任务无法向用户确认时,按“用户提供 URL > `GPT_IMAGE_PLAYGROUND_URL` > 默认本地探测地址”的顺序执行,并在输出里标明服务地址来源和是否只是自动发现。
|
| 51 |
5. 位于仓库根目录且用户是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。该命令只读、非计费、不写 env 文件,默认输出中文摘要;`-- --json` 输出机器可读 JSON。它会报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程是否拿到 Agent 鉴权、页面 SSE 鉴权是否可用,以及 `.env.agent.local` 是否存在私有鉴权配置。Agent CLI 默认从当前仓库根目录自动读取 `.env.agent.local`,shell 环境变量优先;如需禁用自动读取,设置 `GPT_IMAGE_AGENT_LOAD_ENV_FILE=0`。
|
| 52 |
6. 让脚本请求 `GET /api/agent/capabilities`。如果所选地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,交互式任务中向用户询问实际部署地址、端口、域名和是否需要鉴权;非交互式任务中显式失败并输出下一步动作。
|
| 53 |
+
7. 读取 capabilities 中的认证方式、模型、模型级限制、`image_transport`、`orchestration`、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。
|
| 54 |
8. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。
|
| 55 |
+
9. 文生图默认使用 `POST /api/agent/image-requests`,请求体为 JSON 业务意图,服务端返回 job 状态并由脚本轮询 `job.result_url`。服务端内部仍会返回标准 `AgentImageResponse`,并在 `execution` 中暴露真实执行路径。显式 `--agent` 才使用 `POST /api/agent/images/generate`;不要把 `responsesModel`、`thinking`、`promptOptimization` 或 `force_web` 发送到 Agent JSON,这些字段需要页面端 `/api/images` form-data 路径。
|
| 56 |
10. 图片编辑若走 Agent edit,使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段必须使用从 `image_0` 开始的连续字段,最大数量以 capabilities 的 `limits.upload_images.max` 为准;跳号、超过当前 profile 上限、`image_01` 或 `image_foo` 会被显式拒绝。该 Agent 端点同样是非流式端点;上游 SSE 字段按 `agent_streaming.upstream_sse.request_fields_by_mode.edit` 发送,不要给 Agent edit 传 `image_backend`。需要 `image_backend=responses-image-generation` 或页面表单字段 `image_streaming_strategy=responses-sse` 的 edit,一律走页面端 `/api/images` form-data SSE;脚本参数仍写作 `--streaming-strategy responses-sse`。
|
| 57 |
11. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 58 |
+
12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在默认 WebP edit、复杂 UI 批量、长图恢复、页面高级字段、显式 `--page-sse` 或明确诊断后使用。
|
| 59 |
+
13. 读取 `agent_jobs` 只用于理解服务端编排结果和显式 `--job` 诊断路径。普通 generate 不再由 Agent 客户端根据本地/远端或尺寸选择 Agent JSON、page SSE 或 job。
|
| 60 |
14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。
|
| 61 |
15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
|
| 62 |
16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。
|
|
|
|
| 86 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 87 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 88 |
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。
|
| 89 |
+
- 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。
|
| 90 |
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
| 91 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
| 92 |
- 不要把 `/api/runtime-capabilities`、`/api/feedback`、`/api/shares`、`/api/logs` 或 `/api/image-delete` 当成 Agent API。它们是页面运行态或页面工作流端点,鉴权和字段契约与 `/api/agent/*` 不同;反馈和诊断的 Agent 只读入口是 `/api/agent/page-requests/{id}/feedback`、`/api/agent/page-requests/feedback`、`/api/agent/diagnostics/page-requests/{id}` 和 `/api/agent/diagnostics/page-requests`。
|
| 93 |
|
| 94 |
## Job Polling
|
| 95 |
|
| 96 |
+
默认 generate 不直接调用 job endpoint;服务端编排入口会在内部使用 job polling 并返回 `job.result_url`。当 `agent_jobs.supported=true` 且需要显式诊断或兼容旧流程时,job 路径可使用:
|
| 97 |
|
| 98 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 99 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
|
|
| 101 |
|
| 102 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 103 |
|
| 104 |
+
当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新提交同一业务意图与同一 `Idempotency-Key`,避免重复业务操作。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。
|
| 105 |
|
| 106 |
## 可用脚本
|
| 107 |
|
| 108 |
以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 109 |
|
| 110 |
+
- `scripts/generate-image.mjs`:文生图调用。默认 dry-run,不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。
|
| 111 |
- `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
|
| 112 |
- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。
|
| 113 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
|
| 114 |
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
|
| 115 |
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 116 |
|
| 117 |
+
生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`,便于人工审查。
|
| 118 |
所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
|
| 119 |
|
| 120 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
|
|
|
| 180 |
node "<skill-root>/scripts/convert-image-format.mjs" --format png --output ./source.png ./source.webp --overwrite
|
| 181 |
```
|
| 182 |
|
| 183 |
+
生成脚本默认把文生图业务意图提交到服务端编排入口 `/api/agent/image-requests`;服务端内部决定使用 Agent JSON、内部上游 SSE、job polling 或其他可观测路径。脚本不再根据 `max_edge>2048`、公网 HTTPS 或 `streaming_strategy=off` 自行切换默认端点。单张 generate 仍支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 和 `--force-web`;这些字段属于页面 SSE form-data,dry-run 会显示 page SSE 路由,真实请求会发送 `responsesModel`、`thinking`、`promptOptimization` 和 `force_web`。`--agent`、`--job`、`--page-sse` 是显式诊断或兼容开关,使用时必须记录原因,并用新的 `Idempotency-Key` 避免混淆业务操作。默认 WebP edit 走页面 SSE;显式 `--agent` 才走 Agent multipart 最终 JSON,输出格式固定为 Agent 契约。Responses image_generation edit 属于页面 SSE 路径:可显式传 `--page-sse --image-backend responses-image-generation --streaming-strategy responses-sse`;如果运行时已显式配置 `IMAGE_GENERATION_BACKEND=responses-image-generation` 或兼容别名 `responses`,且 `IMAGE_STREAMING_STRATEGY=responses-sse`,也可以依赖服务端默认值。Docker compose 本身不设置这两个默认值,未配置 `.env.local` 时仍是 `images-api` 和 `auto`。不要为 Responses edit 加 `--agent`。默认 WebP edit 与非流式策略冲突时脚本前置拒绝,除非显式添加 `--agent` 做 Agent JSON 对照。页面高级 edit 字段与非流式策略冲突时脚本同样前置拒绝。上游流式字段优先读取 `agent_streaming.upstream_sse.request_fields_by_mode`:generate 支持 `--image-backend`、`--stream-mode`、`--streaming-strategy`、`--partial-images`;Agent edit 只支持 `--stream-mode`、`--streaming-strategy`、`--partial-images`。页面 SSE edit 可发送 `image_backend` 和表单字段 `image_streaming_strategy`;CLI 参数是 `--streaming-strategy`,batch JSONL 字段是 `streaming_strategy`。
|
| 184 |
|
| 185 |
generate 或页面 SSE 请求包含 `image_backend` 时,`partial_images` 必须先按 `limits.partial_images_by_backend[image_backend]` 校验;capabilities 没有该字段时才退回 `limits.partial_images`。Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API/profile 范围校验;Responses backend edit 需要页面 SSE。不要把 Matsca `limits.partial_images=0..4` 误套到 `responses-image-generation`,Responses backend 当前使用自己的 `1..3` 范围。
|
| 186 |
|
skills/gpt-image-playground-agent/references/api.md
CHANGED
|
@@ -29,9 +29,9 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
|
|
| 29 |
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
|
| 30 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 31 |
|
| 32 |
-
生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。必须显式添加 `--allow-billable` 才会
|
| 33 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 34 |
-
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
|
| 35 |
位于仓库根目录且是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。它只读、非计费、不写 env 文件,默认输出中文摘要,并报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程鉴权、页面 SSE 鉴权和下一步动作。
|
| 36 |
自动化消费时使用 `npm run first-run -- --json`。
|
| 37 |
Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,Agent 端点才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。页面端 `/api/images` SSE 另看 `agent_streaming.page_sse.auth`;当其声明 `required=true` 时,form-data 必须包含 `passwordHash`。`GPT_IMAGE_AGENT_TOKEN` 不能替代页面 SSE 表单鉴权。
|
|
@@ -69,16 +69,16 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
|
|
| 69 |
- `--timeout-ms`:未显式指定时,脚本先用 `420000ms` 读取 capabilities;真实请求会采用 `420000ms` 与 `capabilities.image_transport.upstream_timeout_ms` 中较大的值。
|
| 70 |
- `--prompt-file`:从文本文件读取 prompt。
|
| 71 |
- `--idempotency-key`:指定稳定幂等键。
|
| 72 |
-
- `--page-sse`:强制使用页面端 `/api/images` form-data SSE。
|
| 73 |
-
- `--agent`:强制使用 `/api/agent/images/generate` 非流式 JSON。
|
| 74 |
-
- `--job`:强制使用 Agent job polling。
|
| 75 |
- `--dry-run`:只输出将要发送的 JSON。
|
| 76 |
- `--allow-billable`:允许真实调用生图端点。
|
| 77 |
- `--preset`:常用 dry-run/真实调用参数集,当前包括 `1k-smoke-agent`、`4k-agent-nonstream`、`4k-page-sse` 和 `4k-upstream-sse-newapi`。dry-run 会展开真实请求字段,不触发计费。
|
| 78 |
|
| 79 |
-
|
| 80 |
单张 generate 脚本使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时会选择页面端 `/api/images` SSE。显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 与这些页面高级字段同时出现时会在网络请求前失败,避免字段被 Agent JSON 忽略。
|
| 81 |
-
当服务端默认 `IMAGE_STREAMING_STRATEGY=off` 且请求未覆盖 `streaming_strategy` 时,运行时默认策略为 `off`;WebUI 会把 server-default 流式请求切到 `non_stream`,并发批量开关不可用。脚本显式传 `--streaming-strategy off` 时
|
| 82 |
|
| 83 |
编辑脚本参数:
|
| 84 |
|
|
@@ -200,7 +200,7 @@ GET /api/agent/capabilities
|
|
| 200 |
- `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 201 |
- `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。
|
| 202 |
- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 203 |
-
- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。即使该字段为 `supported=true`,页面 SSE 仍可能在当前渠道返回 `503`、断流或没有选中渠道;这时应先诊断,再显式
|
| 204 |
- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
|
| 205 |
- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
|
| 206 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
|
@@ -209,8 +209,12 @@ GET /api/agent/capabilities
|
|
| 209 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 210 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
| 211 |
- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
|
| 212 |
-
- `
|
| 213 |
-
- `
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
- `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
|
| 215 |
- 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
|
| 216 |
- `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
|
|
@@ -235,7 +239,7 @@ GET /api/agent/capabilities
|
|
| 235 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 236 |
- `agent_request_diagnostics`:Agent state 请求诊断能力。`endpoints.lookup` 支持 `request_id` 或 `idempotency_key` 查询参数;`endpoints.single` 支持按 `request_id` 路径查询;`retention.ttl_seconds` 与 Agent request TTL 一致。
|
| 237 |
|
| 238 |
-
|
| 239 |
|
| 240 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 241 |
|
|
@@ -248,7 +252,7 @@ Idempotency-Key: <stable-key>
|
|
| 248 |
Content-Type: application/json
|
| 249 |
```
|
| 250 |
|
| 251 |
-
请求体与 `POST /api/agent/images/generate` 相同。创建成功后返回:
|
| 252 |
|
| 253 |
```json
|
| 254 |
{
|
|
@@ -287,6 +291,17 @@ GET /api/agent/jobs/{id}/result
|
|
| 287 |
|
| 288 |
## 生成图片
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
```http
|
| 291 |
POST /api/agent/images/generate
|
| 292 |
Authorization: Bearer <token>
|
|
@@ -315,12 +330,12 @@ Content-Type: application/json
|
|
| 315 |
}
|
| 316 |
```
|
| 317 |
|
| 318 |
-
Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`。
|
| 319 |
|
| 320 |
- 页面 SSE 使用独立的 `POST /api/images` form-data 路径。
|
| 321 |
- 若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,generate 可通过 `request_fields_by_mode.generate` 声明的字段控制服务端内部上游 SSE 消费。Agent JSON 的 `image_backend=responses-image-generation` 当前只支持 generate;Responses backend edit 使用页面端 `/api/images` form-data SSE。
|
| 322 |
- 不要向 Agent 生成端点发送 `responsesModel`、`gptModel`、`gpt_model`、`thinking`、`promptOptimization`、`prompt_optimization`、`force_web` 或 `forceWeb`。这些是页面 form-data 高级字段;单张 generate 脚本会在需要时走 `/api/images` SSE。
|
| 323 |
-
- Agent 生成端点最终响应
|
| 324 |
- `stream_mode=stream` 强制流式并直接暴露失败。
|
| 325 |
- `stream_mode=non_stream` 直接非流式。
|
| 326 |
- `stream_mode=auto` 允许显式可观测回退。
|
|
@@ -354,9 +369,9 @@ Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE
|
|
| 354 |
"server_elapsed_ms": 64000
|
| 355 |
},
|
| 356 |
"execution": {
|
| 357 |
-
"transport": "
|
| 358 |
-
"endpoint": "/api/agent/
|
| 359 |
-
"route_mode": "
|
| 360 |
"operation": "generate",
|
| 361 |
"image_backend": "images-api",
|
| 362 |
"stream_mode": "non_stream",
|
|
@@ -626,8 +641,8 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 626 |
|
| 627 |
| 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
|
| 628 |
| --- | --- | --- | --- |
|
| 629 |
-
| `POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts | Agent
|
| 630 |
-
| `POST /api/images` | 页面 form-data SSE API | 否 | 仅在
|
| 631 |
| `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
|
| 632 |
| `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
|
| 633 |
| `POST /api/agent/page-requests/feedback` | Agent 结果反馈批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询最新反馈。 |
|
|
|
|
| 29 |
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
|
| 30 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 31 |
|
| 32 |
+
生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。必须显式添加 `--allow-billable` 才会调用真实端点。generate 默认提交到 `/api/agent/image-requests` 服务端编排入口;`--agent`、`--job`、`--page-sse` 才会显式改用 `/api/agent/images/generate`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
|
| 33 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 34 |
+
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,会覆盖服务声明的默认编排入口,不触发真实生图或编辑。
|
| 35 |
位于仓库根目录且是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。它只读、非计费、不写 env 文件,默认输出中文摘要,并报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程鉴权、页面 SSE 鉴权和下一步动作。
|
| 36 |
自动化消费时使用 `npm run first-run -- --json`。
|
| 37 |
Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,Agent 端点才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。页面端 `/api/images` SSE 另看 `agent_streaming.page_sse.auth`;当其声明 `required=true` 时,form-data 必须包含 `passwordHash`。`GPT_IMAGE_AGENT_TOKEN` 不能替代页面 SSE 表单鉴权。
|
|
|
|
| 69 |
- `--timeout-ms`:未显式指定时,脚本先用 `420000ms` 读取 capabilities;真实请求会采用 `420000ms` 与 `capabilities.image_transport.upstream_timeout_ms` 中较大的值。
|
| 70 |
- `--prompt-file`:从文本文件读取 prompt。
|
| 71 |
- `--idempotency-key`:指定稳定幂等键。
|
| 72 |
+
- `--page-sse`:诊断或兼容开关,强制使用页面端 `/api/images` form-data SSE。
|
| 73 |
+
- `--agent`:诊断或兼容开关,强制使用 `/api/agent/images/generate` 非流式 JSON。
|
| 74 |
+
- `--job`:诊断或兼容开关,强制使用 Agent job polling。
|
| 75 |
- `--dry-run`:只输出将要发送的 JSON。
|
| 76 |
- `--allow-billable`:允许真实调用生图端点。
|
| 77 |
- `--preset`:常用 dry-run/真实调用参数集,当前包括 `1k-smoke-agent`、`4k-agent-nonstream`、`4k-page-sse` 和 `4k-upstream-sse-newapi`。dry-run 会展开真实请求字段,不触发计费。
|
| 78 |
|
| 79 |
+
普通单次文生图默认提交到 `/api/agent/image-requests` 服务端编排入口;脚本不再按 `max_edge>2048`、公网 HTTPS 或 `--streaming-strategy off` 自行选择 page SSE、Agent JSON 或 job endpoint。需要对照时显式使用 `--page-sse`、`--agent` 或 `--job`。
|
| 80 |
单张 generate 脚本使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时会选择页面端 `/api/images` SSE。显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 与这些页面高级字段同时出现时会在网络请求前失败,避免字段被 Agent JSON 忽略。
|
| 81 |
+
当服务端默认 `IMAGE_STREAMING_STRATEGY=off` 且请求未覆盖 `streaming_strategy` 时,运行时默认策略为 `off`;WebUI 会把 server-default 流式请求切到 `non_stream`,并发批量开关不可用。generate 脚本显式传 `--streaming-strategy off` 时仍提交给服务端编排入口,除非同时显式使用 `--agent`。
|
| 82 |
|
| 83 |
编辑脚本参数:
|
| 84 |
|
|
|
|
| 200 |
- `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 201 |
- `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。
|
| 202 |
- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 203 |
+
- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。即使该字段为 `supported=true`,页面 SSE 仍可能在当前渠道返回 `503`、断流或没有选中渠道;这时应先诊断,再显式选择诊断路径,不自动回退。
|
| 204 |
- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
|
| 205 |
- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
|
| 206 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
|
|
|
| 209 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 210 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
| 211 |
- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
|
| 212 |
+
- `orchestration.supported`:当前为 `true`,表示普通 generate 默认由服务端编排。
|
| 213 |
+
- `orchestration.endpoint`:当前为 `POST /api/agent/image-requests`,客户端只提交业务意图,不选择内部传输路径。
|
| 214 |
+
- `orchestration.transport_selection`:当前为 `server_owned`,表示 Agent 客户端不应按尺寸、远端 HTTPS 或流式参数自行选择 page SSE、Agent JSON 或 job endpoint。
|
| 215 |
+
- `orchestration.result_mode`:当前为 `job_polling`,脚本会轮询 `job.result_url` 并输出标准 `AgentImageResponse`。
|
| 216 |
+
- `routing_rules.agent_generate_small_smoke`:`strength=explicit`,兼容旧客户端和显式 `--agent` 诊断路径;不是普通 generate 默认入口。
|
| 217 |
+
- `routing_rules.page_sse_large_generate`:`strength=explicit`,显式 page SSE 诊断和页面工作台路径的参考规则;普通 generate 默认仍走 `orchestration.endpoint`。
|
| 218 |
- `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
|
| 219 |
- 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
|
| 220 |
- `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
|
|
|
|
| 239 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 240 |
- `agent_request_diagnostics`:Agent state 请求诊断能力。`endpoints.lookup` 支持 `request_id` 或 `idempotency_key` 查询参数;`endpoints.single` 支持按 `request_id` 路径查询;`retention.ttl_seconds` 与 Agent request TTL 一致。
|
| 241 |
|
| 242 |
+
普通 generate 默认使用 `orchestration.endpoint`,不是客户端直接选择 job endpoint。`agent_jobs.supported=true` 且 `mode=job_polling` 表示服务端编排和显式 `--job` 诊断路径可使用同一套 job 状态机。高分辨率 edit 和复杂 UI 批量生产仍按页面/批量规则使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent edit 或 job 路径。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。
|
| 243 |
|
| 244 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 245 |
|
|
|
|
| 252 |
Content-Type: application/json
|
| 253 |
```
|
| 254 |
|
| 255 |
+
请求体与 `POST /api/agent/image-requests` / `POST /api/agent/images/generate` 相同。创建成功后返回:
|
| 256 |
|
| 257 |
```json
|
| 258 |
{
|
|
|
|
| 291 |
|
| 292 |
## 生成图片
|
| 293 |
|
| 294 |
+
默认服务端编排入口:
|
| 295 |
+
|
| 296 |
+
```http
|
| 297 |
+
POST /api/agent/image-requests
|
| 298 |
+
Authorization: Bearer <token>
|
| 299 |
+
Idempotency-Key: <stable-key>
|
| 300 |
+
Content-Type: application/json
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
该入口返回 `AgentJobStatusResponse`,脚本会继续轮询 `job.result_url` 并输出标准 `AgentImageResponse`。显式诊断或兼容旧流程时才直连 Agent JSON:
|
| 304 |
+
|
| 305 |
```http
|
| 306 |
POST /api/agent/images/generate
|
| 307 |
Authorization: Bearer <token>
|
|
|
|
| 330 |
}
|
| 331 |
```
|
| 332 |
|
| 333 |
+
Agent JSON 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。普通客户端默认不直接调用它,而是使用 `/api/agent/image-requests`。不要向该端点发送 `stream: true`。
|
| 334 |
|
| 335 |
- 页面 SSE 使用独立的 `POST /api/images` form-data 路径。
|
| 336 |
- 若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,generate 可通过 `request_fields_by_mode.generate` 声明的字段控制服务端内部上游 SSE 消费。Agent JSON 的 `image_backend=responses-image-generation` 当前只支持 generate;Responses backend edit 使用页面端 `/api/images` form-data SSE。
|
| 337 |
- 不要向 Agent 生成端点发送 `responsesModel`、`gptModel`、`gpt_model`、`thinking`、`promptOptimization`、`prompt_optimization`、`force_web` 或 `forceWeb`。这些是页面 form-data 高级字段;单张 generate 脚本会在需要时走 `/api/images` SSE。
|
| 338 |
+
- `/api/agent/image-requests` 的最终轮询结果和 Agent JSON 生成端点最终响应都使用 `AgentImageResponse`。
|
| 339 |
- `stream_mode=stream` 强制流式并直接暴露失败。
|
| 340 |
- `stream_mode=non_stream` 直接非流式。
|
| 341 |
- `stream_mode=auto` 允许显式可观测回退。
|
|
|
|
| 369 |
"server_elapsed_ms": 64000
|
| 370 |
},
|
| 371 |
"execution": {
|
| 372 |
+
"transport": "agent_job_polling",
|
| 373 |
+
"endpoint": "/api/agent/image-requests",
|
| 374 |
+
"route_mode": "job",
|
| 375 |
"operation": "generate",
|
| 376 |
"image_backend": "images-api",
|
| 377 |
"stream_mode": "non_stream",
|
|
|
|
| 641 |
|
| 642 |
| 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
|
| 643 |
| --- | --- | --- | --- |
|
| 644 |
+
| `POST /api/agent/image-requests`、`POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts | Agent API | 是 | 普通 generate 默认用 image-requests;其他 Agent 端点通过 skill 脚本和 Agent 鉴权调用。 |
|
| 645 |
+
| `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 |
|
| 646 |
| `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
|
| 647 |
| `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
|
| 648 |
| `POST /api/agent/page-requests/feedback` | Agent 结果反馈批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询最新反馈。 |
|
skills/gpt-image-playground-agent/scripts/generate-image.mjs
CHANGED
|
@@ -45,6 +45,7 @@ const DEFAULT_OUTPUT_FORMAT = 'webp';
|
|
| 45 |
const DEFAULT_OUTPUT_COMPRESSION = 100;
|
| 46 |
const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
|
| 47 |
const PAGE_SSE_ENDPOINT = '/api/images';
|
|
|
|
| 48 |
const GENERATE_PRESETS = {
|
| 49 |
'1k-smoke-agent': ['--agent', '--size', '1024x1024', '--quality', 'low', '--stream-mode', 'non_stream'],
|
| 50 |
'4k-agent-nonstream': ['--agent', '--size', '3840x2160', '--quality', 'high', '--stream-mode', 'non_stream'],
|
|
@@ -182,8 +183,22 @@ try {
|
|
| 182 |
}
|
| 183 |
|
| 184 |
try {
|
| 185 |
-
if (
|
| 186 |
-
await runGenerateJob(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
} else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
|
| 188 |
try {
|
| 189 |
const result = await runPageSseRequest();
|
|
@@ -497,6 +512,9 @@ function buildDryRunVerificationScope() {
|
|
| 497 |
}
|
| 498 |
|
| 499 |
function dryRunEndpoint(body, routeMode) {
|
|
|
|
|
|
|
|
|
|
| 500 |
if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
|
| 501 |
if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
|
| 502 |
if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`;
|
|
@@ -506,6 +524,15 @@ function dryRunEndpoint(body, routeMode) {
|
|
| 506 |
}
|
| 507 |
|
| 508 |
function buildGenerateRoutingGuidance(body, routeMode) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
if (routeMode === 'job') {
|
| 510 |
return {
|
| 511 |
recommended_endpoint: AGENT_ENDPOINTS.create_generate_job,
|
|
@@ -1147,12 +1174,14 @@ function buildPageSseDiagnostics(state) {
|
|
| 1147 |
};
|
| 1148 |
}
|
| 1149 |
|
| 1150 |
-
async function runGenerateJob() {
|
|
|
|
|
|
|
| 1151 |
let lastResult;
|
| 1152 |
let lastRetryAfter = null;
|
| 1153 |
|
| 1154 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 1155 |
-
const { response, result } = await fetchJson(`${baseUrl}${
|
| 1156 |
method: 'POST',
|
| 1157 |
headers: {
|
| 1158 |
'Content-Type': 'application/json',
|
|
@@ -1167,10 +1196,7 @@ async function runGenerateJob() {
|
|
| 1167 |
const jobResult = await pollJobResult(result?.job);
|
| 1168 |
console.log(
|
| 1169 |
JSON.stringify(
|
| 1170 |
-
buildSuccessOutput(enrichImageUrls(jobResult),
|
| 1171 |
-
transport: 'agent_job_polling',
|
| 1172 |
-
endpoint: AGENT_ENDPOINTS.create_generate_job
|
| 1173 |
-
}, completeScriptTiming(scriptTiming)),
|
| 1174 |
null,
|
| 1175 |
2
|
| 1176 |
)
|
|
@@ -1189,7 +1215,7 @@ async function runGenerateJob() {
|
|
| 1189 |
JSON.stringify(
|
| 1190 |
buildFailureOutput(
|
| 1191 |
{ ...lastResult, retry_after: lastRetryAfter },
|
| 1192 |
-
|
| 1193 |
),
|
| 1194 |
null,
|
| 1195 |
2
|
|
@@ -1240,7 +1266,23 @@ async function pollJobResult(job) {
|
|
| 1240 |
|
| 1241 |
async function runContractCheck(capabilitiesValue) {
|
| 1242 |
const checks = [];
|
| 1243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1244 |
method: 'POST',
|
| 1245 |
headers: {
|
| 1246 |
'Content-Type': 'application/json',
|
|
@@ -1249,41 +1291,61 @@ async function runContractCheck(capabilitiesValue) {
|
|
| 1249 |
body: JSON.stringify(requestBody),
|
| 1250 |
timeoutMs
|
| 1251 |
});
|
| 1252 |
-
if (response.status ==
|
| 1253 |
-
|
| 1254 |
-
} else {
|
| 1255 |
-
console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
|
| 1256 |
process.exit(1);
|
| 1257 |
}
|
|
|
|
|
|
|
| 1258 |
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
| 1271 |
-
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
|
| 1275 |
-
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
}
|
| 1279 |
-
checks.push({
|
| 1280 |
-
endpoint: AGENT_ENDPOINTS.create_generate_job,
|
| 1281 |
-
status: jobCheck.response.status,
|
| 1282 |
-
error_code: jobCheck.result.error.code
|
| 1283 |
-
});
|
| 1284 |
}
|
|
|
|
|
|
|
| 1285 |
|
| 1286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1287 |
}
|
| 1288 |
|
| 1289 |
async function fetchJson(url, init) {
|
|
@@ -1349,10 +1411,25 @@ function supportsJobPolling(capabilitiesValue) {
|
|
| 1349 |
);
|
| 1350 |
}
|
| 1351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1352 |
function supportsPageSse(capabilitiesValue) {
|
| 1353 |
return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true);
|
| 1354 |
}
|
| 1355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
function shouldUseJobPolling(capabilitiesValue, routeMode) {
|
| 1357 |
if (routeMode !== 'job') return false;
|
| 1358 |
if (!supportsJobPolling(capabilitiesValue)) {
|
|
@@ -1380,6 +1457,10 @@ function isLargeGenerate(request) {
|
|
| 1380 |
return readMaxImageEdge(request.size) > 2048;
|
| 1381 |
}
|
| 1382 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1383 |
function isPageSseAllowed(request) {
|
| 1384 |
const streamingStrategy = request.streaming_strategy ?? request.streamingStrategy;
|
| 1385 |
const streamMode = request.stream_mode ?? request.streamMode;
|
|
|
|
| 45 |
const DEFAULT_OUTPUT_COMPRESSION = 100;
|
| 46 |
const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
|
| 47 |
const PAGE_SSE_ENDPOINT = '/api/images';
|
| 48 |
+
const SERVER_ORCHESTRATED_TRANSPORT = 'server_orchestrated';
|
| 49 |
const GENERATE_PRESETS = {
|
| 50 |
'1k-smoke-agent': ['--agent', '--size', '1024x1024', '--quality', 'low', '--stream-mode', 'non_stream'],
|
| 51 |
'4k-agent-nonstream': ['--agent', '--size', '3840x2160', '--quality', 'high', '--stream-mode', 'non_stream'],
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
try {
|
| 186 |
+
if (shouldUseServerOrchestration(capabilities, requestBody, options.routeMode)) {
|
| 187 |
+
await runGenerateJob({
|
| 188 |
+
createEndpoint: AGENT_ENDPOINTS.create_image_request,
|
| 189 |
+
routing: {
|
| 190 |
+
transport: SERVER_ORCHESTRATED_TRANSPORT,
|
| 191 |
+
endpoint: AGENT_ENDPOINTS.create_image_request
|
| 192 |
+
}
|
| 193 |
+
});
|
| 194 |
+
} else if (shouldUseJobPolling(capabilities, options.routeMode)) {
|
| 195 |
+
await runGenerateJob({
|
| 196 |
+
createEndpoint: AGENT_ENDPOINTS.create_generate_job,
|
| 197 |
+
routing: {
|
| 198 |
+
transport: 'agent_job_polling',
|
| 199 |
+
endpoint: AGENT_ENDPOINTS.create_generate_job
|
| 200 |
+
}
|
| 201 |
+
});
|
| 202 |
} else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
|
| 203 |
try {
|
| 204 |
const result = await runPageSseRequest();
|
|
|
|
| 512 |
}
|
| 513 |
|
| 514 |
function dryRunEndpoint(body, routeMode) {
|
| 515 |
+
if (routeMode === 'auto' && shouldPreferServerOrchestration(body)) {
|
| 516 |
+
return `${baseUrl}${AGENT_ENDPOINTS.create_image_request}`;
|
| 517 |
+
}
|
| 518 |
if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
|
| 519 |
if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
|
| 520 |
if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`;
|
|
|
|
| 524 |
}
|
| 525 |
|
| 526 |
function buildGenerateRoutingGuidance(body, routeMode) {
|
| 527 |
+
if (routeMode === 'auto' && shouldPreferServerOrchestration(body)) {
|
| 528 |
+
return {
|
| 529 |
+
recommended_endpoint: AGENT_ENDPOINTS.create_image_request,
|
| 530 |
+
transport: SERVER_ORCHESTRATED_TRANSPORT,
|
| 531 |
+
strength: 'recommended',
|
| 532 |
+
result_mode: 'job_polling',
|
| 533 |
+
reason: 'Agent 客户端只提交生成意图;服务端负责选择内部执行路径和轮询结果。'
|
| 534 |
+
};
|
| 535 |
+
}
|
| 536 |
if (routeMode === 'job') {
|
| 537 |
return {
|
| 538 |
recommended_endpoint: AGENT_ENDPOINTS.create_generate_job,
|
|
|
|
| 1174 |
};
|
| 1175 |
}
|
| 1176 |
|
| 1177 |
+
async function runGenerateJob(options = {}) {
|
| 1178 |
+
const createEndpoint = options.createEndpoint || AGENT_ENDPOINTS.create_generate_job;
|
| 1179 |
+
const routing = options.routing || { transport: 'agent_job_polling', endpoint: createEndpoint };
|
| 1180 |
let lastResult;
|
| 1181 |
let lastRetryAfter = null;
|
| 1182 |
|
| 1183 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 1184 |
+
const { response, result } = await fetchJson(`${baseUrl}${createEndpoint}`, {
|
| 1185 |
method: 'POST',
|
| 1186 |
headers: {
|
| 1187 |
'Content-Type': 'application/json',
|
|
|
|
| 1196 |
const jobResult = await pollJobResult(result?.job);
|
| 1197 |
console.log(
|
| 1198 |
JSON.stringify(
|
| 1199 |
+
buildSuccessOutput(enrichImageUrls(jobResult), routing, completeScriptTiming(scriptTiming)),
|
|
|
|
|
|
|
|
|
|
| 1200 |
null,
|
| 1201 |
2
|
| 1202 |
)
|
|
|
|
| 1215 |
JSON.stringify(
|
| 1216 |
buildFailureOutput(
|
| 1217 |
{ ...lastResult, retry_after: lastRetryAfter },
|
| 1218 |
+
routing
|
| 1219 |
),
|
| 1220 |
null,
|
| 1221 |
2
|
|
|
|
| 1266 |
|
| 1267 |
async function runContractCheck(capabilitiesValue) {
|
| 1268 |
const checks = [];
|
| 1269 |
+
checks.push(await checkContractEndpoint(AGENT_ENDPOINTS.generate));
|
| 1270 |
+
|
| 1271 |
+
if (shouldRequireServerOrchestrationContract()) {
|
| 1272 |
+
checks.push(await checkContractEndpoint(readRequiredOrchestrationEndpoint(capabilitiesValue)));
|
| 1273 |
+
} else if (supportsServerOrchestration(capabilitiesValue)) {
|
| 1274 |
+
checks.push(await checkContractEndpoint(readOrchestrationEndpoint(capabilitiesValue)));
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
if (supportsJobPolling(capabilitiesValue)) {
|
| 1278 |
+
checks.push(await checkContractEndpoint(AGENT_ENDPOINTS.create_generate_job));
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
async function checkContractEndpoint(endpoint) {
|
| 1285 |
+
const { response, result } = await fetchJson(`${baseUrl}${endpoint}`, {
|
| 1286 |
method: 'POST',
|
| 1287 |
headers: {
|
| 1288 |
'Content-Type': 'application/json',
|
|
|
|
| 1291 |
body: JSON.stringify(requestBody),
|
| 1292 |
timeoutMs
|
| 1293 |
});
|
| 1294 |
+
if (response.status !== 400 || result?.error?.code !== 'idempotency_key_required') {
|
| 1295 |
+
console.error(JSON.stringify({ ok: false, billable: false, endpoint, status: response.status, result }, null, 2));
|
|
|
|
|
|
|
| 1296 |
process.exit(1);
|
| 1297 |
}
|
| 1298 |
+
return { endpoint, status: response.status, error_code: result.error.code };
|
| 1299 |
+
}
|
| 1300 |
|
| 1301 |
+
function readOrchestrationEndpoint(capabilitiesValue) {
|
| 1302 |
+
const endpoint = capabilitiesValue?.orchestration?.endpoint;
|
| 1303 |
+
if (typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
|
| 1304 |
+
console.error(
|
| 1305 |
+
JSON.stringify(
|
| 1306 |
+
{
|
| 1307 |
+
ok: false,
|
| 1308 |
+
billable: false,
|
| 1309 |
+
error: {
|
| 1310 |
+
code: 'invalid_orchestration_endpoint',
|
| 1311 |
+
message: 'capabilities.orchestration.endpoint 必须是以 / 开头的路径。'
|
| 1312 |
+
},
|
| 1313 |
+
orchestration: capabilitiesValue?.orchestration ?? null
|
| 1314 |
+
},
|
| 1315 |
+
null,
|
| 1316 |
+
2
|
| 1317 |
+
)
|
| 1318 |
+
);
|
| 1319 |
+
process.exit(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1320 |
}
|
| 1321 |
+
return endpoint;
|
| 1322 |
+
}
|
| 1323 |
|
| 1324 |
+
function shouldRequireServerOrchestrationContract() {
|
| 1325 |
+
return options.routeMode === 'auto' && shouldPreferServerOrchestration(requestBody);
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
function readRequiredOrchestrationEndpoint(capabilitiesValue) {
|
| 1329 |
+
if (!supportsServerOrchestration(capabilitiesValue)) {
|
| 1330 |
+
console.error(
|
| 1331 |
+
JSON.stringify(
|
| 1332 |
+
{
|
| 1333 |
+
ok: false,
|
| 1334 |
+
billable: false,
|
| 1335 |
+
error: {
|
| 1336 |
+
code: 'orchestration_required',
|
| 1337 |
+
message:
|
| 1338 |
+
'默认 generate 需要 capabilities.orchestration.supported=true 且 transport_selection=server_owned。'
|
| 1339 |
+
},
|
| 1340 |
+
orchestration: capabilitiesValue?.orchestration ?? null
|
| 1341 |
+
},
|
| 1342 |
+
null,
|
| 1343 |
+
2
|
| 1344 |
+
)
|
| 1345 |
+
);
|
| 1346 |
+
process.exit(1);
|
| 1347 |
+
}
|
| 1348 |
+
return readOrchestrationEndpoint(capabilitiesValue);
|
| 1349 |
}
|
| 1350 |
|
| 1351 |
async function fetchJson(url, init) {
|
|
|
|
| 1411 |
);
|
| 1412 |
}
|
| 1413 |
|
| 1414 |
+
function supportsServerOrchestration(capabilitiesValue) {
|
| 1415 |
+
return Boolean(
|
| 1416 |
+
capabilitiesValue?.orchestration?.supported === true &&
|
| 1417 |
+
capabilitiesValue.orchestration.transport_selection === 'server_owned'
|
| 1418 |
+
);
|
| 1419 |
+
}
|
| 1420 |
+
|
| 1421 |
function supportsPageSse(capabilitiesValue) {
|
| 1422 |
return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true);
|
| 1423 |
}
|
| 1424 |
|
| 1425 |
+
function shouldUseServerOrchestration(capabilitiesValue, request, routeMode) {
|
| 1426 |
+
if (routeMode !== 'auto' || !shouldPreferServerOrchestration(request)) return false;
|
| 1427 |
+
if (!supportsServerOrchestration(capabilitiesValue)) {
|
| 1428 |
+
throw new Error('服务 capabilities 未声明 orchestration.supported=true,不能调用统一生成入口。');
|
| 1429 |
+
}
|
| 1430 |
+
return true;
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
function shouldUseJobPolling(capabilitiesValue, routeMode) {
|
| 1434 |
if (routeMode !== 'job') return false;
|
| 1435 |
if (!supportsJobPolling(capabilitiesValue)) {
|
|
|
|
| 1457 |
return readMaxImageEdge(request.size) > 2048;
|
| 1458 |
}
|
| 1459 |
|
| 1460 |
+
function shouldPreferServerOrchestration(request) {
|
| 1461 |
+
return !hasPageOnlyGenerateOptions(request);
|
| 1462 |
+
}
|
| 1463 |
+
|
| 1464 |
function isPageSseAllowed(request) {
|
| 1465 |
const streamingStrategy = request.streaming_strategy ?? request.streamingStrategy;
|
| 1466 |
const streamMode = request.stream_mode ?? request.streamMode;
|
skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
export const AGENT_ENDPOINTS = Object.freeze({
|
| 2 |
capabilities: '/api/agent/capabilities',
|
| 3 |
openapi: '/api/agent/openapi.json',
|
|
|
|
| 4 |
generate: '/api/agent/images/generate',
|
| 5 |
edit: '/api/agent/images/edit',
|
| 6 |
create_generate_job: '/api/agent/jobs/images/generate',
|
|
|
|
| 1 |
export const AGENT_ENDPOINTS = Object.freeze({
|
| 2 |
capabilities: '/api/agent/capabilities',
|
| 3 |
openapi: '/api/agent/openapi.json',
|
| 4 |
+
create_image_request: '/api/agent/image-requests',
|
| 5 |
generate: '/api/agent/images/generate',
|
| 6 |
edit: '/api/agent/images/edit',
|
| 7 |
create_generate_job: '/api/agent/jobs/images/generate',
|
skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs
CHANGED
|
@@ -156,6 +156,7 @@ function readNonNegativeNumber(value) {
|
|
| 156 |
function readRouteMode(routing) {
|
| 157 |
if (typeof routing?.route_mode === 'string' && routing.route_mode) return routing.route_mode;
|
| 158 |
if (routing?.transport === 'page_sse') return 'page_sse';
|
|
|
|
| 159 |
if (routing?.transport === 'agent_job_polling') return 'job';
|
| 160 |
if (routing?.transport === 'agent_json') return 'agent';
|
| 161 |
return undefined;
|
|
|
|
| 156 |
function readRouteMode(routing) {
|
| 157 |
if (typeof routing?.route_mode === 'string' && routing.route_mode) return routing.route_mode;
|
| 158 |
if (routing?.transport === 'page_sse') return 'page_sse';
|
| 159 |
+
if (routing?.transport === 'server_orchestrated') return 'orchestrated';
|
| 160 |
if (routing?.transport === 'agent_job_polling') return 'job';
|
| 161 |
if (routing?.transport === 'agent_json') return 'agent';
|
| 162 |
return undefined;
|
src/app/api/agent/agent-routes.test.ts
CHANGED
|
@@ -1113,6 +1113,40 @@ describe('Agent route integration', () => {
|
|
| 1113 |
}
|
| 1114 |
});
|
| 1115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
it('does not consume generate job idempotency keys for local profile validation failures', async () => {
|
| 1117 |
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 1118 |
let upstreamCalls = 0;
|
|
@@ -2320,7 +2354,7 @@ describe('Agent route integration', () => {
|
|
| 2320 |
{ params: Promise.resolve({ id: artifactId }) }
|
| 2321 |
);
|
| 2322 |
assert.equal(allowed.status, 200);
|
| 2323 |
-
assert.equal(allowed.headers.get('content-type'), 'image/
|
| 2324 |
assert.ok((await allowed.arrayBuffer()).byteLength > 0);
|
| 2325 |
|
| 2326 |
const metadata = await getArtifact(
|
|
@@ -2332,6 +2366,8 @@ describe('Agent route integration', () => {
|
|
| 2332 |
assert.equal(metadata.status, 200);
|
| 2333 |
const metadataBody = await metadata.json();
|
| 2334 |
assert.equal(metadataBody.artifact.id, artifactId);
|
|
|
|
|
|
|
| 2335 |
assert.equal('filepath' in metadataBody.artifact, false);
|
| 2336 |
|
| 2337 |
const deleted = await deleteArtifact(
|
|
@@ -3012,6 +3048,7 @@ async function loadAgentRoutes() {
|
|
| 3012 |
const artifactRoute = await import('./artifacts/[id]/route');
|
| 3013 |
const artifactContentRoute = await import('./artifacts/[id]/content/route');
|
| 3014 |
const capabilitiesRoute = await import('./capabilities/route');
|
|
|
|
| 3015 |
const createGenerateJobRoute = await import('./jobs/images/generate/route');
|
| 3016 |
const jobRoute = await import('./jobs/[id]/route');
|
| 3017 |
const jobResultRoute = await import('./jobs/[id]/result/route');
|
|
@@ -3026,6 +3063,7 @@ async function loadAgentRoutes() {
|
|
| 3026 |
getCapabilities: () => capabilitiesRoute.GET(),
|
| 3027 |
generateImage: (request: Request) => generateRoute.POST(asNextRequest(request)),
|
| 3028 |
editImage: (request: Request) => editRoute.POST(asNextRequest(request)),
|
|
|
|
| 3029 |
createGenerateJob: (request: Request) => createGenerateJobRoute.POST(asNextRequest(request)),
|
| 3030 |
getJob: (request: Request, context: AgentRouteContext) => jobRoute.GET(asNextRequest(request), context),
|
| 3031 |
getJobResult: (request: Request, context: AgentRouteContext) =>
|
|
@@ -3090,6 +3128,24 @@ function agentJobJsonRequest(
|
|
| 3090 |
});
|
| 3091 |
}
|
| 3092 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3093 |
type AgentEditRequestFields = {
|
| 3094 |
image_0?: Buffer;
|
| 3095 |
mask?: Buffer;
|
|
|
|
| 1113 |
}
|
| 1114 |
});
|
| 1115 |
|
| 1116 |
+
it('creates a server-orchestrated image request job and records the orchestration endpoint', async () => {
|
| 1117 |
+
const { createImageRequest, getJobResult } = await loadAgentRoutes();
|
| 1118 |
+
let upstreamCalls = 0;
|
| 1119 |
+
const upstream = await startImageUpstream(() => {
|
| 1120 |
+
upstreamCalls += 1;
|
| 1121 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 1122 |
+
});
|
| 1123 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1124 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1125 |
+
|
| 1126 |
+
try {
|
| 1127 |
+
const created = await createImageRequest(
|
| 1128 |
+
agentImageRequest('route-orchestrated-key', { prompt: 'server orchestrated generate' })
|
| 1129 |
+
);
|
| 1130 |
+
assert.equal(created.status, 202);
|
| 1131 |
+
const createdBody = await created.json();
|
| 1132 |
+
assert.equal(createdBody.job.state, 'running');
|
| 1133 |
+
assert.equal(createdBody.job.idempotency_key, 'route-orchestrated-key');
|
| 1134 |
+
|
| 1135 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 1136 |
+
assert.equal(result.status, 200);
|
| 1137 |
+
const resultBody = await result.json();
|
| 1138 |
+
assert.equal(resultBody.request_id, createdBody.job.id);
|
| 1139 |
+
assert.equal(resultBody.cached, false);
|
| 1140 |
+
assert.equal(resultBody.images.length, 1);
|
| 1141 |
+
assert.equal(resultBody.execution.transport, 'agent_job_polling');
|
| 1142 |
+
assert.equal(resultBody.execution.endpoint, '/api/agent/image-requests');
|
| 1143 |
+
assert.equal(resultBody.execution.route_mode, 'job');
|
| 1144 |
+
assert.equal(upstreamCalls, 1);
|
| 1145 |
+
} finally {
|
| 1146 |
+
await upstream.close();
|
| 1147 |
+
}
|
| 1148 |
+
});
|
| 1149 |
+
|
| 1150 |
it('does not consume generate job idempotency keys for local profile validation failures', async () => {
|
| 1151 |
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 1152 |
let upstreamCalls = 0;
|
|
|
|
| 2354 |
{ params: Promise.resolve({ id: artifactId }) }
|
| 2355 |
);
|
| 2356 |
assert.equal(allowed.status, 200);
|
| 2357 |
+
assert.equal(allowed.headers.get('content-type'), 'image/png');
|
| 2358 |
assert.ok((await allowed.arrayBuffer()).byteLength > 0);
|
| 2359 |
|
| 2360 |
const metadata = await getArtifact(
|
|
|
|
| 2366 |
assert.equal(metadata.status, 200);
|
| 2367 |
const metadataBody = await metadata.json();
|
| 2368 |
assert.equal(metadataBody.artifact.id, artifactId);
|
| 2369 |
+
assert.equal(metadataBody.artifact.output_format, 'png');
|
| 2370 |
+
assert.equal(metadataBody.artifact.mime_type, 'image/png');
|
| 2371 |
assert.equal('filepath' in metadataBody.artifact, false);
|
| 2372 |
|
| 2373 |
const deleted = await deleteArtifact(
|
|
|
|
| 3048 |
const artifactRoute = await import('./artifacts/[id]/route');
|
| 3049 |
const artifactContentRoute = await import('./artifacts/[id]/content/route');
|
| 3050 |
const capabilitiesRoute = await import('./capabilities/route');
|
| 3051 |
+
const imageRequestRoute = await import('./image-requests/route');
|
| 3052 |
const createGenerateJobRoute = await import('./jobs/images/generate/route');
|
| 3053 |
const jobRoute = await import('./jobs/[id]/route');
|
| 3054 |
const jobResultRoute = await import('./jobs/[id]/result/route');
|
|
|
|
| 3063 |
getCapabilities: () => capabilitiesRoute.GET(),
|
| 3064 |
generateImage: (request: Request) => generateRoute.POST(asNextRequest(request)),
|
| 3065 |
editImage: (request: Request) => editRoute.POST(asNextRequest(request)),
|
| 3066 |
+
createImageRequest: (request: Request) => imageRequestRoute.POST(asNextRequest(request)),
|
| 3067 |
createGenerateJob: (request: Request) => createGenerateJobRoute.POST(asNextRequest(request)),
|
| 3068 |
getJob: (request: Request, context: AgentRouteContext) => jobRoute.GET(asNextRequest(request), context),
|
| 3069 |
getJobResult: (request: Request, context: AgentRouteContext) =>
|
|
|
|
| 3128 |
});
|
| 3129 |
}
|
| 3130 |
|
| 3131 |
+
function agentImageRequest(
|
| 3132 |
+
idempotencyKey: string,
|
| 3133 |
+
body: Record<string, unknown>,
|
| 3134 |
+
headers: Record<string, string> = {}
|
| 3135 |
+
) {
|
| 3136 |
+
const requestBody =
|
| 3137 |
+
'stream_mode' in body || 'streaming_strategy' in body ? body : { ...body, stream_mode: 'non_stream' };
|
| 3138 |
+
return new Request('http://localhost/api/agent/image-requests', {
|
| 3139 |
+
method: 'POST',
|
| 3140 |
+
headers: {
|
| 3141 |
+
'Content-Type': 'application/json',
|
| 3142 |
+
'Idempotency-Key': idempotencyKey,
|
| 3143 |
+
...headers
|
| 3144 |
+
},
|
| 3145 |
+
body: JSON.stringify(requestBody)
|
| 3146 |
+
});
|
| 3147 |
+
}
|
| 3148 |
+
|
| 3149 |
type AgentEditRequestFields = {
|
| 3150 |
image_0?: Buffer;
|
| 3151 |
mask?: Buffer;
|
src/app/api/agent/image-requests/route.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createAgentGenerateJobResponse } from '@/lib/agent-generate-job-route';
|
| 2 |
+
import { AGENT_ENDPOINTS } from '@/lib/agent-api-paths.mjs';
|
| 3 |
+
import type { NextRequest } from 'next/server';
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
return createAgentGenerateJobResponse(request, {
|
| 7 |
+
transportEndpoint: AGENT_ENDPOINTS.create_image_request
|
| 8 |
+
});
|
| 9 |
+
}
|
src/app/api/agent/jobs/images/generate/route.ts
CHANGED
|
@@ -1,96 +1,9 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import {
|
| 3 |
-
|
| 4 |
-
parseAgentGenerateRequest,
|
| 5 |
-
prepareAgentGenerate,
|
| 6 |
-
resolveExistingAgentRequest,
|
| 7 |
-
readIdempotencyKey
|
| 8 |
-
} from '@/lib/agent-image-service';
|
| 9 |
-
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 10 |
-
import { AgentApiError, agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response';
|
| 11 |
-
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 12 |
-
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 13 |
-
import { createRequestId, type BeginAgentRequestResult } from '@/lib/agent-state-store';
|
| 14 |
-
import { NextRequest, NextResponse } from 'next/server';
|
| 15 |
|
| 16 |
export async function POST(request: NextRequest) {
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
assertAgentAuthorized(request.headers);
|
| 20 |
-
const imageRequest = await parseAgentGenerateRequest(request);
|
| 21 |
-
const idempotencyKey = readIdempotencyKey(request.headers);
|
| 22 |
-
const requestHash = buildGenerateRequestHash(imageRequest);
|
| 23 |
-
const store = await ensureAgentStateStoreReady();
|
| 24 |
-
const existingResponse = jobBeginResultResponse(
|
| 25 |
-
resolveExistingAgentRequest(
|
| 26 |
-
await store.getRequestByIdempotencyKey(idempotencyKey),
|
| 27 |
-
requestHash
|
| 28 |
-
)
|
| 29 |
-
);
|
| 30 |
-
if (existingResponse) return existingResponse;
|
| 31 |
-
const preparation = prepareAgentGenerate(imageRequest, request.headers);
|
| 32 |
-
const leaseMs = readAgentLeaseMs(process.env);
|
| 33 |
-
const beginResult = await store.beginRequest({
|
| 34 |
-
idempotencyKey,
|
| 35 |
-
requestHash,
|
| 36 |
-
mode: 'generate',
|
| 37 |
-
requestJson: imageRequest,
|
| 38 |
-
leaseMs,
|
| 39 |
-
ttlSeconds: readAgentRequestTtlSeconds(process.env)
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
const storedResponse = jobBeginResultResponse(beginResult);
|
| 43 |
-
if (storedResponse) return storedResponse;
|
| 44 |
-
|
| 45 |
-
requestId = beginResult.record.requestId;
|
| 46 |
-
startAgentGenerateJob({
|
| 47 |
-
store,
|
| 48 |
-
request: imageRequest,
|
| 49 |
-
headers: new Headers(request.headers),
|
| 50 |
-
requestId,
|
| 51 |
-
idempotencyKey,
|
| 52 |
-
leaseMs,
|
| 53 |
-
preparation
|
| 54 |
-
});
|
| 55 |
-
return runningJobResponse(beginResult.record, 5, false);
|
| 56 |
-
} catch (error) {
|
| 57 |
-
return agentErrorResponse(normalizeAgentError(error), requestId);
|
| 58 |
-
}
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
function createIdempotencyConflictError(): AgentApiError {
|
| 62 |
-
return new AgentApiError({
|
| 63 |
-
code: 'idempotency_conflict',
|
| 64 |
-
message: 'Idempotency-Key 已被不同请求正文使用。',
|
| 65 |
-
status: 409,
|
| 66 |
-
retryable: false
|
| 67 |
-
});
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
function jobBeginResultResponse(beginResult: BeginAgentRequestResult | undefined): NextResponse | undefined {
|
| 71 |
-
if (!beginResult || beginResult.type === 'acquired') return undefined;
|
| 72 |
-
if (beginResult.type === 'conflict') {
|
| 73 |
-
throw createIdempotencyConflictError();
|
| 74 |
-
}
|
| 75 |
-
if (beginResult.type === 'replay' || beginResult.type === 'failed') {
|
| 76 |
-
return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
|
| 77 |
-
status: 202,
|
| 78 |
-
headers: {
|
| 79 |
-
'X-Idempotent-Replay': 'true',
|
| 80 |
-
'X-Request-Id': beginResult.record.requestId
|
| 81 |
-
}
|
| 82 |
-
});
|
| 83 |
-
}
|
| 84 |
-
return runningJobResponse(beginResult.record, beginResult.retryAfterSeconds, true);
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
function runningJobResponse(record: Parameters<typeof buildAgentJobStatusResponse>[0], retryAfterSeconds: number, replay: boolean) {
|
| 88 |
-
return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
|
| 89 |
-
status: 202,
|
| 90 |
-
headers: {
|
| 91 |
-
'Retry-After': String(retryAfterSeconds),
|
| 92 |
-
'X-Request-Id': record.requestId,
|
| 93 |
-
...(replay ? { 'X-Idempotent-Replay': 'true' } : {})
|
| 94 |
-
}
|
| 95 |
});
|
| 96 |
}
|
|
|
|
| 1 |
+
import { createAgentGenerateJobResponse } from '@/lib/agent-generate-job-route';
|
| 2 |
+
import { AGENT_ENDPOINTS } from '@/lib/agent-api-paths.mjs';
|
| 3 |
+
import type { NextRequest } from 'next/server';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export async function POST(request: NextRequest) {
|
| 6 |
+
return createAgentGenerateJobResponse(request, {
|
| 7 |
+
transportEndpoint: AGENT_ENDPOINTS.create_generate_job
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
});
|
| 9 |
}
|
src/app/api/deploy-marker/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
-
const deployMarker = {"schema_version":1,"local_sha":"
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
|
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
+
const deployMarker = {"schema_version":1,"local_sha":"3203c24f5434c569bbfec2fb425430d3e45fcbc3","created_at":"2026-06-23T13:55:49.360Z","deploy_id":"dea5a9f7-24bf-4297-a091-f730901c220a"} as const;
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
src/lib/agent-api-contracts.test.ts
CHANGED
|
@@ -439,7 +439,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 439 |
});
|
| 440 |
assert.equal(
|
| 441 |
capabilities.agent_streaming.page_sse.agent_usage,
|
| 442 |
-
'
|
| 443 |
);
|
| 444 |
assert.deepEqual(capabilities.upstream_request_headers.default, {
|
| 445 |
user_agent_effective: 'gpt-image-playground/2.1.0',
|
|
@@ -480,16 +480,18 @@ describe('buildAgentCapabilities', () => {
|
|
| 480 |
resume_or_recover: true
|
| 481 |
});
|
| 482 |
assert.equal(capabilities.routing_rules.agent_generate_small_smoke.endpoint, AGENT_ENDPOINTS.generate);
|
|
|
|
| 483 |
assert.deepEqual(capabilities.routing_rules.agent_generate_small_smoke.action, {
|
| 484 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 485 |
transport: 'agent_json',
|
| 486 |
-
strength: '
|
| 487 |
requires_new_idempotency_key_on_retry: true,
|
| 488 |
no_automatic_fallback: true
|
| 489 |
});
|
| 490 |
assert.equal(capabilities.routing_rules.page_sse_large_generate.endpoint, '/api/images');
|
| 491 |
assert.equal(capabilities.routing_rules.page_sse_large_generate.transport, 'page_sse');
|
| 492 |
-
assert.equal(capabilities.routing_rules.page_sse_large_generate.strength, '
|
|
|
|
| 493 |
assert.deepEqual(capabilities.routing_rules.page_sse_large_generate.conditions, {
|
| 494 |
operation: 'generate',
|
| 495 |
max_edge: { operator: 'gt', value: 2048 },
|
|
@@ -497,6 +499,17 @@ describe('buildAgentCapabilities', () => {
|
|
| 497 |
});
|
| 498 |
assert.equal(capabilities.routing_rules.retry_recovery.reuse_failed_idempotency_key, false);
|
| 499 |
assert.match(capabilities.routing_rules.retry_recovery.new_attempt_guidance, /new Idempotency-Key/);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
assert.deepEqual(capabilities.supported.image_backends, ['images-api', 'responses-image-generation']);
|
| 501 |
assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']);
|
| 502 |
assert.deepEqual(capabilities.supported.image_backend_requirements['images-api'], {
|
|
@@ -521,6 +534,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 521 |
]);
|
| 522 |
assert.deepEqual(capabilities.supported.stream_modes, ['auto', 'stream', 'non_stream']);
|
| 523 |
assert.equal(capabilities.endpoints.create_generate_job, AGENT_ENDPOINTS.create_generate_job);
|
|
|
|
| 524 |
assert.equal(capabilities.endpoints.page_request_feedback_batch, AGENT_ENDPOINTS.page_request_feedback_batch);
|
| 525 |
assert.equal(capabilities.endpoints.page_request_feedback, AGENT_ENDPOINTS.page_request_feedback);
|
| 526 |
assert.equal(
|
|
@@ -542,8 +556,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 542 |
]);
|
| 543 |
assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, AGENT_JOB_ENDPOINTS.create_generate_job);
|
| 544 |
assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']);
|
| 545 |
-
assert.match(capabilities.agent_jobs.current_guidance, /\/
|
| 546 |
-
assert.match(capabilities.agent_jobs.current_guidance, /不自动回退/);
|
| 547 |
assert.match(capabilities.agent_jobs.current_guidance, /job/);
|
| 548 |
});
|
| 549 |
|
|
@@ -767,6 +780,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 767 |
assert.equal(document.openapi, '3.1.0');
|
| 768 |
assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
|
| 769 |
assert.ok(AGENT_ENDPOINTS.openapi in document.paths);
|
|
|
|
| 770 |
assert.ok(AGENT_ENDPOINTS.generate in document.paths);
|
| 771 |
assert.ok(AGENT_ENDPOINTS.create_generate_job in document.paths);
|
| 772 |
assert.ok(AGENT_ENDPOINTS.job in document.paths);
|
|
@@ -800,6 +814,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 800 |
assert.ok('AgentPageRequestDiagnosticsCapabilities' in document.components.schemas);
|
| 801 |
assert.ok('AppLogRetentionMetadata' in document.components.schemas);
|
| 802 |
assert.ok('AgentJobCapabilities' in document.components.schemas);
|
|
|
|
| 803 |
assert.ok('AgentRoutingRules' in document.components.schemas);
|
| 804 |
assert.ok('AgentRoutingRule' in document.components.schemas);
|
| 805 |
assert.ok('AgentErrorDiagnostics' in document.components.schemas);
|
|
@@ -814,6 +829,8 @@ describe('buildAgentCapabilities', () => {
|
|
| 814 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['403']);
|
| 815 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['429']);
|
| 816 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['422']);
|
|
|
|
|
|
|
| 817 |
assert.ok(document.paths[AGENT_ENDPOINTS.create_generate_job].post.responses['202']);
|
| 818 |
assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['200']);
|
| 819 |
assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['409']);
|
|
|
|
| 439 |
});
|
| 440 |
assert.equal(
|
| 441 |
capabilities.agent_streaming.page_sse.agent_usage,
|
| 442 |
+
'explicit_for_generate_recommended_for_high_resolution_edit_and_complex_batch'
|
| 443 |
);
|
| 444 |
assert.deepEqual(capabilities.upstream_request_headers.default, {
|
| 445 |
user_agent_effective: 'gpt-image-playground/2.1.0',
|
|
|
|
| 480 |
resume_or_recover: true
|
| 481 |
});
|
| 482 |
assert.equal(capabilities.routing_rules.agent_generate_small_smoke.endpoint, AGENT_ENDPOINTS.generate);
|
| 483 |
+
assert.equal(capabilities.routing_rules.agent_generate_small_smoke.strength, 'explicit');
|
| 484 |
assert.deepEqual(capabilities.routing_rules.agent_generate_small_smoke.action, {
|
| 485 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 486 |
transport: 'agent_json',
|
| 487 |
+
strength: 'explicit',
|
| 488 |
requires_new_idempotency_key_on_retry: true,
|
| 489 |
no_automatic_fallback: true
|
| 490 |
});
|
| 491 |
assert.equal(capabilities.routing_rules.page_sse_large_generate.endpoint, '/api/images');
|
| 492 |
assert.equal(capabilities.routing_rules.page_sse_large_generate.transport, 'page_sse');
|
| 493 |
+
assert.equal(capabilities.routing_rules.page_sse_large_generate.strength, 'explicit');
|
| 494 |
+
assert.equal(capabilities.routing_rules.page_sse_large_generate.action.strength, 'explicit');
|
| 495 |
assert.deepEqual(capabilities.routing_rules.page_sse_large_generate.conditions, {
|
| 496 |
operation: 'generate',
|
| 497 |
max_edge: { operator: 'gt', value: 2048 },
|
|
|
|
| 499 |
});
|
| 500 |
assert.equal(capabilities.routing_rules.retry_recovery.reuse_failed_idempotency_key, false);
|
| 501 |
assert.match(capabilities.routing_rules.retry_recovery.new_attempt_guidance, /new Idempotency-Key/);
|
| 502 |
+
assert.equal(capabilities.orchestration.supported, true);
|
| 503 |
+
assert.equal(capabilities.orchestration.policy, 'server_orchestrated_generate_v1');
|
| 504 |
+
assert.equal(capabilities.orchestration.endpoint, AGENT_ENDPOINTS.create_image_request);
|
| 505 |
+
assert.equal(capabilities.orchestration.client_contract, 'intent_only');
|
| 506 |
+
assert.equal(capabilities.orchestration.transport_selection, 'server_owned');
|
| 507 |
+
assert.equal(capabilities.orchestration.result_mode, 'job_polling');
|
| 508 |
+
assert.deepEqual(capabilities.orchestration.diagnostics, {
|
| 509 |
+
job_result: AGENT_ENDPOINTS.job_result,
|
| 510 |
+
request_lookup: AGENT_ENDPOINTS.agent_request_diagnostics_lookup
|
| 511 |
+
});
|
| 512 |
+
assert.match(capabilities.orchestration.current_guidance, /只提交生成意图/);
|
| 513 |
assert.deepEqual(capabilities.supported.image_backends, ['images-api', 'responses-image-generation']);
|
| 514 |
assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']);
|
| 515 |
assert.deepEqual(capabilities.supported.image_backend_requirements['images-api'], {
|
|
|
|
| 534 |
]);
|
| 535 |
assert.deepEqual(capabilities.supported.stream_modes, ['auto', 'stream', 'non_stream']);
|
| 536 |
assert.equal(capabilities.endpoints.create_generate_job, AGENT_ENDPOINTS.create_generate_job);
|
| 537 |
+
assert.equal(capabilities.endpoints.create_image_request, AGENT_ENDPOINTS.create_image_request);
|
| 538 |
assert.equal(capabilities.endpoints.page_request_feedback_batch, AGENT_ENDPOINTS.page_request_feedback_batch);
|
| 539 |
assert.equal(capabilities.endpoints.page_request_feedback, AGENT_ENDPOINTS.page_request_feedback);
|
| 540 |
assert.equal(
|
|
|
|
| 556 |
]);
|
| 557 |
assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, AGENT_JOB_ENDPOINTS.create_generate_job);
|
| 558 |
assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']);
|
| 559 |
+
assert.match(capabilities.agent_jobs.current_guidance, /orchestration\.endpoint/);
|
|
|
|
| 560 |
assert.match(capabilities.agent_jobs.current_guidance, /job/);
|
| 561 |
});
|
| 562 |
|
|
|
|
| 780 |
assert.equal(document.openapi, '3.1.0');
|
| 781 |
assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
|
| 782 |
assert.ok(AGENT_ENDPOINTS.openapi in document.paths);
|
| 783 |
+
assert.ok(AGENT_ENDPOINTS.create_image_request in document.paths);
|
| 784 |
assert.ok(AGENT_ENDPOINTS.generate in document.paths);
|
| 785 |
assert.ok(AGENT_ENDPOINTS.create_generate_job in document.paths);
|
| 786 |
assert.ok(AGENT_ENDPOINTS.job in document.paths);
|
|
|
|
| 814 |
assert.ok('AgentPageRequestDiagnosticsCapabilities' in document.components.schemas);
|
| 815 |
assert.ok('AppLogRetentionMetadata' in document.components.schemas);
|
| 816 |
assert.ok('AgentJobCapabilities' in document.components.schemas);
|
| 817 |
+
assert.ok('AgentOrchestrationCapabilities' in document.components.schemas);
|
| 818 |
assert.ok('AgentRoutingRules' in document.components.schemas);
|
| 819 |
assert.ok('AgentRoutingRule' in document.components.schemas);
|
| 820 |
assert.ok('AgentErrorDiagnostics' in document.components.schemas);
|
|
|
|
| 829 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['403']);
|
| 830 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['429']);
|
| 831 |
assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['422']);
|
| 832 |
+
assert.ok(document.paths[AGENT_ENDPOINTS.create_image_request].post.responses['202']);
|
| 833 |
+
assert.ok(document.paths[AGENT_ENDPOINTS.create_image_request].post.responses['409']);
|
| 834 |
assert.ok(document.paths[AGENT_ENDPOINTS.create_generate_job].post.responses['202']);
|
| 835 |
assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['200']);
|
| 836 |
assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['409']);
|
src/lib/agent-api-contracts.ts
CHANGED
|
@@ -102,7 +102,8 @@ export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number];
|
|
| 102 |
export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
|
| 103 |
export type AgentJobState = (typeof AGENT_JOB_STATES)[number];
|
| 104 |
export type AgentRoutingTransport = 'agent_json' | 'agent_job_polling' | 'page_sse';
|
| 105 |
-
export type AgentRoutingStrength = 'default' | 'recommended';
|
|
|
|
| 106 |
export type ImageBackendRuntimeRequirement = {
|
| 107 |
supported: true;
|
| 108 |
enabled: boolean;
|
|
@@ -314,9 +315,23 @@ export type AgentCapabilities = {
|
|
| 314 |
source_header: 'Idempotency-Key';
|
| 315 |
max_length: number;
|
| 316 |
};
|
| 317 |
-
agent_usage: '
|
| 318 |
};
|
| 319 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
routing_rules: {
|
| 321 |
high_resolution_edit: AgentRoutingRule;
|
| 322 |
complex_ui_batch: AgentRoutingRule;
|
|
@@ -977,9 +992,24 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 977 |
source_header: 'Idempotency-Key',
|
| 978 |
max_length: PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH
|
| 979 |
},
|
| 980 |
-
agent_usage: '
|
| 981 |
}
|
| 982 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
routing_rules: {
|
| 984 |
high_resolution_edit: {
|
| 985 |
when: ['operation=edit', 'max_edge>2048'],
|
|
@@ -1050,15 +1080,15 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 1050 |
},
|
| 1051 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 1052 |
transport: 'agent_json',
|
| 1053 |
-
strength: '
|
| 1054 |
action: {
|
| 1055 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 1056 |
transport: 'agent_json',
|
| 1057 |
-
strength: '
|
| 1058 |
requires_new_idempotency_key_on_retry: true,
|
| 1059 |
no_automatic_fallback: true
|
| 1060 |
},
|
| 1061 |
-
reason: 'Agent JSON generate remains
|
| 1062 |
},
|
| 1063 |
page_sse_large_generate: {
|
| 1064 |
when: ['operation=generate', 'max_edge>2048', 'single_request=true'],
|
|
@@ -1069,17 +1099,17 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 1069 |
},
|
| 1070 |
endpoint: '/api/images',
|
| 1071 |
transport: 'page_sse',
|
| 1072 |
-
strength: '
|
| 1073 |
action: {
|
| 1074 |
endpoint: '/api/images',
|
| 1075 |
transport: 'page_sse',
|
| 1076 |
-
strength: '
|
| 1077 |
fallback_endpoint: AGENT_ENDPOINTS.generate,
|
| 1078 |
fallback_mode: 'manual_after_diagnosis',
|
| 1079 |
requires_new_idempotency_key_on_retry: true,
|
| 1080 |
no_automatic_fallback: true
|
| 1081 |
},
|
| 1082 |
-
reason: 'Page form-data SSE
|
| 1083 |
},
|
| 1084 |
retry_recovery: {
|
| 1085 |
reuse_failed_idempotency_key: false,
|
|
@@ -1098,7 +1128,7 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 1098 |
endpoints: { ...AGENT_JOB_ENDPOINTS },
|
| 1099 |
states: AGENT_JOB_STATES,
|
| 1100 |
current_guidance:
|
| 1101 |
-
'
|
| 1102 |
},
|
| 1103 |
supported: {
|
| 1104 |
models: AGENT_MODELS,
|
|
|
|
| 102 |
export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
|
| 103 |
export type AgentJobState = (typeof AGENT_JOB_STATES)[number];
|
| 104 |
export type AgentRoutingTransport = 'agent_json' | 'agent_job_polling' | 'page_sse';
|
| 105 |
+
export type AgentRoutingStrength = 'default' | 'recommended' | 'explicit';
|
| 106 |
+
export type AgentOrchestrationPolicy = 'server_orchestrated_generate_v1';
|
| 107 |
export type ImageBackendRuntimeRequirement = {
|
| 108 |
supported: true;
|
| 109 |
enabled: boolean;
|
|
|
|
| 315 |
source_header: 'Idempotency-Key';
|
| 316 |
max_length: number;
|
| 317 |
};
|
| 318 |
+
agent_usage: 'explicit_for_generate_recommended_for_high_resolution_edit_and_complex_batch';
|
| 319 |
};
|
| 320 |
};
|
| 321 |
+
orchestration: {
|
| 322 |
+
supported: true;
|
| 323 |
+
policy: AgentOrchestrationPolicy;
|
| 324 |
+
endpoint: string;
|
| 325 |
+
client_contract: 'intent_only';
|
| 326 |
+
transport_selection: 'server_owned';
|
| 327 |
+
result_mode: 'job_polling';
|
| 328 |
+
hidden_controls: readonly string[];
|
| 329 |
+
diagnostics: {
|
| 330 |
+
job_result: string;
|
| 331 |
+
request_lookup: string;
|
| 332 |
+
};
|
| 333 |
+
current_guidance: string;
|
| 334 |
+
};
|
| 335 |
routing_rules: {
|
| 336 |
high_resolution_edit: AgentRoutingRule;
|
| 337 |
complex_ui_batch: AgentRoutingRule;
|
|
|
|
| 992 |
source_header: 'Idempotency-Key',
|
| 993 |
max_length: PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH
|
| 994 |
},
|
| 995 |
+
agent_usage: 'explicit_for_generate_recommended_for_high_resolution_edit_and_complex_batch'
|
| 996 |
}
|
| 997 |
},
|
| 998 |
+
orchestration: {
|
| 999 |
+
supported: true,
|
| 1000 |
+
policy: 'server_orchestrated_generate_v1',
|
| 1001 |
+
endpoint: AGENT_ENDPOINTS.create_image_request,
|
| 1002 |
+
client_contract: 'intent_only',
|
| 1003 |
+
transport_selection: 'server_owned',
|
| 1004 |
+
result_mode: 'job_polling',
|
| 1005 |
+
hidden_controls: ['transport', 'route_mode', 'client_endpoint_selection'],
|
| 1006 |
+
diagnostics: {
|
| 1007 |
+
job_result: AGENT_ENDPOINTS.job_result,
|
| 1008 |
+
request_lookup: AGENT_ENDPOINTS.agent_request_diagnostics_lookup
|
| 1009 |
+
},
|
| 1010 |
+
current_guidance:
|
| 1011 |
+
'Agent 客户端默认只提交生成意图到 /api/agent/image-requests;服务端负责选择内部执行路径、上游策略和轮询结果。显式 /api/images、Agent JSON 或 job endpoint 仅作为诊断/兼容入口。'
|
| 1012 |
+
},
|
| 1013 |
routing_rules: {
|
| 1014 |
high_resolution_edit: {
|
| 1015 |
when: ['operation=edit', 'max_edge>2048'],
|
|
|
|
| 1080 |
},
|
| 1081 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 1082 |
transport: 'agent_json',
|
| 1083 |
+
strength: 'explicit',
|
| 1084 |
action: {
|
| 1085 |
endpoint: AGENT_ENDPOINTS.generate,
|
| 1086 |
transport: 'agent_json',
|
| 1087 |
+
strength: 'explicit',
|
| 1088 |
requires_new_idempotency_key_on_retry: true,
|
| 1089 |
no_automatic_fallback: true
|
| 1090 |
},
|
| 1091 |
+
reason: 'Agent JSON generate remains available for compatibility and explicit diagnostics; ordinary generate clients should use orchestration.endpoint.'
|
| 1092 |
},
|
| 1093 |
page_sse_large_generate: {
|
| 1094 |
when: ['operation=generate', 'max_edge>2048', 'single_request=true'],
|
|
|
|
| 1099 |
},
|
| 1100 |
endpoint: '/api/images',
|
| 1101 |
transport: 'page_sse',
|
| 1102 |
+
strength: 'explicit',
|
| 1103 |
action: {
|
| 1104 |
endpoint: '/api/images',
|
| 1105 |
transport: 'page_sse',
|
| 1106 |
+
strength: 'explicit',
|
| 1107 |
fallback_endpoint: AGENT_ENDPOINTS.generate,
|
| 1108 |
fallback_mode: 'manual_after_diagnosis',
|
| 1109 |
requires_new_idempotency_key_on_retry: true,
|
| 1110 |
no_automatic_fallback: true
|
| 1111 |
},
|
| 1112 |
+
reason: 'Page form-data SSE is available for explicit page-workbench or diagnostic large generate runs; ordinary generate clients should use orchestration.endpoint.'
|
| 1113 |
},
|
| 1114 |
retry_recovery: {
|
| 1115 |
reuse_failed_idempotency_key: false,
|
|
|
|
| 1128 |
endpoints: { ...AGENT_JOB_ENDPOINTS },
|
| 1129 |
states: AGENT_JOB_STATES,
|
| 1130 |
current_guidance:
|
| 1131 |
+
'Agent 客户端默认使用 orchestration.endpoint;直接创建 job 是兼容和诊断入口。当前执行模型为同实例后台任务,不是跨实例持久队列。'
|
| 1132 |
},
|
| 1133 |
supported: {
|
| 1134 |
models: AGENT_MODELS,
|
src/lib/agent-api-paths.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
export const AGENT_ENDPOINTS = Object.freeze({
|
| 2 |
capabilities: '/api/agent/capabilities',
|
| 3 |
openapi: '/api/agent/openapi.json',
|
|
|
|
| 4 |
generate: '/api/agent/images/generate',
|
| 5 |
edit: '/api/agent/images/edit',
|
| 6 |
create_generate_job: '/api/agent/jobs/images/generate',
|
|
|
|
| 1 |
export const AGENT_ENDPOINTS = Object.freeze({
|
| 2 |
capabilities: '/api/agent/capabilities',
|
| 3 |
openapi: '/api/agent/openapi.json',
|
| 4 |
+
create_image_request: '/api/agent/image-requests',
|
| 5 |
generate: '/api/agent/images/generate',
|
| 6 |
edit: '/api/agent/images/edit',
|
| 7 |
create_generate_job: '/api/agent/jobs/images/generate',
|
src/lib/agent-file-utils.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import {
|
|
|
|
| 2 |
discardArtifactFiles,
|
| 3 |
discardMovedFile,
|
| 4 |
moveArtifactFilesForDeletion,
|
|
@@ -14,11 +15,12 @@ import os from 'node:os';
|
|
| 14 |
import path from 'node:path';
|
| 15 |
import { describe, it } from 'node:test';
|
| 16 |
|
|
|
|
|
|
|
| 17 |
describe('readImageDimensions', () => {
|
| 18 |
it('reads PNG dimensions without external image libraries', () => {
|
| 19 |
const buffer = Buffer.alloc(24);
|
| 20 |
-
|
| 21 |
-
buffer.write('PNG', 1, 'ascii');
|
| 22 |
buffer.write('IHDR', 12, 'ascii');
|
| 23 |
buffer.writeUInt32BE(64, 16);
|
| 24 |
buffer.writeUInt32BE(32, 20);
|
|
@@ -31,6 +33,24 @@ describe('readImageDimensions', () => {
|
|
| 31 |
});
|
| 32 |
});
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
describe('writeFileAtomic', () => {
|
| 35 |
it('removes temporary files when the final rename fails', async () => {
|
| 36 |
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-atomic-'));
|
|
|
|
| 1 |
import {
|
| 2 |
+
detectImageFormat,
|
| 3 |
discardArtifactFiles,
|
| 4 |
discardMovedFile,
|
| 5 |
moveArtifactFilesForDeletion,
|
|
|
|
| 15 |
import path from 'node:path';
|
| 16 |
import { describe, it } from 'node:test';
|
| 17 |
|
| 18 |
+
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
| 19 |
+
|
| 20 |
describe('readImageDimensions', () => {
|
| 21 |
it('reads PNG dimensions without external image libraries', () => {
|
| 22 |
const buffer = Buffer.alloc(24);
|
| 23 |
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0);
|
|
|
|
| 24 |
buffer.write('IHDR', 12, 'ascii');
|
| 25 |
buffer.writeUInt32BE(64, 16);
|
| 26 |
buffer.writeUInt32BE(32, 20);
|
|
|
|
| 33 |
});
|
| 34 |
});
|
| 35 |
|
| 36 |
+
describe('detectImageFormat', () => {
|
| 37 |
+
it('uses image bytes instead of requested output format', () => {
|
| 38 |
+
const buffer = Buffer.from(PNG_BASE64, 'base64');
|
| 39 |
+
|
| 40 |
+
assert.deepEqual(detectImageFormat(buffer, 'webp'), {
|
| 41 |
+
outputFormat: 'png',
|
| 42 |
+
mimeType: 'image/png'
|
| 43 |
+
});
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it('falls back to the requested format for unknown bytes', () => {
|
| 47 |
+
assert.deepEqual(detectImageFormat(Buffer.from('not an image'), 'webp'), {
|
| 48 |
+
outputFormat: 'webp',
|
| 49 |
+
mimeType: 'image/webp'
|
| 50 |
+
});
|
| 51 |
+
});
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
describe('writeFileAtomic', () => {
|
| 55 |
it('removes temporary files when the final rename fails', async () => {
|
| 56 |
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-atomic-'));
|
src/lib/agent-file-utils.ts
CHANGED
|
@@ -9,12 +9,30 @@ export type ImageDimensions = {
|
|
| 9 |
height: number | null;
|
| 10 |
};
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export function mimeTypeForOutputFormat(outputFormat: string): string {
|
| 13 |
if (outputFormat === 'jpeg' || outputFormat === 'jpg') return 'image/jpeg';
|
| 14 |
if (outputFormat === 'webp') return 'image/webp';
|
| 15 |
return 'image/png';
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
export async function writeFileAtomic(filepath: string, buffer: Buffer): Promise<void> {
|
| 19 |
await fs.mkdir(path.dirname(filepath), { recursive: true });
|
| 20 |
const tmpPath = `${filepath}.tmp-${crypto.randomUUID()}`;
|
|
@@ -124,12 +142,7 @@ export function readImageDimensions(buffer: Buffer): ImageDimensions {
|
|
| 124 |
}
|
| 125 |
|
| 126 |
function readPngDimensions(buffer: Buffer): ImageDimensions {
|
| 127 |
-
if (
|
| 128 |
-
buffer.length >= 24 &&
|
| 129 |
-
buffer[0] === 0x89 &&
|
| 130 |
-
buffer.toString('ascii', 1, 4) === 'PNG' &&
|
| 131 |
-
buffer.toString('ascii', 12, 16) === 'IHDR'
|
| 132 |
-
) {
|
| 133 |
return {
|
| 134 |
width: buffer.readUInt32BE(16),
|
| 135 |
height: buffer.readUInt32BE(20)
|
|
@@ -139,7 +152,7 @@ function readPngDimensions(buffer: Buffer): ImageDimensions {
|
|
| 139 |
}
|
| 140 |
|
| 141 |
function readJpegDimensions(buffer: Buffer): ImageDimensions {
|
| 142 |
-
if (
|
| 143 |
return { width: null, height: null };
|
| 144 |
}
|
| 145 |
let offset = 2;
|
|
@@ -163,7 +176,7 @@ function readJpegDimensions(buffer: Buffer): ImageDimensions {
|
|
| 163 |
}
|
| 164 |
|
| 165 |
function readWebpDimensions(buffer: Buffer): ImageDimensions {
|
| 166 |
-
if (
|
| 167 |
return { width: null, height: null };
|
| 168 |
}
|
| 169 |
const chunk = buffer.toString('ascii', 12, 16);
|
|
@@ -191,3 +204,25 @@ function readWebpDimensions(buffer: Buffer): ImageDimensions {
|
|
| 191 |
}
|
| 192 |
return { width: null, height: null };
|
| 193 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
height: number | null;
|
| 10 |
};
|
| 11 |
|
| 12 |
+
export type DetectedImageFormat = {
|
| 13 |
+
outputFormat: 'png' | 'jpeg' | 'webp';
|
| 14 |
+
mimeType: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
type ImageFormatFallback = DetectedImageFormat['outputFormat'] | 'jpg';
|
| 18 |
+
|
| 19 |
export function mimeTypeForOutputFormat(outputFormat: string): string {
|
| 20 |
if (outputFormat === 'jpeg' || outputFormat === 'jpg') return 'image/jpeg';
|
| 21 |
if (outputFormat === 'webp') return 'image/webp';
|
| 22 |
return 'image/png';
|
| 23 |
}
|
| 24 |
|
| 25 |
+
export function detectImageFormat(buffer: Buffer, fallbackOutputFormat: ImageFormatFallback): DetectedImageFormat {
|
| 26 |
+
if (isPng(buffer)) return { outputFormat: 'png', mimeType: 'image/png' };
|
| 27 |
+
if (isJpeg(buffer)) return { outputFormat: 'jpeg', mimeType: 'image/jpeg' };
|
| 28 |
+
if (isWebp(buffer)) return { outputFormat: 'webp', mimeType: 'image/webp' };
|
| 29 |
+
const outputFormat = fallbackOutputFormat === 'jpg' ? 'jpeg' : fallbackOutputFormat;
|
| 30 |
+
return {
|
| 31 |
+
outputFormat,
|
| 32 |
+
mimeType: mimeTypeForOutputFormat(fallbackOutputFormat)
|
| 33 |
+
};
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
export async function writeFileAtomic(filepath: string, buffer: Buffer): Promise<void> {
|
| 37 |
await fs.mkdir(path.dirname(filepath), { recursive: true });
|
| 38 |
const tmpPath = `${filepath}.tmp-${crypto.randomUUID()}`;
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
function readPngDimensions(buffer: Buffer): ImageDimensions {
|
| 145 |
+
if (buffer.length >= 24 && isPng(buffer) && buffer.toString('ascii', 12, 16) === 'IHDR') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return {
|
| 147 |
width: buffer.readUInt32BE(16),
|
| 148 |
height: buffer.readUInt32BE(20)
|
|
|
|
| 152 |
}
|
| 153 |
|
| 154 |
function readJpegDimensions(buffer: Buffer): ImageDimensions {
|
| 155 |
+
if (!isJpeg(buffer)) {
|
| 156 |
return { width: null, height: null };
|
| 157 |
}
|
| 158 |
let offset = 2;
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
function readWebpDimensions(buffer: Buffer): ImageDimensions {
|
| 179 |
+
if (!isWebp(buffer) || buffer.length < 30) {
|
| 180 |
return { width: null, height: null };
|
| 181 |
}
|
| 182 |
const chunk = buffer.toString('ascii', 12, 16);
|
|
|
|
| 204 |
}
|
| 205 |
return { width: null, height: null };
|
| 206 |
}
|
| 207 |
+
|
| 208 |
+
function isPng(buffer: Buffer): boolean {
|
| 209 |
+
return (
|
| 210 |
+
buffer.length >= 8 &&
|
| 211 |
+
buffer[0] === 0x89 &&
|
| 212 |
+
buffer[1] === 0x50 &&
|
| 213 |
+
buffer[2] === 0x4e &&
|
| 214 |
+
buffer[3] === 0x47 &&
|
| 215 |
+
buffer[4] === 0x0d &&
|
| 216 |
+
buffer[5] === 0x0a &&
|
| 217 |
+
buffer[6] === 0x1a &&
|
| 218 |
+
buffer[7] === 0x0a
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function isJpeg(buffer: Buffer): boolean {
|
| 223 |
+
return buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function isWebp(buffer: Buffer): boolean {
|
| 227 |
+
return buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP';
|
| 228 |
+
}
|
src/lib/agent-generate-job-route.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildAgentJobStatusResponse, startAgentGenerateJob } from './agent-job-service';
|
| 2 |
+
import {
|
| 3 |
+
buildGenerateRequestHash,
|
| 4 |
+
parseAgentGenerateRequest,
|
| 5 |
+
prepareAgentGenerate,
|
| 6 |
+
resolveExistingAgentRequest,
|
| 7 |
+
readIdempotencyKey
|
| 8 |
+
} from './agent-image-service';
|
| 9 |
+
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from './agent-api-contracts';
|
| 10 |
+
import { AgentApiError, agentErrorResponse, normalizeAgentError } from './api-error-response';
|
| 11 |
+
import { assertAgentAuthorized } from './agent-auth';
|
| 12 |
+
import { ensureAgentStateStoreReady } from './agent-state-runtime';
|
| 13 |
+
import { createRequestId, type BeginAgentRequestResult } from './agent-state-store';
|
| 14 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 15 |
+
|
| 16 |
+
export async function createAgentGenerateJobResponse(
|
| 17 |
+
request: NextRequest,
|
| 18 |
+
options: { transportEndpoint: string }
|
| 19 |
+
): Promise<NextResponse> {
|
| 20 |
+
let requestId = createRequestId();
|
| 21 |
+
try {
|
| 22 |
+
assertAgentAuthorized(request.headers);
|
| 23 |
+
const imageRequest = await parseAgentGenerateRequest(request);
|
| 24 |
+
const idempotencyKey = readIdempotencyKey(request.headers);
|
| 25 |
+
const requestHash = buildGenerateRequestHash(imageRequest);
|
| 26 |
+
const store = await ensureAgentStateStoreReady();
|
| 27 |
+
const existingResponse = jobBeginResultResponse(
|
| 28 |
+
resolveExistingAgentRequest(await store.getRequestByIdempotencyKey(idempotencyKey), requestHash)
|
| 29 |
+
);
|
| 30 |
+
if (existingResponse) return existingResponse;
|
| 31 |
+
const preparation = prepareAgentGenerate(imageRequest, request.headers);
|
| 32 |
+
const leaseMs = readAgentLeaseMs(process.env);
|
| 33 |
+
const beginResult = await store.beginRequest({
|
| 34 |
+
idempotencyKey,
|
| 35 |
+
requestHash,
|
| 36 |
+
mode: 'generate',
|
| 37 |
+
requestJson: imageRequest,
|
| 38 |
+
leaseMs,
|
| 39 |
+
ttlSeconds: readAgentRequestTtlSeconds(process.env)
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
const storedResponse = jobBeginResultResponse(beginResult);
|
| 43 |
+
if (storedResponse) return storedResponse;
|
| 44 |
+
|
| 45 |
+
requestId = beginResult.record.requestId;
|
| 46 |
+
startAgentGenerateJob({
|
| 47 |
+
store,
|
| 48 |
+
request: imageRequest,
|
| 49 |
+
headers: new Headers(request.headers),
|
| 50 |
+
requestId,
|
| 51 |
+
idempotencyKey,
|
| 52 |
+
leaseMs,
|
| 53 |
+
preparation,
|
| 54 |
+
transportEndpoint: options.transportEndpoint
|
| 55 |
+
});
|
| 56 |
+
return runningJobResponse(beginResult.record, 5, false);
|
| 57 |
+
} catch (error) {
|
| 58 |
+
return agentErrorResponse(normalizeAgentError(error), requestId);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function createIdempotencyConflictError(): AgentApiError {
|
| 63 |
+
return new AgentApiError({
|
| 64 |
+
code: 'idempotency_conflict',
|
| 65 |
+
message: 'Idempotency-Key 已被不同请求正文使用。',
|
| 66 |
+
status: 409,
|
| 67 |
+
retryable: false
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function jobBeginResultResponse(beginResult: BeginAgentRequestResult | undefined): NextResponse | undefined {
|
| 72 |
+
if (!beginResult || beginResult.type === 'acquired') return undefined;
|
| 73 |
+
if (beginResult.type === 'conflict') {
|
| 74 |
+
throw createIdempotencyConflictError();
|
| 75 |
+
}
|
| 76 |
+
if (beginResult.type === 'replay' || beginResult.type === 'failed') {
|
| 77 |
+
return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
|
| 78 |
+
status: 202,
|
| 79 |
+
headers: {
|
| 80 |
+
'X-Idempotent-Replay': 'true',
|
| 81 |
+
'X-Request-Id': beginResult.record.requestId
|
| 82 |
+
}
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
return runningJobResponse(beginResult.record, beginResult.retryAfterSeconds, true);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function runningJobResponse(
|
| 89 |
+
record: Parameters<typeof buildAgentJobStatusResponse>[0],
|
| 90 |
+
retryAfterSeconds: number,
|
| 91 |
+
replay: boolean
|
| 92 |
+
) {
|
| 93 |
+
return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
|
| 94 |
+
status: 202,
|
| 95 |
+
headers: {
|
| 96 |
+
'Retry-After': String(retryAfterSeconds),
|
| 97 |
+
'X-Request-Id': record.requestId,
|
| 98 |
+
...(replay ? { 'X-Idempotent-Replay': 'true' } : {})
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
}
|
src/lib/agent-image-service.ts
CHANGED
|
@@ -1075,7 +1075,7 @@ async function persistOpenAiImages(options: {
|
|
| 1075 |
filepath: persistedImage.filepath,
|
| 1076 |
contentUrl,
|
| 1077 |
metadataUrl,
|
| 1078 |
-
outputFormat:
|
| 1079 |
mimeType: persistedImage.mimeType,
|
| 1080 |
sizeBytes: persistedImage.sizeBytes,
|
| 1081 |
width: persistedImage.width,
|
|
|
|
| 1075 |
filepath: persistedImage.filepath,
|
| 1076 |
contentUrl,
|
| 1077 |
metadataUrl,
|
| 1078 |
+
outputFormat: persistedImage.outputFormat,
|
| 1079 |
mimeType: persistedImage.mimeType,
|
| 1080 |
sizeBytes: persistedImage.sizeBytes,
|
| 1081 |
width: persistedImage.width,
|
src/lib/agent-job-service.ts
CHANGED
|
@@ -97,6 +97,7 @@ export function startAgentGenerateJob(options: {
|
|
| 97 |
idempotencyKey: string;
|
| 98 |
leaseMs: number;
|
| 99 |
preparation?: AgentGeneratePreparation;
|
|
|
|
| 100 |
}): void {
|
| 101 |
void runAgentGenerateJob(options).catch((error) => {
|
| 102 |
appLogger.error('Agent generate job 后台执行失败。', error);
|
|
@@ -116,6 +117,7 @@ async function runAgentGenerateJob(options: {
|
|
| 116 |
idempotencyKey: string;
|
| 117 |
leaseMs: number;
|
| 118 |
preparation?: AgentGeneratePreparation;
|
|
|
|
| 119 |
}): Promise<void> {
|
| 120 |
let heartbeat: { stop: () => void } | undefined;
|
| 121 |
try {
|
|
@@ -129,7 +131,7 @@ async function runAgentGenerateJob(options: {
|
|
| 129 |
preparation: options.preparation,
|
| 130 |
transport: {
|
| 131 |
transport: 'agent_job_polling',
|
| 132 |
-
endpoint: AGENT_ENDPOINTS.create_generate_job,
|
| 133 |
route_mode: 'job'
|
| 134 |
}
|
| 135 |
});
|
|
|
|
| 97 |
idempotencyKey: string;
|
| 98 |
leaseMs: number;
|
| 99 |
preparation?: AgentGeneratePreparation;
|
| 100 |
+
transportEndpoint?: string;
|
| 101 |
}): void {
|
| 102 |
void runAgentGenerateJob(options).catch((error) => {
|
| 103 |
appLogger.error('Agent generate job 后台执行失败。', error);
|
|
|
|
| 117 |
idempotencyKey: string;
|
| 118 |
leaseMs: number;
|
| 119 |
preparation?: AgentGeneratePreparation;
|
| 120 |
+
transportEndpoint?: string;
|
| 121 |
}): Promise<void> {
|
| 122 |
let heartbeat: { stop: () => void } | undefined;
|
| 123 |
try {
|
|
|
|
| 131 |
preparation: options.preparation,
|
| 132 |
transport: {
|
| 133 |
transport: 'agent_job_polling',
|
| 134 |
+
endpoint: options.transportEndpoint ?? AGENT_ENDPOINTS.create_generate_job,
|
| 135 |
route_mode: 'job'
|
| 136 |
}
|
| 137 |
});
|
src/lib/agent-openapi.ts
CHANGED
|
@@ -145,6 +145,31 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
[AGENT_ENDPOINTS.create_generate_job]: {
|
| 149 |
post: {
|
| 150 |
summary: '创建 Agent 图片生成 job',
|
|
@@ -408,6 +433,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 408 |
'limits',
|
| 409 |
'model_limits',
|
| 410 |
'agent_streaming',
|
|
|
|
| 411 |
'routing_rules',
|
| 412 |
'agent_jobs',
|
| 413 |
'supported',
|
|
@@ -624,6 +650,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 624 |
},
|
| 625 |
model_limits: { $ref: '#/components/schemas/AgentModelLimits' },
|
| 626 |
agent_streaming: { $ref: '#/components/schemas/AgentStreamingCapabilities' },
|
|
|
|
| 627 |
routing_rules: { $ref: '#/components/schemas/AgentRoutingRules' },
|
| 628 |
agent_jobs: { $ref: '#/components/schemas/AgentJobCapabilities' },
|
| 629 |
supported: {
|
|
@@ -1045,7 +1072,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1045 |
},
|
| 1046 |
agent_usage: {
|
| 1047 |
type: 'string',
|
| 1048 |
-
enum: ['
|
| 1049 |
}
|
| 1050 |
}
|
| 1051 |
}
|
|
@@ -1090,7 +1117,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1090 |
},
|
| 1091 |
strength: {
|
| 1092 |
type: 'string',
|
| 1093 |
-
enum: ['default', 'recommended'] satisfies AgentRoutingStrength[]
|
| 1094 |
},
|
| 1095 |
action: { $ref: '#/components/schemas/AgentRoutingAction' },
|
| 1096 |
reason: { type: 'string' }
|
|
@@ -1137,7 +1164,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1137 |
},
|
| 1138 |
strength: {
|
| 1139 |
type: 'string',
|
| 1140 |
-
enum: ['default', 'recommended'] satisfies AgentRoutingStrength[]
|
| 1141 |
},
|
| 1142 |
fallback_endpoint: { type: 'string' },
|
| 1143 |
fallback_mode: {
|
|
@@ -1178,6 +1205,46 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1178 |
current_guidance: { type: 'string' }
|
| 1179 |
}
|
| 1180 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
GenerateRequest: {
|
| 1182 |
type: 'object',
|
| 1183 |
required: ['prompt'],
|
|
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
},
|
| 148 |
+
[AGENT_ENDPOINTS.create_image_request]: {
|
| 149 |
+
post: {
|
| 150 |
+
summary: '提交服务端编排的 Agent 图片生成意图',
|
| 151 |
+
security: agentSecurity,
|
| 152 |
+
parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }],
|
| 153 |
+
requestBody: {
|
| 154 |
+
required: true,
|
| 155 |
+
...jsonContent('#/components/schemas/GenerateRequest')
|
| 156 |
+
},
|
| 157 |
+
responses: {
|
| 158 |
+
'200': jsonContent('#/components/schemas/AgentJobStatusResponse'),
|
| 159 |
+
'202': {
|
| 160 |
+
...jsonContent('#/components/schemas/AgentJobStatusResponse'),
|
| 161 |
+
headers: {
|
| 162 |
+
'Retry-After': { schema: { type: 'integer', minimum: 1 } }
|
| 163 |
+
}
|
| 164 |
+
},
|
| 165 |
+
'400': jsonContent('#/components/schemas/AgentError'),
|
| 166 |
+
'409': jsonContent('#/components/schemas/AgentError'),
|
| 167 |
+
...commonAgentErrors,
|
| 168 |
+
'422': jsonContent('#/components/schemas/AgentError'),
|
| 169 |
+
'500': jsonContent('#/components/schemas/AgentError')
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
},
|
| 173 |
[AGENT_ENDPOINTS.create_generate_job]: {
|
| 174 |
post: {
|
| 175 |
summary: '创建 Agent 图片生成 job',
|
|
|
|
| 433 |
'limits',
|
| 434 |
'model_limits',
|
| 435 |
'agent_streaming',
|
| 436 |
+
'orchestration',
|
| 437 |
'routing_rules',
|
| 438 |
'agent_jobs',
|
| 439 |
'supported',
|
|
|
|
| 650 |
},
|
| 651 |
model_limits: { $ref: '#/components/schemas/AgentModelLimits' },
|
| 652 |
agent_streaming: { $ref: '#/components/schemas/AgentStreamingCapabilities' },
|
| 653 |
+
orchestration: { $ref: '#/components/schemas/AgentOrchestrationCapabilities' },
|
| 654 |
routing_rules: { $ref: '#/components/schemas/AgentRoutingRules' },
|
| 655 |
agent_jobs: { $ref: '#/components/schemas/AgentJobCapabilities' },
|
| 656 |
supported: {
|
|
|
|
| 1072 |
},
|
| 1073 |
agent_usage: {
|
| 1074 |
type: 'string',
|
| 1075 |
+
enum: ['explicit_for_generate_recommended_for_high_resolution_edit_and_complex_batch']
|
| 1076 |
}
|
| 1077 |
}
|
| 1078 |
}
|
|
|
|
| 1117 |
},
|
| 1118 |
strength: {
|
| 1119 |
type: 'string',
|
| 1120 |
+
enum: ['default', 'recommended', 'explicit'] satisfies AgentRoutingStrength[]
|
| 1121 |
},
|
| 1122 |
action: { $ref: '#/components/schemas/AgentRoutingAction' },
|
| 1123 |
reason: { type: 'string' }
|
|
|
|
| 1164 |
},
|
| 1165 |
strength: {
|
| 1166 |
type: 'string',
|
| 1167 |
+
enum: ['default', 'recommended', 'explicit'] satisfies AgentRoutingStrength[]
|
| 1168 |
},
|
| 1169 |
fallback_endpoint: { type: 'string' },
|
| 1170 |
fallback_mode: {
|
|
|
|
| 1205 |
current_guidance: { type: 'string' }
|
| 1206 |
}
|
| 1207 |
},
|
| 1208 |
+
AgentOrchestrationCapabilities: {
|
| 1209 |
+
type: 'object',
|
| 1210 |
+
required: [
|
| 1211 |
+
'supported',
|
| 1212 |
+
'policy',
|
| 1213 |
+
'endpoint',
|
| 1214 |
+
'client_contract',
|
| 1215 |
+
'transport_selection',
|
| 1216 |
+
'result_mode',
|
| 1217 |
+
'hidden_controls',
|
| 1218 |
+
'diagnostics',
|
| 1219 |
+
'current_guidance'
|
| 1220 |
+
],
|
| 1221 |
+
properties: {
|
| 1222 |
+
supported: { type: 'boolean', const: true },
|
| 1223 |
+
policy: {
|
| 1224 |
+
type: 'string',
|
| 1225 |
+
enum: ['server_orchestrated_generate_v1']
|
| 1226 |
+
},
|
| 1227 |
+
endpoint: { type: 'string', const: AGENT_ENDPOINTS.create_image_request },
|
| 1228 |
+
client_contract: { type: 'string', enum: ['intent_only'] },
|
| 1229 |
+
transport_selection: { type: 'string', enum: ['server_owned'] },
|
| 1230 |
+
result_mode: { type: 'string', enum: ['job_polling'] },
|
| 1231 |
+
hidden_controls: { type: 'array', items: { type: 'string' } },
|
| 1232 |
+
diagnostics: {
|
| 1233 |
+
type: 'object',
|
| 1234 |
+
required: ['job_result', 'request_lookup'],
|
| 1235 |
+
properties: {
|
| 1236 |
+
job_result: { type: 'string', const: AGENT_ENDPOINTS.job_result },
|
| 1237 |
+
request_lookup: {
|
| 1238 |
+
type: 'string',
|
| 1239 |
+
const: AGENT_ENDPOINTS.agent_request_diagnostics_lookup
|
| 1240 |
+
}
|
| 1241 |
+
},
|
| 1242 |
+
additionalProperties: false
|
| 1243 |
+
},
|
| 1244 |
+
current_guidance: { type: 'string' }
|
| 1245 |
+
},
|
| 1246 |
+
additionalProperties: false
|
| 1247 |
+
},
|
| 1248 |
GenerateRequest: {
|
| 1249 |
type: 'object',
|
| 1250 |
required: ['prompt'],
|
src/lib/image-service.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import {
|
| 2 |
import {
|
| 3 |
createImageResult,
|
| 4 |
type StorageMode,
|
|
@@ -92,21 +92,22 @@ export async function persistOpenAiImages(options: {
|
|
| 92 |
throw new MissingOpenAiImageDataError(index);
|
| 93 |
}
|
| 94 |
const buffer = Buffer.from(b64Json, 'base64');
|
| 95 |
-
const
|
|
|
|
| 96 |
const filepath = path.join(outputDir, filename);
|
| 97 |
if (options.storageMode === 'fs') {
|
| 98 |
await writeFileAtomic(filepath, buffer);
|
| 99 |
}
|
| 100 |
const dimensions = readImageDimensions(buffer);
|
| 101 |
-
const legacyResult = createImageResult(filename, b64Json,
|
| 102 |
persisted.push({
|
| 103 |
filename,
|
| 104 |
b64Json,
|
| 105 |
...(options.includeBase64 ? { responseJson: b64Json } : {}),
|
| 106 |
...(legacyResult.path ? { path: legacyResult.path } : {}),
|
| 107 |
-
outputFormat:
|
| 108 |
filepath,
|
| 109 |
-
mimeType:
|
| 110 |
sizeBytes: buffer.byteLength,
|
| 111 |
width: dimensions.width,
|
| 112 |
height: dimensions.height
|
|
|
|
| 1 |
+
import { detectImageFormat, readImageDimensions, writeFileAtomic } from './agent-file-utils';
|
| 2 |
import {
|
| 3 |
createImageResult,
|
| 4 |
type StorageMode,
|
|
|
|
| 92 |
throw new MissingOpenAiImageDataError(index);
|
| 93 |
}
|
| 94 |
const buffer = Buffer.from(b64Json, 'base64');
|
| 95 |
+
const detectedFormat = detectImageFormat(buffer, options.outputFormat);
|
| 96 |
+
const filename = createImageFilename(batchId, index, detectedFormat.outputFormat);
|
| 97 |
const filepath = path.join(outputDir, filename);
|
| 98 |
if (options.storageMode === 'fs') {
|
| 99 |
await writeFileAtomic(filepath, buffer);
|
| 100 |
}
|
| 101 |
const dimensions = readImageDimensions(buffer);
|
| 102 |
+
const legacyResult = createImageResult(filename, b64Json, detectedFormat.outputFormat, options.storageMode);
|
| 103 |
persisted.push({
|
| 104 |
filename,
|
| 105 |
b64Json,
|
| 106 |
...(options.includeBase64 ? { responseJson: b64Json } : {}),
|
| 107 |
...(legacyResult.path ? { path: legacyResult.path } : {}),
|
| 108 |
+
outputFormat: detectedFormat.outputFormat,
|
| 109 |
filepath,
|
| 110 |
+
mimeType: detectedFormat.mimeType,
|
| 111 |
sizeBytes: buffer.byteLength,
|
| 112 |
width: dimensions.width,
|
| 113 |
height: dimensions.height
|