diff --git a/.claude/skills/generate-image/SKILL.md b/.claude/skills/generate-image/SKILL.md index aed0276b9..25be1c70a 100644 --- a/.claude/skills/generate-image/SKILL.md +++ b/.claude/skills/generate-image/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-image -description: AI image generation via Gemini. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY. +description: AI image generation via Gemini, or Atlas Cloud with `-p atlas`. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY, or ATLASCLOUD_API_KEY when using `-p atlas`. --- # generate-image - AI Image Generation @@ -22,6 +22,7 @@ Note: this is a `codev` subcommand, not standalone. -r, --resolution Resolution: 1K, 2K, 4K (default: 1K) -a, --aspect Aspect ratio (default: 1:1) --ref Reference image (repeatable, max 14) +-p, --provider Provider: gemini (default) or atlas ``` ## Aspect ratios @@ -43,3 +44,23 @@ codev generate-image prompt.txt -o result.png # Prompt from .txt file - Prompt can be a `.txt` file path (auto-detected by extension) - Reference images must exist on disk - Requires `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment variable + +## Alternative provider: Atlas Cloud + +`-p atlas` generates through Atlas Cloud, which serves the same Nano Banana Pro +model over a submit-then-poll REST API. Useful when a Google AI Studio key is +not available. Requires `ATLASCLOUD_API_KEY`; the default provider is unchanged. + +```bash +codev generate-image "A sunset over mountains" -p atlas -a 16:9 +``` + +Measured differences on that path: + +- `-a/--aspect` works (`1:1` returns 1024x1024, `16:9` returns 1376x768). +- `-r/--resolution` has no equivalent field; a note is printed and the model's + default resolution comes back. +- `--ref` is not supported (text-to-image only) and exits with an error rather + than silently ignoring the images. +- The model returns JPEG, so the output file is named after its actual bytes + instead of writing JPEG into the default `output.png`. diff --git a/.codex/skills/generate-image/SKILL.md b/.codex/skills/generate-image/SKILL.md index aed0276b9..25be1c70a 100644 --- a/.codex/skills/generate-image/SKILL.md +++ b/.codex/skills/generate-image/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-image -description: AI image generation via Gemini. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY. +description: AI image generation via Gemini, or Atlas Cloud with `-p atlas`. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY, or ATLASCLOUD_API_KEY when using `-p atlas`. --- # generate-image - AI Image Generation @@ -22,6 +22,7 @@ Note: this is a `codev` subcommand, not standalone. -r, --resolution Resolution: 1K, 2K, 4K (default: 1K) -a, --aspect Aspect ratio (default: 1:1) --ref Reference image (repeatable, max 14) +-p, --provider Provider: gemini (default) or atlas ``` ## Aspect ratios @@ -43,3 +44,23 @@ codev generate-image prompt.txt -o result.png # Prompt from .txt file - Prompt can be a `.txt` file path (auto-detected by extension) - Reference images must exist on disk - Requires `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment variable + +## Alternative provider: Atlas Cloud + +`-p atlas` generates through Atlas Cloud, which serves the same Nano Banana Pro +model over a submit-then-poll REST API. Useful when a Google AI Studio key is +not available. Requires `ATLASCLOUD_API_KEY`; the default provider is unchanged. + +```bash +codev generate-image "A sunset over mountains" -p atlas -a 16:9 +``` + +Measured differences on that path: + +- `-a/--aspect` works (`1:1` returns 1024x1024, `16:9` returns 1376x768). +- `-r/--resolution` has no equivalent field; a note is printed and the model's + default resolution comes back. +- `--ref` is not supported (text-to-image only) and exits with an error rather + than silently ignoring the images. +- The model returns JPEG, so the output file is named after its actual bytes + instead of writing JPEG into the default `output.png`. diff --git a/codev-skeleton/.claude/skills/generate-image/SKILL.md b/codev-skeleton/.claude/skills/generate-image/SKILL.md index aed0276b9..25be1c70a 100644 --- a/codev-skeleton/.claude/skills/generate-image/SKILL.md +++ b/codev-skeleton/.claude/skills/generate-image/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-image -description: AI image generation via Gemini. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY. +description: AI image generation via Gemini, or Atlas Cloud with `-p atlas`. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY, or ATLASCLOUD_API_KEY when using `-p atlas`. --- # generate-image - AI Image Generation @@ -22,6 +22,7 @@ Note: this is a `codev` subcommand, not standalone. -r, --resolution Resolution: 1K, 2K, 4K (default: 1K) -a, --aspect Aspect ratio (default: 1:1) --ref Reference image (repeatable, max 14) +-p, --provider Provider: gemini (default) or atlas ``` ## Aspect ratios @@ -43,3 +44,23 @@ codev generate-image prompt.txt -o result.png # Prompt from .txt file - Prompt can be a `.txt` file path (auto-detected by extension) - Reference images must exist on disk - Requires `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment variable + +## Alternative provider: Atlas Cloud + +`-p atlas` generates through Atlas Cloud, which serves the same Nano Banana Pro +model over a submit-then-poll REST API. Useful when a Google AI Studio key is +not available. Requires `ATLASCLOUD_API_KEY`; the default provider is unchanged. + +```bash +codev generate-image "A sunset over mountains" -p atlas -a 16:9 +``` + +Measured differences on that path: + +- `-a/--aspect` works (`1:1` returns 1024x1024, `16:9` returns 1376x768). +- `-r/--resolution` has no equivalent field; a note is printed and the model's + default resolution comes back. +- `--ref` is not supported (text-to-image only) and exits with an error rather + than silently ignoring the images. +- The model returns JPEG, so the output file is named after its actual bytes + instead of writing JPEG into the default `output.png`. diff --git a/codev-skeleton/.codex/skills/generate-image/SKILL.md b/codev-skeleton/.codex/skills/generate-image/SKILL.md index aed0276b9..25be1c70a 100644 --- a/codev-skeleton/.codex/skills/generate-image/SKILL.md +++ b/codev-skeleton/.codex/skills/generate-image/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-image -description: AI image generation via Gemini. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY. +description: AI image generation via Gemini, or Atlas Cloud with `-p atlas`. Use when the user wants to generate, create, or make an image, or when you need to create visual assets like logos, diagrams, or illustrations. Requires GEMINI_API_KEY or GOOGLE_API_KEY, or ATLASCLOUD_API_KEY when using `-p atlas`. --- # generate-image - AI Image Generation @@ -22,6 +22,7 @@ Note: this is a `codev` subcommand, not standalone. -r, --resolution Resolution: 1K, 2K, 4K (default: 1K) -a, --aspect Aspect ratio (default: 1:1) --ref Reference image (repeatable, max 14) +-p, --provider Provider: gemini (default) or atlas ``` ## Aspect ratios @@ -43,3 +44,23 @@ codev generate-image prompt.txt -o result.png # Prompt from .txt file - Prompt can be a `.txt` file path (auto-detected by extension) - Reference images must exist on disk - Requires `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment variable + +## Alternative provider: Atlas Cloud + +`-p atlas` generates through Atlas Cloud, which serves the same Nano Banana Pro +model over a submit-then-poll REST API. Useful when a Google AI Studio key is +not available. Requires `ATLASCLOUD_API_KEY`; the default provider is unchanged. + +```bash +codev generate-image "A sunset over mountains" -p atlas -a 16:9 +``` + +Measured differences on that path: + +- `-a/--aspect` works (`1:1` returns 1024x1024, `16:9` returns 1376x768). +- `-r/--resolution` has no equivalent field; a note is printed and the model's + default resolution comes back. +- `--ref` is not supported (text-to-image only) and exits with an error rather + than silently ignoring the images. +- The model returns JPEG, so the output file is named after its actual bytes + instead of writing JPEG into the default `output.png`. diff --git a/codev/resources/cloud-instances.md b/codev/resources/cloud-instances.md index 427735b2c..751a270a9 100644 --- a/codev/resources/cloud-instances.md +++ b/codev/resources/cloud-instances.md @@ -212,6 +212,7 @@ Store in `~/.bashrc` or `~/.profile` on the instance: export ANTHROPIC_API_KEY="sk-ant-..." export OPENAI_API_KEY="sk-..." export GEMINI_API_KEY="..." +export ATLASCLOUD_API_KEY="..." # optional: codev generate-image -p atlas ``` ## Comparison Matrix diff --git a/codev/state/task-47B5_thread.md b/codev/state/task-47B5_thread.md new file mode 100644 index 000000000..0fbe8ee1b --- /dev/null +++ b/codev/state/task-47B5_thread.md @@ -0,0 +1,106 @@ +# task-47B5 — finishing external PR #1618 (Atlas Cloud image provider) + +## Context + +PR #1618 by @binyangzhu000-sudo (first-time contributor) adds Atlas Cloud as an +optional provider for `codev generate-image`. `maintainerCanModify` is true, so +the architect's 2026-09-04 review is being applied by pushing commits onto the +contributor's branch (`binyangzhu000-sudo:feat/atlascloud-image-provider`) +rather than sending them a list. Their commit and authorship are preserved — no +rebase, no squash, maintainer commits on top. + +## What the review asked for + +Five MUSTs (skill-doc sync, per-request timeouts, unknown-status fail-fast, +refuse unrecognized download bytes, fast + hardened tests) and two SHOULDs +(`typeof url === 'string'` guard, `ATLASCLOUD_API_KEY` in cloud-instances.md). + +## Decisions worth recording + +- **Poll ordering left alone.** The obvious way to make the polling tests fast + was to poll before the first sleep, which would also save users ~5s. Rejected: + the contributor measured this endpoint and I cannot (no Atlas key). An + immediate poll risks a 404 on a not-yet-visible prediction, and under the + repo's fail-fast rule that would be a hard failure on a path with no test + coverage against the real API. Instead the poll interval became a parameter + (`generateViaAtlas(..., pollIntervalMs = ATLAS_POLL_INTERVAL_MS)`), which + tests pass as 0. Nothing but tests passes it. +- **`atlasFetch` helper** wraps all three fetches with `AbortSignal.timeout` + and turns a `TimeoutError` into the same shaped error message as an HTTP + failure. The CDN download deliberately passes no init, so the Authorization + header cannot reach a third-party origin — a test asserts exactly that. +- **`outputs` retyped `unknown[]`** so the `typeof url === 'string'` guard is + load-bearing rather than decorative. +- **In-progress status allowlist** (`pending`/`queued`/`starting`/`processing`/ + `running`/`in_progress`); anything else, including a missing status, fails + immediately naming the status. +- **Thread file committed separately** from the review-fix commit so the + contributor's PR keeps a clean, reviewable diff. + +## CMAP round (codex + claude, on the diff) + +Both returned substantive findings. Verified each against the file rather than +taking the summary as ground truth; every accepted fix is now locked by a test +that fails when the fix is reverted (checked by mutation, not by assertion). + +Accepted: + +- **codex: the status allowlist was guessed.** It was. I fetched + https://www.atlascloud.ai/docs/en/predictions, which documents exactly + `processing`, `completed`, `failed`. Narrowed the set to `processing` and + cited the URL in the comment — the repo's "never guess field names" rule cuts + against my six invented in-progress words. +- **claude: `AbortSignal` protected the connect, not the body read.** The real + bug, and the same class the fix-up exists to close: `.json()` on an HTML error + page threw a raw `SyntaxError`, and an abort landing on `.arrayBuffer()` threw + a raw `TimeoutError` — zero `console.error`, zero `process.exit`. `atlasFetch` + became `atlasRequest`, which owns connect + stream + parse under one deadline. + Two new tests cover both paths. +- **claude: bypassing `generateImage` lost dispatch coverage.** An `output`/ + `aspect` swap would have gone unnoticed. Added one test through `generateImage` + under fake timers, so the real 5s cadence costs nothing. +- **claude: the credential test had a `Headers` blind spot** — a `Headers` + instance serialises to `{}`, so both assertions would have passed while the + key leaked. Now read via `new Headers(...).get('authorization')`. +- **codex: the abort-signal test was vacuous** — it passed for any + `AbortSignal`, including a controller signal that never fires. Now spies on + `AbortSignal.timeout` and asserts 30s/30s/120s. +- **codex: the prediction id trusted an unchecked cast.** Same `typeof` guard + the architect asked for on `outputs[0]`. +- **codex: deadline checked before the sleep**, so a budget that expired + mid-sleep still bought one more request. Moved after; the test asserts zero + polls. +- **codex + claude: SKILL frontmatter** still said only GEMINI/GOOGLE were + required. Fixed in all four, re-verified byte-identical. +- **claude: `--ref` rejection sat behind the key check**, so `-p atlas --ref x` + without a key named the wrong problem. Reordered. +- **claude: `withDetectedExtension` no longer described what it does** now that + it exits. Renamed `targetPathForImageBytes`. + +Declined, and why: + +- **codex: make `-r 2K/4K` a hard failure on the atlas path.** Defensible under + fail-fast, but it changes behaviour the contributor designed deliberately and + the architect reviewed and accepted, and it is in neither the MUSTs nor the + SHOULDs. Not a maintainer's call to make silently on someone else's PR — + flagged to the architect instead. +- **claude: require `https:` on the download URL.** The URL comes from an + authenticated TLS response and the bytes are magic-byte validated before any + write. Adds a failure mode on a path I cannot test for little gain. +- **codex: clamp each request's signal to the remaining overall budget.** Would + abort legitimate in-flight requests near the deadline. Worst case is now + deadline + one request timeout, which the comment states. + +## Verification + +- `pnpm --filter @cluesmith/codev build` — clean; `tsc --noEmit` — clean. +- generate-image file: 35 tests, 181ms (was 19 tests, ~10s). +- Full package suite: 278 files, 5571 passed, 0 failed. +- Note for anyone repeating this: `npx vitest run` at the *repo root* reports + ~367 failures. That is the wrong invocation — it bypasses the package config + and setup files. The real suite is `vitest run` from `packages/codev`. + +## Status + +See the PR for the final CI state; fork PRs need maintainer approval before +workflows run. diff --git a/packages/codev/src/__tests__/generate-image.test.ts b/packages/codev/src/__tests__/generate-image.test.ts index 850c85726..4e7da0c27 100644 --- a/packages/codev/src/__tests__/generate-image.test.ts +++ b/packages/codev/src/__tests__/generate-image.test.ts @@ -29,7 +29,7 @@ vi.mock('node:fs', async () => { }); // Import after mocks are set up -import { generateImage, GenerateImageOptions } from '../commands/generate-image.js'; +import { generateImage, generateViaAtlas, GenerateImageOptions } from '../commands/generate-image.js'; describe('generate-image', () => { const originalEnv = process.env; @@ -41,7 +41,10 @@ describe('generate-image', () => { beforeEach(() => { vi.clearAllMocks(); + // A copy, so a test that sets or deletes a key cannot leak into the next + // one or into the real environment; afterEach puts the original back. process.env = { ...originalEnv, GEMINI_API_KEY: 'test-api-key' }; + delete process.env.ATLASCLOUD_API_KEY; }); afterEach(() => { @@ -308,4 +311,398 @@ describe('generate-image', () => { expect(call.contents[3]).toBe('Combine these images'); }); }); + + describe('atlas provider', () => { + const originalFetch = global.fetch; + + // A recognisable JPEG header. Not Buffer.from(array): its .buffer is a + // pooled ArrayBuffer with an offset, so slicing it from 0 would hand back + // the wrong bytes. + const JPEG_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + + type FetchCall = { url: string; init?: RequestInit }; + + /** + * Install a fetch stub over the three Atlas hops and record every call. + * + * `polls` is consumed one entry per poll; the last entry repeats. + */ + function stubAtlas(options: { + submit?: Partial & { json?: () => Promise; text?: () => Promise }; + polls?: Array & { json?: () => Promise; text?: () => Promise }>; + download?: Partial & { + arrayBuffer?: () => Promise; + text?: () => Promise; + }; + }): FetchCall[] { + const calls: FetchCall[] = []; + let pollIndex = 0; + global.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const href = String(url); + calls.push({ url: href, init }); + if (href.endsWith('/generateImage')) { + return (options.submit ?? { + ok: true, + status: 200, + json: async () => ({ data: { id: 'pred-1', status: 'processing' } }), + }) as Response; + } + if (href.includes('/prediction/')) { + const polls = options.polls ?? [ + { + ok: true, + status: 200, + json: async () => ({ + data: { status: 'completed', outputs: ['https://cdn.example/a.jpg'] }, + }), + }, + ]; + const poll = polls[Math.min(pollIndex, polls.length - 1)]; + pollIndex += 1; + return poll as Response; + } + return (options.download ?? { + ok: true, + status: 200, + arrayBuffer: async () => JPEG_BYTES.buffer, + }) as Response; + }) as unknown as typeof fetch; + return calls; + } + + /** Drive the Atlas path with no sleep between polls. */ + function runAtlas(output = 'output.png', aspect: '1:1' | '16:9' = '1:1') { + return generateViaAtlas('test prompt', output, aspect, '1K', [], { pollIntervalMs: 0 }); + } + + beforeEach(() => { + process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.useRealTimers(); + }); + + it('exits with error when ATLASCLOUD_API_KEY is not set', async () => { + delete process.env.ATLASCLOUD_API_KEY; + + await expect( + generateImage('test prompt', { provider: 'atlas' } as GenerateImageOptions) + ).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('ATLASCLOUD_API_KEY environment variable not set') + ); + }); + + it('rejects an unknown provider', async () => { + await expect( + generateImage('test prompt', { provider: 'nope' } as GenerateImageOptions) + ).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining("Invalid provider 'nope'") + ); + }); + + it('refuses reference images instead of ignoring them', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await expect( + generateImage('test prompt', { + provider: 'atlas', + ref: ['style.png'], + } as GenerateImageOptions) + ).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--ref is not supported with --provider atlas') + ); + }); + + it('submits aspect_ratio, polls the prediction and names the file after its bytes', async () => { + const calls = stubAtlas({}); + + await runAtlas('output.png', '16:9'); + + const submitted = JSON.parse(String(calls[0]?.init?.body)); + expect(submitted.aspect_ratio).toBe('16:9'); + expect(submitted.model).toBe('google/nano-banana-pro/text-to-image'); + // api.atlascloud.ai rejects some default User-Agents with 403/1010. + expect((calls[0]?.init?.headers as Record)['User-Agent']).toBeTruthy(); + expect(calls[1]?.url).toContain('/prediction/pred-1'); + // The model returns JPEG, so the .png target must not be used verbatim. + expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith('output.jpg', expect.anything()); + }); + + it('sends the API key to Atlas and never to the CDN', async () => { + const calls = stubAtlas({}); + + await runAtlas(); + + const [submit, poll, download] = calls; + // Read through Headers, not as a plain object: a Headers instance + // serialises to {}, so an object-only assertion would pass while the + // credential leaked. + const authOf = (call?: FetchCall) => new Headers(call?.init?.headers ?? {}).get('authorization'); + expect(authOf(submit)).toBe('Bearer test-atlas-key'); + expect(authOf(poll)).toBe('Bearer test-atlas-key'); + expect(download?.url).toBe('https://cdn.example/a.jpg'); + // The CDN is a different origin: it must not see the credential at all. + expect(authOf(download)).toBeNull(); + expect(JSON.stringify(download?.init ?? {})).not.toContain('test-atlas-key'); + }); + + it('bounds every request with a timeout signal, not merely a signal', async () => { + // AbortSignal.timeout specifically: a plain AbortController signal is + // also an AbortSignal but never fires, so asserting the type alone would + // pass on code that has no deadline at all. + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + const calls = stubAtlas({}); + + await runAtlas(); + + expect(calls).toHaveLength(3); + for (const call of calls) { + expect(call.init?.signal).toBeInstanceOf(AbortSignal); + } + // submit, poll, then the larger budget for the image download. + expect(timeoutSpy.mock.calls.map(([ms]) => ms)).toEqual([30_000, 30_000, 120_000]); + timeoutSpy.mockRestore(); + }); + + it('keeps polling while the prediction is in progress', async () => { + const calls = stubAtlas({ + polls: [ + { ok: true, status: 200, json: async () => ({ data: { status: 'processing' } }) }, + { + ok: true, + status: 200, + json: async () => ({ + data: { status: 'completed', outputs: ['https://cdn.example/a.jpg'] }, + }), + }, + ], + }); + + await runAtlas(); + + expect(calls.filter((c) => c.url.includes('/prediction/'))).toHaveLength(2); + expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith('output.jpg', expect.anything()); + }); + + it('reports a failed submit', async () => { + stubAtlas({ + submit: { ok: false, status: 401, text: async () => 'bad key' }, + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas submit failed (401): bad key') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('reports a missing prediction id', async () => { + stubAtlas({ + submit: { ok: true, status: 200, json: async () => ({ data: {} }) }, + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('did not return a prediction id') + ); + }); + + it('reports a failed poll', async () => { + stubAtlas({ + polls: [{ ok: false, status: 500, text: async () => 'upstream boom' }], + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas poll failed (500): upstream boom') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('reports a failed prediction', async () => { + stubAtlas({ + polls: [ + { + ok: true, + status: 200, + json: async () => ({ data: { status: 'failed', error: 'content policy' } }), + }, + ], + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas generation failed: content policy') + ); + }); + + it('fails immediately on an unrecognized status instead of polling to the timeout', async () => { + const calls = stubAtlas({ + polls: [{ ok: true, status: 200, json: async () => ({ data: { status: 'cancelled' } }) }], + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('unrecognized status: cancelled') + ); + // One poll, not a full ATLAS_TIMEOUT_MS of them reported as a timeout. + expect(calls.filter((c) => c.url.includes('/prediction/'))).toHaveLength(1); + }); + + it('fails when a completed prediction carries no image URL', async () => { + stubAtlas({ + polls: [ + { ok: true, status: 200, json: async () => ({ data: { status: 'completed', outputs: [] } }) }, + ], + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('completed without an image URL') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('fails when the image URL is not a string', async () => { + stubAtlas({ + polls: [ + { + ok: true, + status: 200, + json: async () => ({ data: { status: 'completed', outputs: [{ url: 'nested' }] } }), + }, + ], + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('completed without an image URL') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('reports a failed download', async () => { + stubAtlas({ download: { ok: false, status: 404, text: async () => 'not found' } }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas image download failed (404)') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('refuses to write bytes that are not a recognised image', async () => { + // A 200 carrying an HTML error body must not land in output.png under a + // green "Image saved". + const html = new TextEncoder().encode('

