misonL commited on
Commit
6dd78ad
·
verified ·
1 Parent(s): 6825c0c

Deploy 3203c24 to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@3203c24f5434c569bbfec2fb425430d3e45fcbc3

public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "a623efcc28ea071e4a51a2753173829a0ee01002",
4
- "created_at": "2026-06-21T06:16:41.993Z",
5
- "deploy_id": "83a9c54d-c4df-43fc-8045-97fe4feadb16"
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 page SSE routing guidance for high-resolution generate dry-runs', () => {
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/images');
592
- assert.equal(body.routing_guidance.recommended_endpoint, '/api/images');
593
- assert.equal(body.routing_guidance.transport, 'page_sse');
594
- assert.equal(body.routing_guidance.fallback_endpoint, '/api/agent/images/generate');
595
- assert.equal(body.routing_guidance.fallback_mode, 'manual_after_diagnosis');
596
- assert.equal(body.routing_guidance.reason.includes('max_edge>2048'), true);
 
 
 
 
 
 
 
 
 
 
 
 
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 a billable page SSE generate failure', async () => {
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('uses Agent JSON for billable large generate requests when streaming strategy is off', async () => {
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/images/generate');
3085
- assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/images/generate');
 
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、幂等键、路由选择和产物 URL。
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` 检查请求、路由鉴权;只有用户明确允许真实计费时才加 `--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,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` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSEResponses 路径返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式切换到 Agent JSON 或 job,不自动回退。
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
- - 普通小图单次文生图使用 `/api/agent/images/generate``max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退
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/images/generate`,请求体为 JSON。该 Agent 点对外始终返回最终 `AgentImageResponse` JSON;如 capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend``stream_mode``streaming_strategy`、`partial_images` 控制内部上游 SSE 消费。不要把 `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` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复、显式页面参数或明确诊断后切换
59
- 13. 读取 `agent_jobs`。job 路径显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。
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` 时,显式 job 路径可使用:
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 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。默认大图单次文生图已经切到页面端 `/api/images` SSE,job 不是默认路径。
105
 
106
  ## 可用脚本
107
 
108
  以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
109
 
110
- - `scripts/generate-image.mjs`:JSON 文生图调用。默认 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`,用于在真实计费前检查当前请求应走 Agent JSON页面 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,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
- 生成脚本会对 `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE如果 capabilities 未声明 `agent_streaming.page_sse.supported=true`,脚本会显式失败,不会静默降级到 Agent JSON如果页面流式失败,脚本会返回结构化失败结果,先诊断决定是否用 `--agent` 或 `--job` 重新执行,不会动发起第二次请求。单张 generate 支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 和 `--force-web`;这些字段会进入页面 SSE form-data,dry-run 会显示 page SSE 路由,真实请求会发送 `responsesModel`、`thinking`、`promptOptimization` 和 `force_web`。默认 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`。显式传 `--streaming-strategy off` 或 `--stream-mode non_stream` 时,大图 generate 保持 Agent JSON 非流式路径;默认 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
 
 
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` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSEResponses 路径或服务端编排入口返回 `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` 才会按 capabilities 路由规则调用 `/api/agent/images/generate`、`/api/agent/images/edit`、`/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,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
- `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE如果显式传 `--streaming-strategy off`,即使是大图也保持 `/api/agent/images/generate` 非流式 JSON 路径,用于诊断对照。
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`、断流或没有选中渠道;这时应先诊断,再显式切换到 Agent JSON 或 job,不自动回退。
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
- - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`
213
- - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE失败后先诊断再显式选择 `/api/agent/images/generate` 或 job 路径;不要把页面 SSE 的失败解释成自动回退到 Agent JSON 成功
 
 
 
 
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
- `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产默认优先 `routing_rules` 使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent edit 或 job 路径。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。
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 生成端点最终响应仍是 `AgentImageResponse`。
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": "agent_json",
358
- "endpoint": "/api/agent/images/generate",
359
- "route_mode": "agent",
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 JSON API | 是 | 通过 skill 脚本和 Agent 鉴权调用。 |
630
- | `POST /api/images` | 页面 form-data SSE API | 否 | 仅在大图、复杂 UI 批量、页面高级字段或路由规则要求时由 skill 显式选择。 |
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 (shouldUseJobPolling(capabilities, options.routeMode)) {
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}${AGENT_ENDPOINTS.create_generate_job}`, {
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
- { transport: 'agent_job_polling', endpoint: AGENT_ENDPOINTS.create_generate_job }
1193
  ),
1194
  null,
1195
  2
@@ -1240,7 +1266,23 @@ async function pollJobResult(job) {
1240
 
1241
  async function runContractCheck(capabilitiesValue) {
1242
  const checks = [];
1243
- const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 === 400 && result?.error?.code === 'idempotency_key_required') {
1253
- checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code });
1254
- } else {
1255
- console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
1256
  process.exit(1);
1257
  }
 
 
1258
 
1259
- if (supportsJobPolling(capabilitiesValue)) {
1260
- const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
1261
- method: 'POST',
1262
- headers: {
1263
- 'Content-Type': 'application/json',
1264
- ...authHeaders()
1265
- },
1266
- body: JSON.stringify(requestBody),
1267
- timeoutMs
1268
- });
1269
- if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') {
1270
- console.error(
1271
- JSON.stringify(
1272
- { ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result },
1273
- null,
1274
- 2
1275
- )
1276
- );
1277
- process.exit(1);
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
- console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/webp');
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 { buildAgentJobStatusResponse, startAgentGenerateJob } from '@/lib/agent-job-service';
2
- import {
3
- buildGenerateRequestHash,
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
- let requestId = createRequestId();
18
- try {
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":"a623efcc28ea071e4a51a2753173829a0ee01002","created_at":"2026-06-21T06:16:41.993Z","deploy_id":"83a9c54d-c4df-43fc-8045-97fe4feadb16"} as const;
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
- 'recommended_for_high_resolution_generate_edit_and_complex_batch'
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: 'default',
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, 'recommended');
 
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, /\/api\/images SSE/);
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: 'recommended_for_high_resolution_generate_edit_and_complex_batch';
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: 'recommended_for_high_resolution_generate_edit_and_complex_batch'
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: 'default',
1054
  action: {
1055
  endpoint: AGENT_ENDPOINTS.generate,
1056
  transport: 'agent_json',
1057
- strength: 'default',
1058
  requires_new_idempotency_key_on_retry: true,
1059
  no_automatic_fallback: true
1060
  },
1061
- reason: 'Agent JSON generate remains the stable contract and smoke path for normal single-image requests.'
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: 'recommended',
1073
  action: {
1074
  endpoint: '/api/images',
1075
  transport: 'page_sse',
1076
- strength: 'recommended',
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 keeps large generate requests observable; if the stream fails, diagnose first and choose any Agent JSON retry explicitly.'
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
- ' max_edge>2048 的单次文生图请求优先使用页面端 /api/images SSE如果页面流式失败,先诊断再显式选择 Agent JSON 或 job 路径,不自动回退job polling 仅用于显式 job 路径。当前执行模型为同实例后台任务,不是跨实例持久队列。'
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
- buffer[0] = 0x89;
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 (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
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 (buffer.length < 30 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') {
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: options.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: ['recommended_for_high_resolution_generate_edit_and_complex_batch']
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 { mimeTypeForOutputFormat, readImageDimensions, writeFileAtomic } from './agent-file-utils';
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 filename = createImageFilename(batchId, index, options.outputFormat);
 
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, options.outputFormat, options.storageMode);
102
  persisted.push({
103
  filename,
104
  b64Json,
105
  ...(options.includeBase64 ? { responseJson: b64Json } : {}),
106
  ...(legacyResult.path ? { path: legacyResult.path } : {}),
107
- outputFormat: options.outputFormat,
108
  filepath,
109
- mimeType: mimeTypeForOutputFormat(options.outputFormat),
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