From 0f6b3402c0d25e88bc35c1f651e7761467ff07dd Mon Sep 17 00:00:00 2001 From: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:59:35 +0800 Subject: [PATCH 1/3] feat(generate-image): add Atlas Cloud as an optional provider `codev generate-image` is wired to the @google/genai SDK, so it needs a Google AI Studio key. Atlas Cloud serves the same Nano Banana Pro model over its own submit-then-poll REST API, so this adds it behind `-p/--provider atlas`. The default stays `gemini` and its code path is untouched. Measured against the endpoint rather than assumed: - `-a/--aspect` maps to Atlas's `aspect_ratio` and is honoured: 1:1 returns 1024x1024 and 16:9 returns 1376x768. - `-r/--resolution` has no equivalent field there. Rather than silently dropping it, a note is printed saying the model's default resolution comes back. - `--ref` is not supported on this path (text-to-image only) and exits with an error that points at `--provider gemini`, instead of generating something that quietly ignores the reference images. - The model returns JPEG. Writing those bytes into the default `output.png` would mislabel the file, so the output is named after its actual bytes and the rename is logged. The Gemini path keeps its existing behaviour. No new dependency: the Atlas path uses global fetch (the package already requires Node >= 20). Tests: 5 new vitest cases covering the missing credential, an unknown provider, the refused `--ref`, a full submit/poll/download with the JPEG rename and the explicit User-Agent, and a failed prediction. `vitest run src/__tests__/generate-image.test.ts` is 19 passed. `tsc --noEmit` reports nothing for the touched files (the pre-existing errors in `src/agent-farm` and `src/lib/github.ts` come from unbuilt workspace deps). One provider detail worth having in the file: api.atlascloud.ai rejects some clients' default User-Agent with 403 (error code 1010), which is what the constant and its comment are for. Signed-off-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> --- .../.claude/skills/generate-image/SKILL.md | 23 ++- .../.codex/skills/generate-image/SKILL.md | 23 ++- .../src/__tests__/generate-image.test.ts | 114 ++++++++++++ packages/codev/src/cli.ts | 4 +- packages/codev/src/commands/generate-image.ts | 164 +++++++++++++++++- 5 files changed, 324 insertions(+), 4 deletions(-) diff --git a/codev-skeleton/.claude/skills/generate-image/SKILL.md b/codev-skeleton/.claude/skills/generate-image/SKILL.md index aed0276b9..f460f4ed3 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. --- # 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..f460f4ed3 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. --- # 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/packages/codev/src/__tests__/generate-image.test.ts b/packages/codev/src/__tests__/generate-image.test.ts index 850c85726..febef6c5f 100644 --- a/packages/codev/src/__tests__/generate-image.test.ts +++ b/packages/codev/src/__tests__/generate-image.test.ts @@ -308,4 +308,118 @@ describe('generate-image', () => { expect(call.contents[3]).toBe('Combine these images'); }); }); + + describe('atlas provider', () => { + const originalFetch = global.fetch; + + 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 () => { + process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; + 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 () => { + process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; + // 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 = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const calls: Array<{ url: string; init?: RequestInit }> = []; + 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 { + ok: true, + status: 200, + json: async () => ({ data: { id: 'pred-1', status: 'processing' } }), + } as Response; + } + if (href.includes('/prediction/')) { + return { + ok: true, + status: 200, + json: async () => ({ + data: { status: 'completed', outputs: ['https://cdn.example/a.jpg'] }, + }), + } as Response; + } + return { + ok: true, + status: 200, + arrayBuffer: async () => jpeg.buffer, + } as Response; + }) as unknown as typeof fetch; + + await generateImage('test prompt', { + provider: 'atlas', + aspect: '16:9', + output: 'output.png', + } as GenerateImageOptions); + + 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()); + }, 20000); + + it('reports a failed prediction', async () => { + process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; + global.fetch = vi.fn(async (url: string | URL | Request) => { + const href = String(url); + if (href.endsWith('/generateImage')) { + return { ok: true, status: 200, json: async () => ({ data: { id: 'pred-2' } }) } as Response; + } + return { + ok: true, + status: 200, + json: async () => ({ data: { status: 'failed', error: 'content policy' } }), + } as Response; + }) as unknown as typeof fetch; + + await expect( + generateImage('test prompt', { provider: 'atlas' } as GenerateImageOptions) + ).rejects.toThrow('process.exit called'); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Atlas generation failed: content policy') + ); + }, 20000); + }); }); 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..d512db2cd 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,25 @@ 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; +// 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 +72,136 @@ function readPrompt(promptOrPath: string): string { return promptOrPath; } +/** + * Name a downloaded image after its actual bytes. + * + * 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. + */ +function withDetectedExtension(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) return output; + 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; +} + +/** + * Generate through Atlas Cloud: submit a job, poll the prediction, download. + */ +async function generateViaAtlas( + promptText: string, + output: string, + aspect: AspectRatio, + resolution: Resolution, + refs: string[] +): Promise { + 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); + } + 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); + } + // 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 submitResponse = await fetch(`${ATLAS_BASE_URL}/generateImage`, { + method: 'POST', + headers, + body: JSON.stringify({ model: ATLAS_MODEL, prompt: promptText, aspect_ratio: aspect }), + }); + if (!submitResponse.ok) { + console.error( + chalk.red('Error:') + ` Atlas submit failed (${submitResponse.status}): ${await submitResponse.text()}` + ); + process.exit(1); + } + const submitted = (await submitResponse.json()) as { data?: { id?: string } }; + const predictionId = submitted.data?.id; + if (!predictionId) { + console.error(chalk.red('Error:') + ' Atlas did not return a prediction id'); + process.exit(1); + } + + const deadline = Date.now() + ATLAS_TIMEOUT_MS; + for (;;) { + if (Date.now() > deadline) { + console.error(chalk.red('Error:') + ` Atlas prediction ${predictionId} timed out`); + process.exit(1); + } + await new Promise((done) => setTimeout(done, ATLAS_POLL_INTERVAL_MS)); + const pollResponse = await fetch( + `${ATLAS_BASE_URL}/prediction/${encodeURIComponent(predictionId)}`, + { headers } + ); + if (!pollResponse.ok) { + console.error( + chalk.red('Error:') + ` Atlas poll failed (${pollResponse.status}): ${await pollResponse.text()}` + ); + process.exit(1); + } + const polled = (await pollResponse.json()) as { + data?: { status?: string; outputs?: string[]; error?: string }; + }; + const status = polled.data?.status; + if (status === 'completed') { + const url = polled.data?.outputs?.[0]; + if (!url) { + console.error(chalk.red('Error:') + ' Atlas completed without an image URL'); + process.exit(1); + } + const download = await fetch(url); + if (!download.ok) { + console.error(chalk.red('Error:') + ` Atlas image download failed (${download.status})`); + process.exit(1); + } + const bytes = Buffer.from(await download.arrayBuffer()); + const target = withDetectedExtension(output, bytes); + writeFileSync(target, bytes); + console.log(chalk.green('Image saved to') + ` ${target}`); + return; + } + if (status === 'failed') { + console.error(chalk.red('Error:') + ` Atlas generation failed: ${polled.data?.error ?? 'unknown'}`); + process.exit(1); + } + } +} + /** * Main generate-image function */ @@ -70,6 +216,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 +266,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 From a2f907dc372988acda77baf7cb3eef509af2309f Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Fri, 4 Sep 2026 08:35:05 -0700 Subject: [PATCH 2/3] Apply maintainer review to the Atlas Cloud provider Review fixes on top of @binyangzhu000-sudo's contribution, pushed to the branch rather than sent back as a list. The provider design, the measured flag mapping and the contributor's commit are unchanged. Skill docs: `.claude/` and `.codex/skills/generate-image/SKILL.md` get the same edit as the two skeleton copies, so all four are byte-identical again. Their frontmatter also lists ATLASCLOUD_API_KEY, since that string is what drives skill selection. Fail-fast fixes, in the spirit of the repo rule: - Every request is bounded. ATLAS_TIMEOUT_MS capped the poll loop but no individual fetch, so a stalled submit, poll or download hung forever. `atlasRequest` puts connect, stream and parse under one AbortSignal.timeout: the signal stays armed while the body streams, so a poll answering 200 with an HTML error page, or a download aborted mid-stream, now reports a clear error instead of escaping as a raw SyntaxError or TimeoutError stack. - Unknown statuses fail immediately, naming the status. The loop only exited on completed/failed, so `cancelled` or an undefined status polled for the full 300s and then reported a misleading timeout. The in-progress allowlist is `processing` alone: that is what https://www.atlascloud.ai/docs/en/predictions documents, and inventing plausible extras would be guessing at a contract. - Unrecognized download bytes are never written. `withDetectedExtension` passed them through, so an HTML error body with a 200 landed in output.png under a green "Image saved". It now exits; renamed `targetPathForImageBytes`, since the old name no longer described it. - The prediction id gets the same `typeof` guard as the image URL, and the overall deadline is checked after the sleep rather than before, so a budget that expires mid-sleep does not buy one more request. - `--ref` is refused before the credential check, so `-p atlas --ref x` without a key names the flag rather than the key. Tests: 19 -> 35 cases, and the file runs in 181ms instead of ~10s (the poll cadence and overall budget are injectable; the two polling tests no longer sleep for real). ATLASCLOUD_API_KEY is explicitly cleared per test. New coverage for submit non-ok, poll non-ok, unknown status, missing and non-string prediction id, missing and non-string image URL, unrecognized bytes, an HTML body served as JSON, a mid-stream abort, and the overall timeout. The security assertion the review asked for is there: Authorization IS sent to Atlas and is NOT sent to the CDN, read through `Headers` so a Headers instance cannot hide a leak behind an empty object. Each fix was checked by reverting it and confirming a test fails. `pnpm --filter @cluesmith/codev build` and `tsc --noEmit` are clean; the package suite is 278 files / 5571 passed. --- .claude/skills/generate-image/SKILL.md | 23 +- .codex/skills/generate-image/SKILL.md | 23 +- .../.claude/skills/generate-image/SKILL.md | 2 +- .../.codex/skills/generate-image/SKILL.md | 2 +- codev/resources/cloud-instances.md | 1 + .../src/__tests__/generate-image.test.ts | 389 +++++++++++++++--- packages/codev/src/commands/generate-image.ts | 190 ++++++--- 7 files changed, 520 insertions(+), 110 deletions(-) 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 f460f4ed3..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, 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. +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 diff --git a/codev-skeleton/.codex/skills/generate-image/SKILL.md b/codev-skeleton/.codex/skills/generate-image/SKILL.md index f460f4ed3..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, 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. +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 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/packages/codev/src/__tests__/generate-image.test.ts b/packages/codev/src/__tests__/generate-image.test.ts index febef6c5f..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(() => { @@ -312,6 +315,70 @@ describe('generate-image', () => { 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(); @@ -338,7 +405,6 @@ describe('generate-image', () => { }); it('refuses reference images instead of ignoring them', async () => { - process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; vi.mocked(existsSync).mockReturnValue(true); await expect( @@ -353,42 +419,9 @@ describe('generate-image', () => { }); it('submits aspect_ratio, polls the prediction and names the file after its bytes', async () => { - process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; - // 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 = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); - const calls: Array<{ url: string; init?: RequestInit }> = []; - 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 { - ok: true, - status: 200, - json: async () => ({ data: { id: 'pred-1', status: 'processing' } }), - } as Response; - } - if (href.includes('/prediction/')) { - return { - ok: true, - status: 200, - json: async () => ({ - data: { status: 'completed', outputs: ['https://cdn.example/a.jpg'] }, - }), - } as Response; - } - return { - ok: true, - status: 200, - arrayBuffer: async () => jpeg.buffer, - } as Response; - }) as unknown as typeof fetch; + const calls = stubAtlas({}); - await generateImage('test prompt', { - provider: 'atlas', - aspect: '16:9', - output: 'output.png', - } as GenerateImageOptions); + await runAtlas('output.png', '16:9'); const submitted = JSON.parse(String(calls[0]?.init?.body)); expect(submitted.aspect_ratio).toBe('16:9'); @@ -398,28 +431,278 @@ describe('generate-image', () => { 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()); - }, 20000); + }); + + 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 () => { - process.env.ATLASCLOUD_API_KEY = 'test-atlas-key'; - global.fetch = vi.fn(async (url: string | URL | Request) => { - const href = String(url); - if (href.endsWith('/generateImage')) { - return { ok: true, status: 200, json: async () => ({ data: { id: 'pred-2' } }) } as Response; - } - return { - ok: true, - status: 200, - json: async () => ({ data: { status: 'failed', error: 'content policy' } }), - } as Response; - }) as unknown as typeof fetch; + 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( - generateImage('test prompt', { provider: 'atlas' } as GenerateImageOptions) + 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('Atlas generation failed: content policy') + expect.stringContaining('did not return a prediction id') ); - }, 20000); + }); + + 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/commands/generate-image.ts b/packages/codev/src/commands/generate-image.ts index d512db2cd..5a5da0f32 100644 --- a/packages/codev/src/commands/generate-image.ts +++ b/packages/codev/src/commands/generate-image.ts @@ -31,6 +31,14 @@ 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'; @@ -73,13 +81,16 @@ function readPrompt(promptOrPath: string): string { } /** - * Name a downloaded image after its actual bytes. + * 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. + * 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 withDetectedExtension(output: string, bytes: Buffer): string { +function targetPathForImageBytes(output: string, bytes: Buffer): string { const detected = bytes.subarray(0, 3).toString('hex') === 'ffd8ff' ? 'jpg' @@ -89,7 +100,15 @@ function withDetectedExtension(output: string, bytes: Buffer): string { bytes.subarray(8, 12).toString('ascii') === 'WEBP' ? 'webp' : null; - if (!detected) return output; + 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; @@ -97,30 +116,82 @@ function withDetectedExtension(output: string, bytes: Buffer): string { 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. */ -async function generateViaAtlas( +export async function generateViaAtlas( promptText: string, output: string, aspect: AspectRatio, resolution: Resolution, - refs: string[] + refs: string[], + options: { pollIntervalMs?: number; timeoutMs?: number } = {} ): Promise { - const apiKey = process.env.ATLASCLOUD_API_KEY; - if (!apiKey) { + 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:') + - ' ATLASCLOUD_API_KEY environment variable not set.\n' + - 'Get an API key at https://www.atlascloud.ai/console' + ' --ref is not supported with --provider atlas (this path is text-to-image only).\n' + + 'Use --provider gemini for reference images.' ); process.exit(1); } - if (refs.length > 0) { + const apiKey = process.env.ATLASCLOUD_API_KEY; + if (!apiKey) { 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.' + ' ATLASCLOUD_API_KEY environment variable not set.\n' + + 'Get an API key at https://www.atlascloud.ai/console' ); process.exit(1); } @@ -139,64 +210,77 @@ async function generateViaAtlas( 'User-Agent': ATLAS_USER_AGENT, }; - const submitResponse = await fetch(`${ATLAS_BASE_URL}/generateImage`, { - method: 'POST', - headers, - body: JSON.stringify({ model: ATLAS_MODEL, prompt: promptText, aspect_ratio: aspect }), - }); - if (!submitResponse.ok) { - console.error( - chalk.red('Error:') + ` Atlas submit failed (${submitResponse.status}): ${await submitResponse.text()}` - ); - process.exit(1); - } - const submitted = (await submitResponse.json()) as { data?: { id?: string } }; - const predictionId = submitted.data?.id; - if (!predictionId) { + 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() + ATLAS_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; for (;;) { - if (Date.now() > deadline) { - console.error(chalk.red('Error:') + ` Atlas prediction ${predictionId} timed out`); - process.exit(1); - } - await new Promise((done) => setTimeout(done, ATLAS_POLL_INTERVAL_MS)); - const pollResponse = await fetch( - `${ATLAS_BASE_URL}/prediction/${encodeURIComponent(predictionId)}`, - { headers } - ); - if (!pollResponse.ok) { + 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 poll failed (${pollResponse.status}): ${await pollResponse.text()}` + chalk.red('Error:') + ` Atlas prediction ${predictionId} timed out after ${timeoutMs}ms` ); process.exit(1); } - const polled = (await pollResponse.json()) as { - data?: { status?: string; outputs?: string[]; error?: string }; - }; - const status = polled.data?.status; + 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 (!url) { + 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); } - const download = await fetch(url); - if (!download.ok) { - console.error(chalk.red('Error:') + ` Atlas image download failed (${download.status})`); - process.exit(1); - } - const bytes = Buffer.from(await download.arrayBuffer()); - const target = withDetectedExtension(output, bytes); + // 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') { - console.error(chalk.red('Error:') + ` Atlas generation failed: ${polled.data?.error ?? 'unknown'}`); + 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); } } From bfcc2bb16a291a2a0597745f57ca4b5dc4140b46 Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Fri, 4 Sep 2026 08:35:09 -0700 Subject: [PATCH 3/3] Add builder thread for the PR #1618 review pass Records the maintainer-edit approach, the CMAP round, and the three findings that were declined along with the reasoning. --- codev/state/task-47B5_thread.md | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 codev/state/task-47B5_thread.md 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.