502 Bad Gateway

'); + stubAtlas({ + download: { ok: true, status: 200, arrayBuffer: async () => html.buffer }, + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('not a JPEG, PNG or WebP image') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + + it('routes --provider atlas through the Atlas path with the CLI options', async () => { + // The only test that exercises generateImage -> generateViaAtlas argument + // passing (the rest call generateViaAtlas directly), so it is what would + // catch a swapped `output`/`aspect`. Fake timers keep the real 5s poll + // cadence free. + vi.useFakeTimers(); + const calls = stubAtlas({}); + + const run = generateImage('test prompt', { + provider: 'atlas', + aspect: '16:9', + output: 'dispatch.png', + } as GenerateImageOptions); + await vi.advanceTimersByTimeAsync(5000); + await run; + + expect(JSON.parse(String(calls[0]?.init?.body)).aspect_ratio).toBe('16:9'); + expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith('dispatch.jpg', expect.anything()); + }); + + it('gives up when the overall budget is spent instead of polling forever', async () => { + const calls = stubAtlas({ + polls: [{ ok: true, status: 200, json: async () => ({ data: { status: 'processing' } }) }], + }); + + // The budget expires *during* the first sleep. Checking the deadline + // before sleeping instead of after would miss that and spend one more + // request on an already-dead budget. + await expect( + generateViaAtlas('test prompt', 'output.png', '1:1', '1K', [], { + pollIntervalMs: 5, + timeoutMs: 1, + }) + ).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('timed out after 1ms')); + expect(calls.filter((c) => c.url.includes('/prediction/'))).toHaveLength(0); + }); + + it('rejects a prediction id that is not a string', async () => { + stubAtlas({ + submit: { ok: true, status: 200, json: async () => ({ data: { id: 12345 } }) }, + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('did not return a prediction id') + ); + }); + + it('reports an HTML error page served as a 200 poll response', async () => { + // The body read is inside the timeout guard, so a JSON parse failure is + // reported like any other poll failure instead of escaping as a raw + // SyntaxError stack. + stubAtlas({ + polls: [ + { + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token \'<\', " { + // AbortSignal.timeout stays armed while the body streams, so the abort + // can land on arrayBuffer() rather than on fetch(). + const aborted = new Error('The operation was aborted due to timeout'); + aborted.name = 'TimeoutError'; + stubAtlas({ + download: { + ok: true, + status: 200, + arrayBuffer: async () => { + throw aborted; + }, + }, + }); + + await expect(runAtlas()).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas image download failed: no response within 120000ms') + ); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/codev/src/cli.ts b/packages/codev/src/cli.ts index 8d065a2b5..f2329d0d7 100644 --- a/packages/codev/src/cli.ts +++ b/packages/codev/src/cli.ts @@ -243,12 +243,13 @@ program // Generate-image command program .command('generate-image') - .description('Generate images using Gemini (Nano Banana Pro)') + .description('Generate images using Gemini (Nano Banana Pro), or Atlas Cloud with --provider atlas') .argument('', 'Text prompt or path to .txt file') .option('-o, --output ', 'Output file path', 'output.png') .option('-r, --resolution ', 'Resolution: 1K, 2K, or 4K', '1K') .option('-a, --aspect ', 'Aspect ratio: 1:1, 16:9, 9:16, 3:4, 4:3, 3:2, 2:3', '1:1') .option('--ref ', 'Reference image(s) for image-to-image generation (up to 14)') + .option('-p, --provider ', 'Provider: gemini (default) or atlas (Atlas Cloud, same model)', 'gemini') .action(async (prompt, options) => { try { await generateImage(prompt, { @@ -256,6 +257,7 @@ program resolution: options.resolution, aspect: options.aspect, ref: options.ref, + provider: options.provider, }); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/packages/codev/src/commands/generate-image.ts b/packages/codev/src/commands/generate-image.ts index 1e47f2ec5..5a5da0f32 100644 --- a/packages/codev/src/commands/generate-image.ts +++ b/packages/codev/src/commands/generate-image.ts @@ -1,7 +1,9 @@ /** * generate-image - AI-powered image generation using Google's Gemini model (Nano Banana Pro) * - * Uses the @google/genai SDK with GEMINI_API_KEY from environment. + * Uses the @google/genai SDK with GEMINI_API_KEY from environment. Atlas Cloud + * serves the same model over a submit-then-poll REST API and is available as an + * opt-in provider with ATLASCLOUD_API_KEY. */ import { GoogleGenAI } from '@google/genai'; @@ -20,11 +22,33 @@ type Resolution = (typeof RESOLUTIONS)[number]; const ASPECT_RATIOS = ['1:1', '16:9', '9:16', '3:4', '4:3', '3:2', '2:3'] as const; type AspectRatio = (typeof ASPECT_RATIOS)[number]; +// Providers serving the same model +const PROVIDERS = ['gemini', 'atlas'] as const; +type Provider = (typeof PROVIDERS)[number]; + +// Atlas Cloud serves the same Nano Banana Pro model over its own async REST API +const ATLAS_BASE_URL = 'https://api.atlascloud.ai/api/v1/model'; +const ATLAS_MODEL = 'google/nano-banana-pro/text-to-image'; +const ATLAS_POLL_INTERVAL_MS = 5000; +const ATLAS_TIMEOUT_MS = 300_000; +// Per-request bounds. ATLAS_TIMEOUT_MS caps the poll loop as a whole, but it is +// only checked between requests, so each fetch carries its own deadline. +const ATLAS_REQUEST_TIMEOUT_MS = 30_000; +const ATLAS_DOWNLOAD_TIMEOUT_MS = 120_000; +// The only status Atlas documents for a prediction that is still running +// (https://www.atlascloud.ai/docs/en/predictions lists exactly processing, +// completed and failed). Anything else is unrecognized and fails, named. +const ATLAS_IN_PROGRESS_STATUSES = new Set(['processing']); +// api.atlascloud.ai rejects some clients' default User-Agent with 403 (error +// code 1010), so every request sends an explicit one. +const ATLAS_USER_AGENT = 'codev-generate-image/1'; + export interface GenerateImageOptions { output?: string; resolution?: string; aspect?: string; ref?: string[]; + provider?: string; } /** @@ -56,6 +80,212 @@ function readPrompt(promptOrPath: string): string { return promptOrPath; } +/** + * Check that the downloaded bytes really are an image, and name the file after + * the container they turned out to be. + * + * Atlas serves whatever container the model produced (JPEG for Nano Banana + * Pro), so writing those bytes into the default `output.png` would mislabel + * the file. Bytes that are not a recognised image are not written at all: a + * 200 response carrying an HTML error body must never land in output.png + * under a green "Image saved". Exits rather than returning on that path. + */ +function targetPathForImageBytes(output: string, bytes: Buffer): string { + const detected = + bytes.subarray(0, 3).toString('hex') === 'ffd8ff' + ? 'jpg' + : bytes.subarray(0, 8).toString('hex') === '89504e470d0a1a0a' + ? 'png' + : bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ? 'webp' + : null; + if (!detected) { + console.error( + chalk.red('Error:') + + ` Atlas returned ${bytes.length} bytes that are not a JPEG, PNG or WebP image` + + ` (starts with ${bytes.subarray(0, 8).toString('hex') || ''}).` + + ' Nothing was written.' + ); + process.exit(1); + } + const current = output.toLowerCase().split('.').pop(); + if (current === detected || (detected === 'jpg' && current === 'jpeg')) return output; + const renamed = output.replace(/\.[^./\\]+$/, '') + '.' + detected; + console.log(chalk.yellow('Note:') + ` provider returned ${detected.toUpperCase()}; saving as ${renamed}`); + return renamed; +} + +/** + * One Atlas exchange - connect, stream and parse - under a single deadline. + * + * ATLAS_TIMEOUT_MS bounds the poll loop as a whole, but it is only checked + * between requests: without a signal, a single stalled connection hangs the + * command forever. The signal also stays armed while the body streams, so the + * body read belongs inside the guard too - otherwise a poll that answers 200 + * with an HTML error page, or a download aborted mid-stream, escapes as a raw + * SyntaxError or TimeoutError stack instead of a clear message. + */ +async function atlasRequest( + url: string, + what: string, + timeoutMs: number, + read: (response: Response) => Promise, + init: RequestInit = {} +): Promise { + // Resolved inside the try, acted on outside it, so that reporting a failure + // never lands in our own catch. + let outcome: { ok: true; body: T } | { ok: false; detail: string }; + try { + const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + outcome = response.ok + ? { ok: true, body: await read(response) } + : { ok: false, detail: ` (${response.status}): ${await response.text()}` }; + } catch (error) { + outcome = { + ok: false, + detail: + error instanceof Error && error.name === 'TimeoutError' + ? `: no response within ${timeoutMs}ms` + : `: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (!outcome.ok) { + console.error(chalk.red('Error:') + ` Atlas ${what} failed${outcome.detail}`.trimEnd()); + process.exit(1); + } + return outcome.body; +} + +/** + * Generate through Atlas Cloud: submit a job, poll the prediction, download. + * + * The cadence and the overall budget are parameters rather than constants so + * tests can drive the loop without sleeping for real. Nothing but tests passes + * them; the CLI uses the defaults. + */ +export async function generateViaAtlas( + promptText: string, + output: string, + aspect: AspectRatio, + resolution: Resolution, + refs: string[], + options: { pollIntervalMs?: number; timeoutMs?: number } = {} +): Promise { + const pollIntervalMs = options.pollIntervalMs ?? ATLAS_POLL_INTERVAL_MS; + const timeoutMs = options.timeoutMs ?? ATLAS_TIMEOUT_MS; + + // Argument problems are reported before credential problems, so that + // `--provider atlas --ref x` names the unsupported flag rather than a + // missing key the user would not have needed anyway. + if (refs.length > 0) { + console.error( + chalk.red('Error:') + + ' --ref is not supported with --provider atlas (this path is text-to-image only).\n' + + 'Use --provider gemini for reference images.' + ); + process.exit(1); + } + const apiKey = process.env.ATLASCLOUD_API_KEY; + if (!apiKey) { + console.error( + chalk.red('Error:') + + ' ATLASCLOUD_API_KEY environment variable not set.\n' + + 'Get an API key at https://www.atlascloud.ai/console' + ); + process.exit(1); + } + // Measured against the endpoint: aspect_ratio is honoured (1:1 -> 1024x1024, + // 16:9 -> 1376x768), while 1K/2K/4K has no equivalent field here. + if (resolution !== '1K') { + console.log( + chalk.yellow('Note:') + + ` --resolution ${resolution} is not exposed by this provider; the model's default resolution is returned.` + ); + } + + const headers = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': ATLAS_USER_AGENT, + }; + + const submitted = (await atlasRequest( + `${ATLAS_BASE_URL}/generateImage`, + 'submit', + ATLAS_REQUEST_TIMEOUT_MS, + (response) => response.json() as Promise, + { + method: 'POST', + headers, + body: JSON.stringify({ model: ATLAS_MODEL, prompt: promptText, aspect_ratio: aspect }), + } + )) as { data?: { id?: unknown } } | null; + const predictionId = submitted?.data?.id; + if (typeof predictionId !== 'string' || predictionId === '') { + console.error(chalk.red('Error:') + ' Atlas did not return a prediction id'); + process.exit(1); + } + + const deadline = Date.now() + timeoutMs; + for (;;) { + await new Promise((done) => setTimeout(done, pollIntervalMs)); + // Checked after the sleep, not before it: a pre-sleep check can pass on a + // budget that expires during the sleep, and then spend a whole further + // request on it. + if (Date.now() >= deadline) { + console.error( + chalk.red('Error:') + ` Atlas prediction ${predictionId} timed out after ${timeoutMs}ms` + ); + process.exit(1); + } + const polled = (await atlasRequest( + `${ATLAS_BASE_URL}/prediction/${encodeURIComponent(predictionId)}`, + 'poll', + ATLAS_REQUEST_TIMEOUT_MS, + (response) => response.json() as Promise, + { headers } + )) as { data?: { status?: unknown; outputs?: unknown[]; error?: unknown } } | null; + const status = polled?.data?.status; + if (status === 'completed') { + const url = polled?.data?.outputs?.[0]; + if (typeof url !== 'string' || url === '') { + console.error(chalk.red('Error:') + ' Atlas completed without an image URL'); + process.exit(1); + } + // No Atlas headers here: the CDN is a different origin and must never + // see the API key. + const bytes = Buffer.from( + await atlasRequest(url, 'image download', ATLAS_DOWNLOAD_TIMEOUT_MS, (response) => + response.arrayBuffer() + ) + ); + const target = targetPathForImageBytes(output, bytes); + writeFileSync(target, bytes); + console.log(chalk.green('Image saved to') + ` ${target}`); + return; + } + if (status === 'failed') { + const detail = polled?.data?.error; + console.error( + chalk.red('Error:') + + ` Atlas generation failed: ${typeof detail === 'string' && detail !== '' ? detail : 'unknown'}` + ); + process.exit(1); + } + if (typeof status !== 'string' || !ATLAS_IN_PROGRESS_STATUSES.has(status)) { + // Polling on past a status we don't understand burns the full timeout and + // then reports it as one, which is a lie about what happened. + console.error( + chalk.red('Error:') + + ` Atlas prediction ${predictionId} reported an unrecognized status: ` + + (status === undefined ? '(none)' : String(status)) + ); + process.exit(1); + } + } +} + /** * Main generate-image function */ @@ -70,6 +300,15 @@ export async function generateImage( const resolution = (options.resolution || '1K') as Resolution; const aspect = (options.aspect || '1:1') as AspectRatio; const refs = options.ref || []; + const provider = (options.provider || 'gemini') as Provider; + + // Validate provider + if (!PROVIDERS.includes(provider)) { + console.error( + chalk.red('Error:') + ` Invalid provider '${provider}'. Use: ${PROVIDERS.join(', ')}` + ); + process.exit(1); + } // Validate resolution if (!RESOLUTIONS.includes(resolution)) { @@ -111,6 +350,13 @@ export async function generateImage( // Read prompt const promptText = readPrompt(prompt); + + if (provider === 'atlas') { + console.log(chalk.blue('Generating image with') + ` ${ATLAS_MODEL} (Atlas Cloud)...`); + await generateViaAtlas(promptText, output, aspect, resolution, refs); + return; + } + console.log(chalk.blue('Generating image with') + ` ${MODEL}...`); // Create client