diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a906c6..3198a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. +## Unreleased (v2.5.0) + +- New `openai` image provider: `sprite-gen gen --provider openai` calls the OpenAI Images REST API with nothing but `OPENAI_API_KEY` — the credential a headless container (a Modal worker, CI, a SaaS backend) can have, where the `codex` route's interactive ChatGPT login cannot exist. New images go to `/v1/images/generations`, `--ref` switches to `/v1/images/edits` as multipart with the references as repeated `image[]` parts in order (up to 16), and gpt-image's inline base64 is decoded and published as a verified PNG without resizing. Default model `gpt-image-2.5-flare`. `--transparent` asks for `background: transparent` with `output_format: png` — the same measured `native` strategy as codex, and a live run came back 84 % alpha-0 with the subject at alpha 251–254. `--aspect-ratio` maps to one of the gpt-image `size` values that satisfy the API's constraints (both sides divisible by 16, ratio within 1:3..3:1, 655,360–8,294,400 pixels); a ratio with no exact size is refused rather than rounded to a nearby one you would be billed for. A missing or empty key, a rejected key and a failed request are all terminal: this provider never falls back to codex, to another credential, or to a retry. +- sprite-gen stays subscription-first. `codex` and `grok` run on a subscription you already pay for; `openai` bills per call, so it runs **only** when `--provider openai` names it. `SPRITE_GEN_DEFAULT_PROVIDER=openai` is refused rather than honoured, the guided `workflow` flow neither offers it nor saves it as a preference, no availability fallback targets it (a codex outage still reaches grok, never metered credit), and having `OPENAI_API_KEY` in the environment changes no route by itself. Every call that does spend API credit — image or video — prints one stderr line naming the charge before the request leaves. +- `gen --quality low|medium|high|xhigh|max|auto` and `gen --resolution 1k|1.5k|2k`: the two knobs an image is billed on, as one shared vocabulary with a per-provider subset. grok Imagine carries `auto|low|medium` and all three resolution tiers and prices an image on the pair; openai carries the whole quality range and takes its size from `--aspect-ratio`; codex `image_gen` exposes neither dial. A knob a provider cannot honour fails loudly instead of being dropped from a request body you are about to pay for. Omitting both keeps every existing call byte-identical. The resolution names are tiers, not pixel counts — `1.5k` rendered 1408x1408 at 1:1 (2026-09-20 measurement) — and the grok subsets were read off the server rather than the prose: `quality` deserializes the wider shared enum and then refuses per model (`high` → HTTP 400 "This model only supports the following quality value(s): low, medium, auto."), `resolution: 1.5k` renders although the capability guide lists only 1k and 2k, and an unknown field is not refused at all, which is why these names are checked locally. Grok image reports carry `extra.quality` and `extra.resolution` when the request sent them. +- `video`, `video-extend` and `video-edit` announce the per-call charge on stderr when they run on `XAI_API_KEY` instead of the Grok subscription login — one line per submitted job, naming the duration and resolution it is priced on. The subscription route stays silent and no other video behaviour changed. +- Registering a provider now fails loudly when the workflow catalog has no label for it. The guided flow built its provider choices by zipping labels onto the provider tuple positionally, so a third provider was dropped without a word; labels are a checked mapping (a missing one fails at import) and the flow offers the subscription routes only. `workflow` access probing knows `openai` — ready when the key is set, with `billing: api-credit` and the billing confirmation that goes with it. + ## v2.4.1 - A visible hop is no longer refused as "never leaves its rest pose" - `video-loop` one-shot detection no longer refuses a visible hop because the rest pose is not one pose. The excursion is admitted by either the peak's height in MADs of the rest noise (as before) or the fraction of the subject's pixel mass the peak moves (new, ≥ 0.4); the report records `excursion_moved` and `excursion_rule`. A body that walked a few steps, hopped 42 px and froze scored 1.8 MADs because the walking preamble and the frozen tail inflate the "rest noise"; by moved mass it scores 1.07. A jittering stand still fails both rules, and clips the MAD rule already accepted are cut exactly as before. diff --git a/README.es.md b/README.es.md index a655493..70606ba 100644 --- a/README.es.md +++ b/README.es.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -La generación de imágenes forma parte de este motor (`sprite_gen.gen`, proveedores `codex` y `grok`; la skill genérica `image-gen` es una fina lanzadera sobre él). El vídeo usa **tu propia** credencial — el login de la CLI `grok` o una `XAI_API_KEY` — y nada se distribuye con el repositorio ([docs/video.md](docs/video.md)). +La generación de imágenes forma parte de este motor (`sprite_gen.gen`, proveedores `codex` y `grok` sobre una suscripción que ya pagas, más un proveedor `openai` para servidores y SaaS, facturado por llamada y que solo se ejecuta si lo nombras explícitamente; la skill genérica `image-gen` es una fina lanzadera sobre él). El vídeo usa **tu propia** credencial — el login de la CLI `grok` o una `XAI_API_KEY` — y nada se distribuye con el repositorio ([docs/video.md](docs/video.md)). `sprite-gen` soporta CPython 3.10+; CI ejecuta 3.10 y 3.14. El inicio rápido necesita un Python con `venv`/`ensurepip` funcionando. diff --git a/README.fr.md b/README.fr.md index 949f0d5..714165f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -La génération d'images fait partie de ce moteur (`sprite_gen.gen`, providers `codex` et `grok` ; la skill générique `image-gen` n'en est qu'une fine navette). La vidéo utilise **vos propres** identifiants — la connexion via la CLI `grok` ou une `XAI_API_KEY` — et rien n'est livré avec le dépôt ([docs/video.md](docs/video.md)). +La génération d'images fait partie de ce moteur (`sprite_gen.gen`, providers `codex` et `grok` sur un abonnement que vous payez déjà, plus un provider `openai` pour les serveurs et le SaaS, facturé à l'appel et lancé uniquement s'il est nommé explicitement ; la skill générique `image-gen` n'en est qu'une fine navette). La vidéo utilise **vos propres** identifiants — la connexion via la CLI `grok` ou une `XAI_API_KEY` — et rien n'est livré avec le dépôt ([docs/video.md](docs/video.md)). `sprite-gen` supporte CPython 3.10+ ; la CI tourne sur 3.10 et 3.14. Le démarrage rapide nécessite un Python avec `venv`/`ensurepip` fonctionnels. diff --git a/README.ja.md b/README.ja.md index 216baa8..dcfeb30 100644 --- a/README.ja.md +++ b/README.ja.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -画像生成はこのエンジンの一部です(`sprite_gen.gen`、プロバイダは `codex` と `grok`。汎用の `image-gen` スキルはその上の薄いシャトルです)。動画には**あなた自身の**認証情報 — `grok` CLI ログインまたは `XAI_API_KEY` — を使い、リポジトリには何も同梱されていません([docs/video.md](docs/video.md))。 +画像生成はこのエンジンの一部です(`sprite_gen.gen`、すでに契約しているサブスクリプションで動くプロバイダ `codex` と `grok`、さらにサーバーや SaaS 向けに呼び出しごとに課金され、明示的に指定したときだけ動く `openai` プロバイダ。汎用の `image-gen` スキルはその上の薄いシャトルです)。動画には**あなた自身の**認証情報 — `grok` CLI ログインまたは `XAI_API_KEY` — を使い、リポジトリには何も同梱されていません([docs/video.md](docs/video.md))。 `sprite-gen` は CPython 3.10+ をサポートし、CI は 3.10 と 3.14 で実行されます。クイックスタートには `venv`/`ensurepip` が動作する Python が必要です。 diff --git a/README.ko.md b/README.ko.md index b3b4351..4ae290c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -이미지 생성은 이 엔진의 일부다 (`sprite_gen.gen`, 프로바이더 `codex` 와 `grok`; 범용 `image-gen` 스킬은 그 위에 얹힌 얇은 셔틀이다). 비디오는 **본인의** 자격 증명 — `grok` CLI 로그인 또는 `XAI_API_KEY` — 을 사용하며, 레포에는 아무것도 함께 배포되지 않는다 ([docs/video.md](docs/video.md)). +이미지 생성은 이 엔진의 일부다 (`sprite_gen.gen`, 이미 내고 있는 구독으로 도는 프로바이더 `codex` 와 `grok`, 그리고 서버·SaaS 용으로 호출당 과금되며 명시해야만 도는 `openai` 프로바이더; 범용 `image-gen` 스킬은 그 위에 얹힌 얇은 셔틀이다). 비디오는 **본인의** 자격 증명 — `grok` CLI 로그인 또는 `XAI_API_KEY` — 을 사용하며, 레포에는 아무것도 함께 배포되지 않는다 ([docs/video.md](docs/video.md)). `sprite-gen` 은 CPython 3.10+ 를 지원하고, CI 는 3.10 과 3.14 에서 돈다. 퀵스타트에는 `venv`/`ensurepip` 이 동작하는 Python 이 필요하다. diff --git a/README.md b/README.md index 0fd07fa..b07141c 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -Image generation is part of this engine (`sprite_gen.gen`, providers `codex` and `grok`; the general `image-gen` skill is a thin shuttle over it). Video uses **your own** credential — the `grok` CLI login or an `XAI_API_KEY` — and nothing is shipped with the repo ([docs/video.md](docs/video.md)). +Image generation is part of this engine (`sprite_gen.gen`, providers `codex` and `grok` on a subscription you already pay for, plus an explicit-only `openai` provider for servers and SaaS that bills per call; the general `image-gen` skill is a thin shuttle over it). Video uses **your own** credential — the `grok` CLI login or an `XAI_API_KEY` — and nothing is shipped with the repo ([docs/video.md](docs/video.md)). `sprite-gen` supports CPython 3.10+; CI runs 3.10 and 3.14. The quickstart needs a Python with working `venv`/`ensurepip`. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index ec34b69..693b1b5 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -155,7 +155,7 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu --repo aldegad/sprite-gen --path . --name sprite-gen ``` -图像生成是本引擎的一部分(`sprite_gen.gen`,提供方 `codex` 与 `grok`;通用的 `image-gen` 技能只是覆在其上的一层薄封装)。视频使用**你自己的**凭据 —— `grok` CLI 登录或 `XAI_API_KEY` —— 仓库中不附带任何凭据([docs/video.md](docs/video.md))。 +图像生成是本引擎的一部分(`sprite_gen.gen`,跑在你已经付费的订阅上的提供方 `codex` 与 `grok`,以及面向服务器与 SaaS、按调用计费、必须显式指定才会运行的 `openai` 提供方;通用的 `image-gen` 技能只是覆在其上的一层薄封装)。视频使用**你自己的**凭据 —— `grok` CLI 登录或 `XAI_API_KEY` —— 仓库中不附带任何凭据([docs/video.md](docs/video.md))。 `sprite-gen` 支持 CPython 3.10+;CI 运行 3.10 与 3.14。快速开始需要一个 `venv`/`ensurepip` 可用的 Python。 diff --git a/SKILL.md b/SKILL.md index fabbe22..64d6aad 100644 --- a/SKILL.md +++ b/SKILL.md @@ -67,7 +67,8 @@ Pass choices already stated in the request. The guide checks access, combines ex | Standalone video / animate a still, pin a last frame, reference images | `video` (`--image`, `--last-frame`, `--reference`) | [video](docs/video.md) | | Continue or edit an existing clip | `video-extend`, `video-edit` | [video](docs/video.md) | | Grok video sprites | `video-set` | [video-pipeline](docs/video-pipeline.md) | -| Ordinary image or edit | `gen --provider codex` or `gen --provider grok` | [gen](docs/gen.md) | +| Ordinary image or edit | `gen --provider codex` or `gen --provider grok` (subscription routes) | [gen](docs/gen.md) | +| Image generation with no login available (server, container, SaaS) | `gen --provider openai` — server/SaaS route on `OPENAI_API_KEY`, **billed per call**, never a default or a fallback | [gen](docs/gen.md#subscription-first--openai-is-named-or-it-does-not-run) | | Base and direction anchors | `anchor` | [directional-anchor-workflow](docs/directional-anchor-workflow.md) | | Curation view or existing image candidates | `curation`, `unpack-atlas --pngs-dir` | [curation](docs/curation.md) | | Uniform background removal or imported sheets | `cutout`, `slice-sheet` | [sheet-slicing](docs/sheet-slicing.md) | diff --git a/docs/architecture.md b/docs/architecture.md index c6b44ce..7c6770d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ flowchart TB subgraph pkg["sprite_gen/ (_modules.py owns the mapping)"] direction LR spec["spec
read-only asset adapters · sprite-request schema · layout resolver · runio · migrations"] - gen["gen
prepare · gen (codex / grok) · gen-set · video (Grok Imagine)"] + gen["gen
prepare · gen (codex / grok / openai) · gen-set · video (Grok Imagine)"] video["video
canvas · frames · loop · batch (video-set)"] frames["frames
extract · cutout · slice-sheet · unpack-atlas · segment"] curate["curate
curation sidecar · direction anchors"] diff --git a/docs/gen.md b/docs/gen.md index 1309dba..2b4f068 100644 --- a/docs/gen.md +++ b/docs/gen.md @@ -1,6 +1,6 @@ # `sprite-gen gen` — provider-backed image generation (engine SSoT) -> Owns: `sprite-gen gen` / `gen-set`: providers, default resolution, transparency strategy per provider, row usage · Index: [docs/README.md](README.md) +> Owns: `sprite-gen gen` / `gen-set`: providers, default resolution, transparency strategy per provider, the billed knobs (`--quality`, `--resolution`), row usage · Index: [docs/README.md](README.md) Generation is a first-class engine module (`sprite_gen/gen/`), not an external skill. One call = a prompt (+ optional reference images) → one **verified** PNG on @@ -16,22 +16,45 @@ whose terms forbid reaching a subscription login from third-party software is no added, however it is invoked ([#36](https://github.com/aldegad/sprite-gen/pull/36), Antigravity). -| Provider | Backend | Auth | Output truth | Transparency strategy | -|---|---|---|---|---| -| `codex` | codex `image_gen` | ChatGPT OAuth | inline base64 in the session rollout jsonl, decoded deterministically | **`native`** — image_gen returns a real alpha channel when asked (measured, then published) | -| `grok` | direct Imagine `/images/generations` or `/images/edits` | Grok login or `XAI_API_KEY` | inline bytes decoded and re-encoded as a verified PNG | `chroma` — Imagine returns JPEG only; generate on a key and matte it out | +| Provider | Backend | Auth | Billing | Output truth | Transparency strategy | +|---|---|---|---|---|---| +| `codex` | codex `image_gen` | ChatGPT OAuth | your ChatGPT subscription | inline base64 in the session rollout jsonl, decoded deterministically | **`native`** — image_gen returns a real alpha channel when asked (measured, then published) | +| `grok` | direct Imagine `/images/generations` or `/images/edits` | Grok login (preferred) or `XAI_API_KEY` | your SuperGrok Imagine quota; console credit only on the key route | inline bytes decoded and re-encoded as a verified PNG | `chroma` — Imagine returns JPEG only; generate on a key and matte it out | +| `openai` | direct Images `/v1/images/generations` or `/v1/images/edits` | `OPENAI_API_KEY` | **billed per call** — for servers and SaaS | inline base64 decoded and published as a verified PNG | **`native`** — `background: transparent` returns a real alpha channel | The strategy is declared **once**, on the adapter (`Provider.transparency`), and is the only place that says what a backend can do. See [Transparent output](#transparent-output--strategy-per-provider). +## Subscription first — `openai` is named or it does not run + +`codex` and `grok` run on a subscription the user already pays for. `openai` +reaches the same family of GPT image models over the REST API with nothing but a +key — which is what a headless container (a Modal worker, CI, a SaaS backend) can +have, and where an interactive ChatGPT login cannot exist — and every call is +billed against that key. So it is registered, documented, and **explicit-only** +(maintainer 확정 2026-09-20): + +- it runs only when `--provider openai` names it; +- `SPRITE_GEN_DEFAULT_PROVIDER=openai` is **refused**, not honoured; +- the guided flow (`sprite-gen workflow`) never offers it and never saves it as a + preference; +- no availability fallback targets it — a codex outage reaches `grok`, another + subscription route, or it fails; it never reaches metered credit; +- having `OPENAI_API_KEY` in the environment changes no route by itself; +- every call it does make prints one stderr line naming the charge *before* the + request leaves. + +If you are one person generating sprites, use `codex` or `grok`. + ## Default provider selection `--provider` is **optional**. When omitted, the backend is resolved by a fixed precedence (maintainer 확정 2026-07-17): 1. **`SPRITE_GEN_DEFAULT_PROVIDER`** env var (`codex` or `grok`) — the user override. - An unknown value fails loud. + An unknown value fails loud, and so does `openai`: a per-call API backend is + named at the call site or not used ([subscription first](#subscription-first--openai-is-named-or-it-does-not-run)). 2. **`codex`** — the hard default (GPT `image_gen`). If the resolved default is `codex` but codex is unavailable here (CLI not on PATH, @@ -39,7 +62,8 @@ or `codex login status` reports not-logged-in), the resolution **falls back to `grok` — observably, never silently**: a stderr notice is printed and the report JSON records `provider_fallback` (`from`/`to`/`reason`/`default_source`). The grok default (`SPRITE_GEN_DEFAULT_PROVIDER=grok`) has no reverse fallback — a down -grok fails loud at generation time. +grok fails loud at generation time. The fallback target is a subscription route by +construction; `openai` is never one. An **explicit `--provider`** is always honored verbatim — it is never overridden by the availability fallback. An explicitly named provider that is down fails loud @@ -82,17 +106,68 @@ API contracts: [image generation](https://docs.x.ai/developers/model-capabilitie [image editing](https://docs.x.ai/developers/model-capabilities/images/editing), [multi-image editing](https://docs.x.ai/developers/model-capabilities/images/multi-image-editing). +## Direct OpenAI calls and authentication + +`sprite-gen gen --provider openai` → `OpenAIProvider` → the OpenAI Images REST API. +No `codex` CLI, no browser sign-in, no subprocess: the whole credential is +`OPENAI_API_KEY`, read from the environment at call time and never written +anywhere. Unset, the run stops naming the variable and where to get a key; set but +empty, it says so. It never falls back to the codex ChatGPT login, to another key, +or to a retry — and see [subscription first](#subscription-first--openai-is-named-or-it-does-not-run) +for why nothing routes here on its own. + +The default model is `gpt-image-2.5-flare`; `--model` selects another gpt-image +model. New images use `POST /v1/images/generations` (JSON); attaching `--ref` uses +`POST /v1/images/edits`, which takes **multipart/form-data** with the references as +repeated `image[]` parts in the order given (up to 16). A reference that is not a +readable PNG, JPEG or WebP fails before upload, and parts are named by position, not +by the local filename. + +gpt-image models always answer with inline base64 (the API does not accept +`response_format`), so there is no signed URL to follow and no bearer token to +forward. The payload is decoded, verified as a PNG and published atomically without +resizing; a missing, malformed or multi-image response fails without replacing an +existing raw output. HTTP 401/403 names the credential; any other non-200 fails +with the status only — an error body can echo the prompt or a key, so it is not +printed. + +`--aspect-ratio` picks the `size`. gpt-image has no long-edge preset, so the ratio +maps to one concrete size out of a table that satisfies the documented constraints +(both sides divisible by 16, ratio within 1:3..3:1, 655,360–8,294,400 pixels) and is +checked by the test suite: `1:1` 1024x1024, `3:2` 1536x1024, `2:3` 1024x1536, `4:3` +1024x768, `3:4` 768x1024, `16:9` 1536x864, `9:16` 864x1536, `2:1` 1440x720, `1:2` +720x1440, `3:1` 1536x512, `1:3` 512x1536, plus `auto`. Omitted = 1024x1024. A ratio +with no exact gpt-image size (`19.5:9`, say) is refused rather than rounded to a +nearby one you would be billed for. + +`--transparent` asks for `background: transparent` with `output_format: png` — +genuine alpha, the same `native` strategy as codex, and the same measurement gate +afterwards. Measured 2026-09-20 on `gpt-image-2.5-flare` at `--quality low`: 84 % of +the frame at alpha 0, the subject itself at alpha 251–254 (so `partial_alpha_pct` +covers the body, exactly as codex `image_gen` does — downstream extraction treats +`alpha ≤ 16` as transparent, so this is usable as-is). + +Image reports include `extra.auth_source`, `extra.transport: "openai-api"`, +`extra.endpoint`, `extra.quality`, `extra.size`, `extra.background` and, when the +API returns it, `extra.usage` (token counts only — what the call cost, never what it +contained). + +API contract: [OpenAI Images](https://developers.openai.com/api/images/) (field names +and enums confirmed there 2026-09-20). + ## CLI ```bash sprite-gen gen \ - [--provider codex|grok] # optional; default = SPRITE_GEN_DEFAULT_PROVIDER env → codex (observable grok fallback if codex is down) + [--provider codex|grok|openai] # optional; default = SPRITE_GEN_DEFAULT_PROVIDER env → codex (observable grok fallback if codex is down). openai only when named here — it bills per call --prompt "…" # or --prompt-file PROMPT.txt --out DEST.png \ - [--ref REF.png ...] # repeatable; Grok accepts up to five references + [--ref REF.png ...] # repeatable; Grok accepts up to five references, openai up to sixteen [--transparent [--alpha-mode auto|native|chroma] [--chroma-key magenta|green]] \ [--white-check CHECK.png] \ - [--aspect-ratio 1:1] # grok only, e.g. 1:1 or 16:9; single-ref edits inherit the source ratio + [--aspect-ratio 1:1] # grok and openai, e.g. 1:1 or 16:9; grok single-ref edits inherit the source ratio; openai maps it to a gpt-image size + [--quality low|medium|high|xhigh|max|auto] # billed effort; openai takes the range, grok takes auto/low/medium + [--resolution 1k|1.5k|2k] # grok output-size tier, priced with --quality [--model ID] \ [--report REPORT.json] \ [--keep-session] # codex: keep the rollout jsonl instead of deleting it @@ -110,7 +185,40 @@ Backward-compatible wrapper: `$SPRITE_GEN_ROOT/.venv/bin/python $SPRITE_GEN_ROOT `raw_bytes`, `elapsed_seconds`, `session_id` (codex), an `alpha` block (`strategy` + the measured stats), the `chroma` stats when chroma keying ran, and the provider-resolution fields (`provider_resolved_from`, and `provider_fallback` when a - codex→grok default fallback occurred). + codex→grok default fallback occurred), plus the provider `extra` block + (`auth_source`, `transport`, `endpoint`, and the knobs the request actually + carried). + +## `--quality` and `--resolution` — the two billed knobs + +They are one shared vocabulary; what a backend can honour is a per-provider +capability, declared in its adapter: + +| Provider | `--quality` | `--resolution` | +|---|---|---| +| `codex` | refused — `image_gen` exposes no effort dial | refused | +| `grok` | `auto` `low` `medium` | `1k` `1.5k` `2k` | +| `openai` | `auto` `low` `medium` `high` `xhigh` `max` | refused — a gpt-image `size` comes from `--aspect-ratio` | + +- **Omitted = the provider's own default.** The request body is then byte-identical + to what the same call already sent, so nothing about existing runs changes. +- **A level a provider cannot honour fails before the call.** It is never dropped + from a body you are about to pay for, and never downgraded to whatever the + backend felt like returning (No Silent Fallback). "Refused" above is a loud error + naming the flag, not a silently ignored argument. +- **grok prices an image on the pair**, so `2k` + `medium` costs more than `1k` + + `low`. The resolution names are **tiers, not pixel counts**: the service decides + the pixels, and `1.5k` came back 1408x1408 at `--aspect-ratio 1:1` (실측 + 2026-09-20) — not 1536, and not 1024. Nothing here derives a size from the name. +- The grok subsets were read off the server, not off the prose (2026-09-20 probes): + `quality` deserializes the wider shared enum and *then* refuses per model + (`high` → HTTP 400 "This model only supports the following quality value(s): + low, medium, auto."), `resolution: 1.5k` renders (HTTP 200) although the + capability guide's prose lists only 1k and 2k, and an **unknown field is not + refused at all** — a probe carrying one rendered normally. That last one is why + these names are checked locally instead of being left to the service to notice. +- Every call that spends API credit rather than a subscription prints the knobs it + is being billed on in the same stderr line as the charge. ## Transparent output — strategy per provider @@ -119,15 +227,17 @@ strategy it can execute, and `--alpha-mode auto` (the default) follows it: | Strategy | Who | What happens | Refused when | |---|---|---|---| -| `native` | `codex` (**first choice**, 2026-09-08) | The transport prompt asks image_gen for a genuinely transparent background (the bundled `imagegen` skill honours "transparent background" and keeps the generated alpha; codex reports `transparentBackground: true` on the completed item). The decoded PNG's alpha is **measured**: no alpha band or `alpha_zero_pct: 0.0` refuses to publish, RGB under alpha 0 is scrubbed, partial alpha (1–254) is left as produced and reported as `partial_alpha_pct`. | The model drew a checkerboard / flat background (RGB image) — nothing can recover alpha from that, so the run fails instead of silently keying. | +| `native` | `codex` (**first choice**, 2026-09-08), `openai` (`background: transparent`) | On codex the transport prompt asks image_gen for a genuinely transparent background (the bundled `imagegen` skill honours "transparent background" and keeps the generated alpha; codex reports `transparentBackground: true` on the completed item); on openai the request carries `background: transparent` with `output_format: png`, which the API answers with a real alpha channel. Either way the decoded PNG's alpha is **measured**: no alpha band or `alpha_zero_pct: 0.0` refuses to publish, RGB under alpha 0 is scrubbed, partial alpha (1–254) is left as produced and reported as `partial_alpha_pct`. | The model drew a checkerboard / flat background (RGB image) — nothing can recover alpha from that, so the run fails instead of silently keying. | | `chroma` | `grok` (only option), `codex` with `--alpha-mode chroma` | Generate on a `#FF00FF` (or `#00FF00`) background — pick the key by subject colour (magenta subjects → green key) — and matte it out through the frame extractor's canonical YCbCr matte (`remove_chroma_background_ycbcr`), which keys from the background chroma it detects on the borders (`detect_background_key_ycc`) rather than from the pure key alone — the RGB matte behind `cutout`/`extract`/`slice-sheet` does the same since 2026-09-11 (`detect_background_key_rgb`), so every chroma path tolerates the slightly-off green/magenta generators actually paint. Gradients and texture within that chroma family are supported. | `alpha_zero_pct: 0.0` after keying, or stale RGB under alpha 0. | -- **`auto` steps down to `chroma` when `--ref` is attached**, even on codex. Measured +- **`auto` steps down to `chroma` when `--ref` is attached**, on either native provider. Measured 2026-09-08 (plan `sprite-gen/parts-rig`): codex `image_gen` with reference images returned real alpha in 1/6 runs and drew a checkerboard (RGB) in 5/6, while the same prompts on a `#00FF00` key + chroma keying succeeded 6/6. The decision is made before the model runs, printed to stderr, and recorded as `alpha.strategy_source: - "refs-attached"` (`provider-default` / `explicit` otherwise). `--alpha-mode native` + "refs-attached"` (`provider-default` / `explicit` otherwise). The measurement is + codex's; openai's edit path is not separately measured and inherits the same + conservative default. `--alpha-mode native` still forces native alpha with refs — and fails loud on an RGB result. So a ref run's prompt must carry the chroma key, exactly as the sprite-row pipeline already does. - `--alpha-mode chroma` on codex is for prompts that already carry a key background @@ -176,6 +286,10 @@ change it; and no `config.toml` feature toggle grants it. The remedy is to point (`codex login status`), or to use `--provider grok`. The adapter fails loudly with exactly that, rather than falling back on its own. - **grok** — uses the [direct API contract](#direct-grok-calls-and-authentication) above. +- **openai** — uses the [direct API contract](#direct-openai-calls-and-authentication) + above: one HTTPS request on `OPENAI_API_KEY`, no CLI and no subprocess, which is + the whole reason it exists (a container has a key, not a browser). Explicit-only, + and billed per call. ## Sprite-row usage diff --git a/docs/video.md b/docs/video.md index acc7673..007911c 100644 --- a/docs/video.md +++ b/docs/video.md @@ -66,7 +66,9 @@ Resolution order is fixed for both images and videos: **the Grok subscription login wins**, even when `XAI_API_KEY` is set. `GROK_HOME` relocates `~/.grok`. Only when no login file exists can the configured API key use console credits. An expired, unreadable, corrupt or API-rejected login stops the request; it never -switches to API credit. An empty API key is ignored when the login is usable, +switches to API credit. A clip that does leave on `XAI_API_KEY` says so on stderr +before it uploads — one line per submitted job, naming the duration and resolution +it is billed on; the subscription route stays silent. An empty API key is ignored when the login is usable, but is an error when no login exists. With neither credential, the run stops with both setup paths spelled out. diff --git a/sprite_gen/cli.py b/sprite_gen/cli.py index 0fff49e..08a6ea3 100644 --- a/sprite_gen/cli.py +++ b/sprite_gen/cli.py @@ -293,7 +293,7 @@ def _add_correction_loop(p: argparse.ArgumentParser) -> None: slice_sheet.run, ), "gen": ( - "Generate one image via a provider (codex image_gen / grok Imagine) into a verified PNG.", + "Generate one image via a provider (codex image_gen / grok Imagine on a subscription login, or openai Images API for servers/SaaS, billed per call) into a verified PNG.", _add_gen, gen.run, ), diff --git a/sprite_gen/gen/__init__.py b/sprite_gen/gen/__init__.py index 82e760c..579e223 100644 --- a/sprite_gen/gen/__init__.py +++ b/sprite_gen/gen/__init__.py @@ -2,20 +2,34 @@ """Unified image generation layer for sprite-gen. Single source of truth for provider-backed image generation: codex (`image_gen`, -ChatGPT OAuth) and grok (Imagine, xAI OAuth). One call = prompt (+ optional refs) --> one verified raw PNG, with an optional deterministic transparent chroma -post-process. The general `image-gen` skill is a thin shuttle over `sprite-gen gen`. +ChatGPT OAuth), grok (Imagine, xAI OAuth) and openai (Images REST, OPENAI_API_KEY). +One call = prompt (+ optional refs) -> one verified raw PNG, with an optional +deterministic transparent chroma post-process. The general `image-gen` skill is a +thin shuttle over `sprite-gen gen`. + +codex and openai reach the same family of GPT image models by different routes: +codex needs an interactive ChatGPT login and the `codex` CLI on PATH, openai needs +only an API key, which is what a headless container (Modal worker, CI) can have. + +sprite-gen is subscription-first (수홍 2026-09-20). openai exists for servers and +SaaS and is billed per call, so it runs ONLY when `--provider openai` names it: it +is never a default, never a saved preference, never an offered choice in the guided +flow, and never the target of an availability fallback. Having OPENAI_API_KEY in +the environment changes no route by itself. Transparency is a per-provider strategy (`Provider.transparency`, declared once -in each adapter): codex `image_gen` returns a genuinely transparent PNG when asked -(`native`), grok Imagine cannot and is keyed out of a chroma background (`chroma`). -`--transparent` follows the provider's strategy unless `--alpha-mode` overrides it. +in each adapter): codex `image_gen` and openai (`background: transparent`) return a +genuinely transparent PNG when asked (`native`), grok Imagine cannot and is keyed +out of a chroma background (`chroma`). `--transparent` follows the provider's +strategy unless `--alpha-mode` overrides it. CLI: - sprite-gen gen --provider codex|grok --prompt "..." --out DEST.png + sprite-gen gen --provider codex|grok|openai --prompt "..." --out DEST.png [--ref REF.png ...] [--transparent [--alpha-mode auto|native|chroma] [--chroma-key magenta|green]] [--white-check CHECK.png] [--model ID] - [--aspect-ratio 1:1] [--report REPORT.json] [--keep-session] + [--aspect-ratio 1:1] [--quality low|medium|high|xhigh|max|auto] + [--resolution 1k|1.5k|2k] + [--report REPORT.json] [--keep-session] """ from __future__ import annotations @@ -34,6 +48,8 @@ from . import chroma as chroma_mod from .base import ( + QUALITIES, + RESOLUTIONS, TRANSPARENCY_CHROMA, TRANSPARENCY_NATIVE, TRANSPARENCY_STRATEGIES, @@ -46,8 +62,9 @@ ) from .codex_provider import CodexProvider from .grok_provider import GrokProvider +from .openai_provider import OpenAIProvider -PROVIDERS = ("codex", "grok") +PROVIDERS = ("codex", "grok", "openai") # `--alpha-mode`: `auto` reads the provider's declared strategy (the SSoT); # `native` / `chroma` force one. Forcing `native` on a chroma-only provider fails # loud — a strategy the backend cannot execute is not a fallback candidate. @@ -63,6 +80,10 @@ # explicitly named provider that is down fails loud at generation time. DEFAULT_PROVIDER_ENV = "SPRITE_GEN_DEFAULT_PROVIDER" HARD_DEFAULT_PROVIDER = "codex" +# Providers that may be reached without being named. A per-call API-billed backend +# is not one of them: it is explicit-only, so no default, preference or fallback +# can route a subscription user onto metered credit (구독 우선 불변식 1-2). +EXPLICIT_ONLY_PROVIDERS = ("openai",) _CODEX_PROBE_TIMEOUT_SECONDS = 15 @@ -71,6 +92,8 @@ def _make_provider(name: str, *, keep_session: bool): return CodexProvider(keep_session=keep_session) if name == "grok": return GrokProvider() + if name == "openai": + return OpenAIProvider() raise SystemExit(f"gen: unknown provider {name!r}; expected one of {', '.join(PROVIDERS)}") @@ -149,6 +172,10 @@ def resolve_default_provider() -> tuple[str, dict[str, str] | None]: resolved default is codex but codex is unavailable, fall back to grok and return fallback metadata (from/to/reason/default_source) so the switch is observable. Returns (provider, fallback_or_None). + + An EXPLICIT_ONLY provider can never come out of here: not as the hard default, + not out of the env, and not as a fallback target. A codex outage reaches grok + (another subscription route) or nothing at all — never metered API credit. """ configured = os.environ.get(DEFAULT_PROVIDER_ENV, "").strip() if configured: @@ -157,13 +184,21 @@ def resolve_default_provider() -> tuple[str, dict[str, str] | None]: f"gen: {DEFAULT_PROVIDER_ENV}={configured!r} is not a known provider; " f"expected one of {', '.join(PROVIDERS)}" ) + if configured in EXPLICIT_ONLY_PROVIDERS: + raise SystemExit( + f"gen: {DEFAULT_PROVIDER_ENV}={configured!r} is refused — {configured} bills per call " + f"against an API key and must be named explicitly (`--provider {configured}`), never " + f"stood up as a default. Use {', '.join(p for p in PROVIDERS if p not in EXPLICIT_ONLY_PROVIDERS)} " + "for a subscription route." + ) default, source = configured, DEFAULT_PROVIDER_ENV else: default, source = HARD_DEFAULT_PROVIDER, "hard-default" - # The availability-driven fallback is codex -> grok only (the mandated default). - # A grok default that is down fails loud at generation time rather than silently - # reverse-falling-back to codex. + # The availability-driven fallback is codex -> grok only (the mandated default): + # one subscription route to another, never to a per-call API key. A grok default + # that is down fails loud at generation time rather than silently reverse-falling + # back to codex. if default == "codex": ok, reason = _codex_available() if not ok: @@ -210,6 +245,8 @@ def generate_image( refs: list[Path] | None = None, model: str | None = None, aspect_ratio: str | None = None, + quality: str | None = None, + resolution: str | None = None, transparent: bool = False, alpha_mode: str = ALPHA_MODE_AUTO, chroma_key: str = "magenta", @@ -254,6 +291,8 @@ def generate_image( refs=refs, model=model, aspect_ratio=aspect_ratio, + quality=quality, + resolution=resolution, native_alpha=strategy == TRANSPARENCY_NATIVE, ) # 타임아웃 1회 관측 가능 재시도 — 산발 provider 스톨은 같은 호출 재시도로 @@ -341,6 +380,8 @@ def _run(args: argparse.Namespace) -> int: refs=args.ref, model=args.model, aspect_ratio=args.aspect_ratio, + quality=args.quality, + resolution=args.resolution, transparent=args.transparent, alpha_mode=args.alpha_mode, chroma_key=args.chroma_key, @@ -396,7 +437,9 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: default=None, help=( f"generation backend; default resolves via {DEFAULT_PROVIDER_ENV} env " - "then codex, with an observable grok fallback if codex is unavailable" + "then codex, with an observable grok fallback if codex is unavailable. " + "codex and grok run on a subscription login; openai is for servers and SaaS " + "and is billed per call on OPENAI_API_KEY, so it runs only when named here" ), ) parser.add_argument("--prompt") @@ -404,13 +447,37 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--out", required=True, type=Path) parser.add_argument("--ref", action="append", type=Path, default=[], help="reference image (repeatable)") parser.add_argument("--model") - parser.add_argument("--aspect-ratio", help="grok only, e.g. 1:1 16:9 9:16") + parser.add_argument("--aspect-ratio", help="grok and openai, e.g. 1:1 16:9 9:16 (openai maps it to a gpt-image size; codex ignores it)") + parser.add_argument( + "--quality", + choices=QUALITIES, + default=None, + help=( + "rendering effort billed for this image; openai carries the whole range " + "(low..max, auto = the model decides) and grok takes low / medium / auto. " + "Omitted = the provider's own default. A provider that cannot honour the " + "level fails instead of downgrading it" + ), + ) + parser.add_argument( + "--resolution", + choices=RESOLUTIONS, + default=None, + help=( + "output size tier, priced together with --quality: grok Imagine's " + "1k / 1.5k / 2k (tier names, not pixel counts — 1.5k renders 1408px at 1:1). " + "Omitted = the provider's own default (grok: 1k). openai sizes from " + "--aspect-ratio and codex from neither, so both refuse this flag " + "instead of ignoring it" + ), + ) parser.add_argument( "--transparent", action="store_true", help=( "publish a transparent RGBA PNG using the provider's transparency strategy: " - "codex asks image_gen for real alpha (native), grok is keyed out of a chroma background" + "codex asks image_gen for real alpha and openai asks for background=transparent (native), " + "grok is keyed out of a chroma background" ), ) parser.add_argument( @@ -419,7 +486,7 @@ def add_arguments(parser: argparse.ArgumentParser) -> None: default=ALPHA_MODE_AUTO, help=( "transparency strategy for --transparent: auto = the provider's declared strategy " - "(native on codex, but chroma whenever --ref is attached — native alpha with refs is unstable); " + "(native on codex and openai, but chroma whenever --ref is attached — native alpha with refs is unstable); " "chroma forces chroma keying (e.g. a codex prompt that already carries a key background); " "native forces native alpha and is refused on a provider that cannot return alpha" ), diff --git a/sprite_gen/gen/base.py b/sprite_gen/gen/base.py index 2b9f4f5..be8b083 100644 --- a/sprite_gen/gen/base.py +++ b/sprite_gen/gen/base.py @@ -10,12 +10,17 @@ from __future__ import annotations +import io import os import shutil +import sys +import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any, Protocol +from PIL import Image, UnidentifiedImageError + PNG_MAGIC = b"\x89PNG\r\n\x1a\n" # Transparency strategy — a capability each provider declares exactly once @@ -29,6 +34,23 @@ TRANSPARENCY_CHROMA = "chroma" TRANSPARENCY_STRATEGIES = (TRANSPARENCY_NATIVE, TRANSPARENCY_CHROMA) +# Quality is a per-provider capability over one shared vocabulary. This tuple is +# the `--quality` surface (the union); each provider declares the subset it can +# honour and fails loud on a name outside it, so a paid-for level is never +# quietly downgraded to whatever the backend felt like (No Silent Fallback). +QUALITIES = ("auto", "low", "medium", "high", "xhigh", "max") + +# Output resolution — the second billed knob, over one shared vocabulary of grok +# Imagine's output-size tiers, which it prices together with `quality`. The names +# are tiers, not pixel counts: `1.5k` rendered 1408x1408 at 1:1, not 1536 +# (2026-09-20 실측 on the XAI_API_KEY route), so nothing here derives a size from +# the name — the tier goes to the service verbatim and the service sizes. A +# provider that sizes differently (openai derives a gpt-image `size` from +# `--aspect-ratio`) refuses the flag rather than accepting money for a resolution +# it will not deliver. Distinct from the video RESOLUTIONS +# (`480p`/`720p`/`1080p`), which names a frame height. +RESOLUTIONS = ("1k", "1.5k", "2k") + # Child provider processes are independent execution contexts. They must not # inherit parent orchestration identity or lifecycle controls, while ordinary # variables such as PATH remain available. Suffix matching keeps the contract @@ -81,6 +103,21 @@ def provider_binary(name: str) -> str: return shutil.which(name) or name +def announce_api_billing(provider: str, env_name: str, detail: str = "") -> None: + """Say on stderr, before the request leaves, that this call bills API credit. + + sprite-gen is a subscription-first tool: codex runs on a ChatGPT login and + grok prefers its Grok login. A route that instead spends metered API credit + is never silent about it, because the person paying only finds out otherwise + on the invoice (수홍 2026-09-20, 구독 우선 불변식 5). + """ + print( + f"[gen] {provider}: running on {env_name} — this is a per-call API charge, " + f"not a subscription{detail}", + file=sys.stderr, + ) + + def verify_png(path: Path) -> int: """Return the PNG byte count, or raise SystemExit if it is missing/not a PNG.""" if not path.is_file(): @@ -99,13 +136,51 @@ class GenRequest: raw: Path # provider writes the generated PNG (chroma background included) here refs: list[Path] = field(default_factory=list) model: str | None = None - aspect_ratio: str | None = None # grok honours this; codex ignores it + aspect_ratio: str | None = None # grok honours this; openai maps it to a size; codex ignores it + # Rendering effort/fidelity level, from QUALITIES. None = the provider's own + # default; a level the provider does not support is an error, not a downgrade. + quality: str | None = None + # Output resolution, from RESOLUTIONS. None = the provider's own default + # (grok: `1k`); a provider that cannot size its output this way is an error, + # not a downgrade. + resolution: str | None = None # Ask the model for a genuinely transparent background (alpha channel). Only # legal for a provider whose `transparency` is `native`; the orchestrator gates # it and the provider carries the request into its transport prompt. native_alpha: bool = False +def publish_png(data: bytes, path: Path, *, label: str) -> None: + """Publish decoded provider image bytes as a verified PNG at `path`. + + One writer for every provider: decode first (a corrupt or non-image payload + fails before anything is published), re-encode only when the payload is not + already a PNG — grok Imagine answers with JPEG — then verify the bytes and + swap them in atomically. Nothing is resized. A failure leaves whatever was at + `path` untouched, so a stale raw is never reused as a result. + """ + try: + with Image.open(io.BytesIO(data)) as source: + source.load() + if source.format == "PNG": + png = data + else: + buffer = io.BytesIO() + source.convert("RGBA" if "A" in source.getbands() else "RGB").save(buffer, format="PNG") + png = buffer.getvalue() + except (OSError, ValueError, UnidentifiedImageError) as exc: + raise SystemExit(f"{label}: response image is invalid; nothing published") from exc + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".png", delete=False) as tmp: + temp = Path(tmp.name) + try: + temp.write_bytes(png) + verify_png(temp) + os.replace(temp, path) + finally: + temp.unlink(missing_ok=True) + + @dataclass class ProviderRun: """What a provider reports after writing `request.raw`.""" diff --git a/sprite_gen/gen/codex_provider.py b/sprite_gen/gen/codex_provider.py index 991fb75..a2ead18 100644 --- a/sprite_gen/gen/codex_provider.py +++ b/sprite_gen/gen/codex_provider.py @@ -296,6 +296,15 @@ def __init__(self, *, keep_session: bool = False) -> None: self.keep_session = keep_session def generate(self, request: GenRequest, workdir: Path) -> ProviderRun: + # `image_gen` exposes no effort or size dial, so neither knob is carried + # into the prompt. Refusing beats returning a default-effort image under a + # level the caller chose and believes they got (No Silent Fallback). + for flag, value in (("--quality", request.quality), ("--resolution", request.resolution)): + if value is not None: + raise SystemExit( + f"codex-gen: {flag} is not carried into codex image_gen; " + "drop it or use --provider openai" + ) codex_home = _resolve_codex_home() if not codex_home.is_dir(): raise SystemExit( diff --git a/sprite_gen/gen/grok_provider.py b/sprite_gen/gen/grok_provider.py index 4d54041..9960a02 100644 --- a/sprite_gen/gen/grok_provider.py +++ b/sprite_gen/gen/grok_provider.py @@ -1,22 +1,52 @@ # SPDX-License-Identifier: Apache-2.0 -"""Direct Grok Imagine image generation/editing, with no Grok Build process.""" +"""Direct Grok Imagine image generation/editing, with no Grok Build process. + +`quality` and `resolution` are the two knobs Imagine prices an image on +(docs.x.ai, 2026-09-20 확인): quality for grok-imagine-image-2.0 only, resolution +naming an output-size tier (a tier, not a pixel count — `1.5k` rendered 1408x1408 +at 1:1). Both are sent only when the caller asks for one, so a request that names +neither stays exactly what it was. +""" from __future__ import annotations import base64 import binascii import io -import os -import tempfile import time from pathlib import Path from PIL import Image, UnidentifiedImageError from . import xai -from .base import GEN_TIMEOUT_SECONDS, TRANSPARENCY_CHROMA, GenRequest, ProviderRun, verify_png +from .base import ( + GEN_TIMEOUT_SECONDS, + RESOLUTIONS, + TRANSPARENCY_CHROMA, + GenRequest, + ProviderRun, + announce_api_billing, + publish_png, +) DEFAULT_MODEL = "grok-imagine-image-2.0" MAX_REFS = 5 +# The subset of the shared `--quality` vocabulary Imagine can honour. Anything +# finer belongs to another backend, so it is refused here rather than billed as +# whatever the service picked. Omitted = `auto`, which the service resolves to +# low for generations and medium for edits. +# +# Both sets were read off the server, not the prose (2026-09-20 probes on the +# subscription route). Deserialization alone is not the answer: `quality` accepts +# `high` into the shared enum and then refuses it per model — +# HTTP 400 "This model only supports the following quality value(s): low, +# medium, auto." +# — so `high` is refused here instead of costing a round-trip. `resolution` 1.5k +# is served (HTTP 200) although the capability guide's prose lists only 1k and 2k; +# the REST schema enum has all three. An unknown field is not refused at all (a +# probe carrying one rendered normally), which is exactly why these names are +# checked here rather than left to the service to notice. +SUPPORTED_QUALITIES = ("auto", "low", "medium") +SUPPORTED_RESOLUTIONS = RESOLUTIONS ASPECT_RATIOS = ("auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "19.5:9", "9:19.5", "20:9", "9:20", "21:9", "5:2") @@ -41,10 +71,29 @@ def _request_body(request: GenRequest) -> tuple[str, dict]: raise SystemExit(f"grok-gen: at most {MAX_REFS} reference images are supported") if request.aspect_ratio is not None and request.aspect_ratio not in ASPECT_RATIOS: raise SystemExit(f"grok-gen: unsupported aspect ratio {request.aspect_ratio!r}") + # Both knobs are declared capabilities, not hints: a level outside what + # Imagine serves fails here instead of being dropped from the body and billed + # as whatever the service picked (No Silent Fallback). + if request.quality is not None and request.quality not in SUPPORTED_QUALITIES: + raise SystemExit( + f"grok-gen: unsupported quality {request.quality!r}; grok Imagine takes " + f"{', '.join(SUPPORTED_QUALITIES)} — a finer level belongs to --provider openai" + ) + if request.resolution is not None and request.resolution not in SUPPORTED_RESOLUTIONS: + raise SystemExit( + f"grok-gen: unsupported resolution {request.resolution!r}; " + f"expected one of {', '.join(SUPPORTED_RESOLUTIONS)}" + ) body = {"model": request.model or DEFAULT_MODEL, "prompt": request.prompt, "n": 1, "response_format": "b64_json"} if request.aspect_ratio and len(request.refs) != 1: body["aspect_ratio"] = request.aspect_ratio + # Sent only when asked for. An omitted knob keeps the service default and the + # request identical to what the same call was already billed for. + if request.quality is not None: + body["quality"] = request.quality + if request.resolution is not None: + body["resolution"] = request.resolution if request.refs: images = [_reference(Path(ref)) for ref in request.refs] if len(images) == 1: @@ -56,28 +105,16 @@ def _request_body(request: GenRequest) -> tuple[str, dict]: def _publish_image(item: dict, path: Path) -> None: - # Inline bytes avoid signed download URLs and bearer forwarding. + # Inline bytes avoid signed download URLs and bearer forwarding. Imagine may + # answer with JPEG; `publish_png` re-encodes it without resizing. encoded = item.get("b64_json") if not isinstance(encoded, str) or not encoded: raise SystemExit("grok-gen: response has no b64_json image; nothing published") try: data = base64.b64decode(encoded, validate=True) - with Image.open(io.BytesIO(data)) as source: - source.load() - # Imagine may return JPEG. Encode a real PNG without resizing. - png = io.BytesIO() - source.convert("RGBA" if "A" in source.getbands() else "RGB").save(png, format="PNG") - except (ValueError, binascii.Error, OSError, UnidentifiedImageError) as exc: + except (ValueError, binascii.Error) as exc: raise SystemExit("grok-gen: response image is invalid; nothing published") from exc - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".png", delete=False) as tmp: - temp = Path(tmp.name) - try: - temp.write_bytes(png.getvalue()) - verify_png(temp) - os.replace(temp, path) - finally: - temp.unlink(missing_ok=True) + publish_png(data, path, label="grok-gen") class GrokProvider: @@ -88,7 +125,17 @@ def generate(self, request: GenRequest, workdir: Path) -> ProviderRun: if request.native_alpha: raise SystemExit("grok-gen: grok Imagine cannot return an alpha channel; generate on a chroma key instead") endpoint, body = _request_body(request) + # The login is preferred (xai.resolve_credential); reaching the key means no + # login exists, and that spends API credit instead of the Grok subscription. credential = xai.resolve_credential() + # What this image is priced on, named in the same breath as the charge: + # Imagine bills quality x resolution, so 2k medium costs twice 1k low. + priced = ", ".join(f"{knob}={value}" for knob, value in + (("quality", request.quality), ("resolution", request.resolution)) if value) + if credential.source == xai.AUTH_SOURCE_API_KEY: + announce_api_billing(self.name, xai.AUTH_ENV, + f"{f' ({priced})' if priced else ''} — " + f"`{xai.GROK_LOGIN_COMMAND}` signs your Grok subscription in.") started = time.monotonic() status, reply = xai.http_json("POST", xai.API_BASE + endpoint, credential.token, body, timeout=GEN_TIMEOUT_SECONDS) @@ -97,8 +144,11 @@ def generate(self, request: GenRequest, workdir: Path) -> ProviderRun: else f"check {xai.AUTH_ENV}") raise SystemExit(f"grok-gen: credential {credential.source} rejected (HTTP {status}); {remedy}") # Error bodies can include prompts, credentials or URLs; do not echo them. + # The one thing worth naming is the combination the docs scope to + # grok-imagine-image-2.0, which another model is entitled to reject. if status != 200: - raise SystemExit(f"grok-gen: image request failed (HTTP {status}); no retry or provider fallback") + scoped = f" (sent with {priced}, documented for {DEFAULT_MODEL})" if priced and body["model"] != DEFAULT_MODEL else "" + raise SystemExit(f"grok-gen: image request failed (HTTP {status}); no retry or provider fallback{scoped}") items = reply.get("data") if isinstance(reply, dict) else None if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): raise SystemExit("grok-gen: expected exactly one image in the response; nothing published") @@ -106,4 +156,7 @@ def generate(self, request: GenRequest, workdir: Path) -> ProviderRun: return ProviderRun(provider=self.name, elapsed_seconds=time.monotonic() - started, model=body["model"], extra={"auth_source": credential.source, "transport": "xai-api", "endpoint": endpoint, - "aspect_ratio_source": "reference" if len(request.refs) == 1 else "request-or-auto"}) + "aspect_ratio_source": "reference" if len(request.refs) == 1 else "request-or-auto", + # Absent = the service default, which the report must not + # invent a name for (`auto` differs per endpoint). + **{knob: body[knob] for knob in ("quality", "resolution") if knob in body}}) diff --git a/sprite_gen/gen/openai_provider.py b/sprite_gen/gen/openai_provider.py new file mode 100644 index 0000000..a35fbdd --- /dev/null +++ b/sprite_gen/gen/openai_provider.py @@ -0,0 +1,294 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Direct OpenAI Images generation/editing with an API key and no ChatGPT login. + +The `codex` provider reaches the same family of models through the ChatGPT +subscription (an interactive OAuth login and a `codex` CLI on PATH). Neither +exists inside a headless container, so this adapter talks to the REST endpoints +with `OPENAI_API_KEY` alone: `POST /v1/images/generations`, or +`POST /v1/images/edits` (multipart) when reference images are attached. + +Field names and enums are the documented Images API surface (developers.openai.com, +2026-09-20 확인): gpt-image models always answer with base64 (`response_format` is +not accepted), `quality` is low|medium|high|xhigh|max|auto, `background` is +transparent|opaque|auto and transparency needs `output_format` png or webp, and a +custom `size` must have both sides divisible by 16, an aspect ratio within +1:3..3:1, and a total pixel count between 655,360 and 8,294,400. + +This backend is for servers and SaaS and is billed per call, so sprite-gen keeps it +explicit-only (구독 우선 불변식, 수홍 2026-09-20): it runs when `--provider openai` +names it and never as a default, a saved preference, a guided-flow option or a +fallback target, and every call announces the charge on stderr before it leaves. +Personal use belongs on a subscription provider (`codex`, `grok`). + +This provider never falls back to codex, to another key, or to a retry: a missing +credential, a rejected key or a failed request is the observable outcome. +""" +from __future__ import annotations + +import base64 +import binascii +import io +import json +import os +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + +from PIL import Image, UnidentifiedImageError + +from .base import ( + GEN_TIMEOUT_SECONDS, + QUALITIES, + TRANSPARENCY_NATIVE, + GenRequest, + ProviderRun, + announce_api_billing, + publish_png, +) + +API_BASE = "https://api.openai.com/v1" +AUTH_ENV = "OPENAI_API_KEY" +AUTH_SOURCE_API_KEY = AUTH_ENV + +DEFAULT_MODEL = "gpt-image-2.5-flare" +# The documented default is `auto` (the model picks the effort). It is sent +# explicitly so the billed level is always visible in the request and the report. +DEFAULT_QUALITY = "auto" +# gpt-image-2.5 accepts the whole shared vocabulary. +SUPPORTED_QUALITIES = QUALITIES +# "For GPT image models, you can provide up to 16 images." (images/edits `images`) +MAX_REFS = 16 +PNG_OUTPUT_FORMAT = "png" +BACKGROUND_TRANSPARENT = "transparent" +BACKGROUND_OPAQUE = "opaque" + +# `--aspect-ratio` -> a concrete gpt-image `size`. Every entry satisfies the +# documented constraints (both sides divisible by 16, ratio inside 1:3..3:1, +# 655,360 <= pixels <= 8,294,400) and is checked by the test suite, so the ratio +# a caller asks for is the ratio that is billed and returned. A ratio that has no +# exact gpt-image size is refused rather than quietly rounded to a nearby one. +SIZES = { + "auto": "auto", + "1:1": "1024x1024", + "3:2": "1536x1024", + "2:3": "1024x1536", + "4:3": "1024x768", + "3:4": "768x1024", + "16:9": "1536x864", + "9:16": "864x1536", + "2:1": "1440x720", + "1:2": "720x1440", + "3:1": "1536x512", + "1:3": "512x1536", +} +DEFAULT_SIZE = SIZES["1:1"] + +_REFERENCE_MIMES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"} + + +def resolve_credential(env: dict[str, str] | None = None) -> str: + """Return the OpenAI API key, or fail loud naming the variable that is missing.""" + env = os.environ if env is None else env + key = (env.get(AUTH_ENV) or "").strip() + if key: + return key + if AUTH_ENV in env: + raise SystemExit( + f"openai-gen: {AUTH_ENV} is set but empty; give it a value. " + "This provider never falls back to the codex ChatGPT login." + ) + raise SystemExit( + f"openai-gen: no OpenAI credential — {AUTH_ENV} is not set.\n" + f" export {AUTH_ENV}= (platform.openai.com/api-keys). " + "This provider never falls back to the codex ChatGPT login, which needs an " + "interactive browser sign-in that a container does not have." + ) + + +def _reference(path: Path, index: int) -> tuple[str, str, bytes]: + """Read one reference image as a (filename, mime, bytes) multipart part. + + The part is named by position, not by the local file: a user's filename is + their data, and a name carrying a quote or a newline would forge the part + header it is written into. + """ + try: + data = path.read_bytes() + with Image.open(io.BytesIO(data)) as source: + source.load() + mime = Image.MIME.get(source.format) + if mime not in _REFERENCE_MIMES: + raise ValueError("reference must be PNG, JPEG or WebP") + except (OSError, ValueError, UnidentifiedImageError) as exc: + raise SystemExit(f"openai-gen: invalid reference image {path}") from exc + return f"image-{index}.{_REFERENCE_MIMES[mime]}", mime, data + + +def _fields(request: GenRequest) -> dict[str, str]: + """The request fields shared by generations and edits, validated offline. + + Everything that can be refused without spending money is refused here, before + a credential is read or a socket is opened. + """ + if not request.prompt.strip(): + raise SystemExit("openai-gen: empty prompt") + if len(request.refs) > MAX_REFS: + raise SystemExit(f"openai-gen: at most {MAX_REFS} reference images are supported") + # `size` here comes from --aspect-ratio (SIZES); gpt-image has no long-edge + # preset, so an asked-for resolution is refused instead of being dropped from + # a body the caller is about to pay for (No Silent Fallback). + if request.resolution is not None: + raise SystemExit( + f"openai-gen: resolution {request.resolution!r} is grok Imagine's vocabulary; " + "a gpt-image size comes from --aspect-ratio" + ) + quality = request.quality or DEFAULT_QUALITY + if quality not in SUPPORTED_QUALITIES: + raise SystemExit( + f"openai-gen: unsupported quality {quality!r}; expected one of {', '.join(SUPPORTED_QUALITIES)}" + ) + if request.aspect_ratio is None: + size = DEFAULT_SIZE + elif request.aspect_ratio in SIZES: + size = SIZES[request.aspect_ratio] + else: + raise SystemExit( + f"openai-gen: aspect ratio {request.aspect_ratio!r} has no gpt-image size; " + f"expected one of {', '.join(SIZES)}" + ) + return { + "model": request.model or DEFAULT_MODEL, + "prompt": request.prompt, + "n": "1", + "size": size, + "quality": quality, + # png keeps the alpha channel that `background: transparent` produces. + "output_format": PNG_OUTPUT_FORMAT, + "background": BACKGROUND_TRANSPARENT if request.native_alpha else BACKGROUND_OPAQUE, + } + + +def _json_request(fields: dict[str, str]) -> tuple[bytes, str]: + body = {**fields, "n": int(fields["n"])} + return json.dumps(body).encode("utf-8"), "application/json" + + +def _multipart_request(fields: dict[str, str], refs: list[Path]) -> tuple[bytes, str]: + """Encode the edits request; `/v1/images/edits` takes multipart, not JSON. + + Reference images go in as repeated `image[]` parts, in the order they were + given, because the model reads them positionally. + """ + boundary = "----sprite-gen-" + uuid.uuid4().hex + marker = f"--{boundary}\r\n".encode("utf-8") + chunks: list[bytes] = [] + for name, value in fields.items(): + chunks.append(marker) + chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8")) + chunks.append(value.encode("utf-8") + b"\r\n") + for index, ref in enumerate(refs): + filename, mime, data = _reference(ref, index) + chunks.append(marker) + chunks.append( + f'Content-Disposition: form-data; name="image[]"; filename="{filename}"\r\n' + f"Content-Type: {mime}\r\n\r\n".encode("utf-8") + ) + chunks.append(data + b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + return b"".join(chunks), f"multipart/form-data; boundary={boundary}" + + +def http_image(url: str, token: str, data: bytes, content_type: str, *, timeout: float) -> tuple[int, Any]: + """POST an images request and return (status, parsed JSON). + + A transport error is terminal: the server may already have accepted and billed + the request, so it is never retried here. + """ + request = urllib.request.Request(url, data=data, method="POST") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + request.add_header("Content-Type", content_type) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return response.status, (json.loads(raw) if raw else {}) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise SystemExit("openai-gen: response was not valid JSON") from exc + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + try: + return exc.code, json.loads(raw) + except json.JSONDecodeError: + return exc.code, {"raw": raw[:400]} + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise SystemExit( + "openai-gen: request failed or timed out; no automatic retry (the server may have accepted it)" + ) from exc + + +def _publish_image(item: dict, path: Path) -> None: + # gpt-image models always answer with inline base64 — no signed URL to follow + # and no bearer token to forward. + encoded = item.get("b64_json") + if not isinstance(encoded, str) or not encoded: + raise SystemExit("openai-gen: response has no b64_json image; nothing published") + try: + data = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as exc: + raise SystemExit("openai-gen: response image is invalid; nothing published") from exc + publish_png(data, path, label="openai-gen") + + +class OpenAIProvider: + name = "openai" + # `background: transparent` returns a real alpha channel on gpt-image-2.5. + transparency = TRANSPARENCY_NATIVE + + def generate(self, request: GenRequest, workdir: Path) -> ProviderRun: + fields = _fields(request) + refs = [Path(ref) for ref in request.refs] + if refs: + endpoint = "/images/edits" + data, content_type = _multipart_request(fields, refs) + else: + endpoint = "/images/generations" + data, content_type = _json_request(fields) + token = resolve_credential() + announce_api_billing( + self.name, + AUTH_ENV, + f" (model={fields['model']}, quality={fields['quality']}, size={fields['size']}).", + ) + started = time.monotonic() + status, reply = http_image(API_BASE + endpoint, token, data, content_type, timeout=GEN_TIMEOUT_SECONDS) + if status in (401, 403): + raise SystemExit( + f"openai-gen: credential {AUTH_SOURCE_API_KEY} rejected (HTTP {status}); " + f"check the key's value and that its project may call {fields['model']}" + ) + # Error bodies can echo the prompt, a key or a signed URL; do not print them. + if status != 200: + raise SystemExit(f"openai-gen: image request failed (HTTP {status}); no retry or provider fallback") + items = reply.get("data") if isinstance(reply, dict) else None + if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict): + raise SystemExit("openai-gen: expected exactly one image in the response; nothing published") + _publish_image(items[0], request.raw) + usage = reply.get("usage") if isinstance(reply, dict) else None + return ProviderRun( + provider=self.name, + elapsed_seconds=time.monotonic() - started, + model=fields["model"], + extra={ + "auth_source": AUTH_SOURCE_API_KEY, + "transport": "openai-api", + "endpoint": endpoint, + "quality": fields["quality"], + "size": fields["size"], + "background": fields["background"], + # Token counts only — what the call cost, never what it contained. + **({"usage": usage} if isinstance(usage, dict) else {}), + }, + ) diff --git a/sprite_gen/gen/video.py b/sprite_gen/gen/video.py index 7bc6644..9eadbe5 100644 --- a/sprite_gen/gen/video.py +++ b/sprite_gen/gen/video.py @@ -21,7 +21,9 @@ 1. the grok CLI login file `~/.grok/auth.json` (SuperGrok Imagine quota via the OIDC access token the CLI stored at `grok login`). `GROK_HOME` relocates it. -2. `XAI_API_KEY` - an xAI console key, only when no grok login file exists. +2. `XAI_API_KEY` - an xAI console key, only when no grok login file exists. That + route spends metered API credit, so it says so on stderr before it uploads; + the subscription route stays silent (구독 우선 불변식 5, 수홍 2026-09-20). The login token expires (about six hours, 2026-09-08 실측) and the grok CLI is the only writer of that file, so an expired token is not refreshed here: the run @@ -53,6 +55,7 @@ from typing import Any, Callable from sprite_gen.spec.runio import atomic_write_text +from .base import announce_api_billing from .xai import ( API_BASE, AUTH_ENV, AUTH_SOURCE_API_KEY, AUTH_SOURCE_GROK_LOGIN, HTTP_TIMEOUT_SECONDS, GROK_REFRESH_COMMAND, GROK_REFRESH_WHERE, GROK_LOGIN_COMMAND, @@ -365,7 +368,21 @@ def _submit_poll_publish( `accept(staged_path, final_poll)` may refuse the clip (raise SystemExit) while it is still the `.part` file — `video-extend` measures the staged bytes with ffprobe; the - staged file is removed and `out` is never written.""" + staged file is removed and `out` is never written. + + Every billed request leaves from here, which is why the API-credit notice lives + here too: one line per submitted job, whichever verb asked for it.""" + if credential.source == AUTH_SOURCE_API_KEY: + # Reaching the key means no grok login exists (resolve_credential's fixed + # order), so this clip spends metered API credit instead of the Imagine + # subscription quota. What sets the amount travels with the charge: Imagine + # prices a clip by length x output size, so a 15 s 1080p clip is nothing + # like a 3 s 480p one (`video-edit` sends neither and names neither). + priced = ", ".join(f"{knob}={body[knob]}{'s' if knob == 'duration' else ''}" + for knob in ("duration", "resolution") if knob in body) + announce_api_billing(verb, AUTH_ENV, + f"{f' ({priced})' if priced else ''} — " + f"`{GROK_LOGIN_COMMAND}` signs your Grok subscription in.") started = time.monotonic() status, reply = call("POST", f"{API_BASE}/videos/{endpoint}", credential.token, body) if status in (401, 403): diff --git a/sprite_gen/workflow/access.py b/sprite_gen/workflow/access.py index 84e5700..adec1e6 100644 --- a/sprite_gen/workflow/access.py +++ b/sprite_gen/workflow/access.py @@ -6,6 +6,8 @@ import subprocess from sprite_gen.gen.base import provider_binary, provider_subprocess_env +from sprite_gen.gen.openai_provider import AUTH_ENV as OPENAI_AUTH_ENV +from sprite_gen.gen.openai_provider import resolve_credential as resolve_openai_credential from sprite_gen.gen.xai import AUTH_SOURCE_API_KEY, resolve_credential @@ -27,6 +29,17 @@ def probe_access(provider: str, *, video: bool = False) -> dict: if "chatgpt" not in output: return {**result, "reason": "login succeeded but ChatGPT subscription authentication was not identified"} return {**result, "login": "ready"} + if provider == "openai": + # An API key is the whole credential: there is no login to inspect and no + # subscription route to prefer, so readiness is the key's presence and the + # billing route is always metered API credit. + try: + resolve_openai_credential() + except (SystemExit, OSError, ValueError): + return {**result, "login": "unavailable", + "reason": f"{OPENAI_AUTH_ENV} is not set; export the key or choose another provider"} + return {**result, "login": "ready", "billing": "api-credit", + "reason": f"OpenAI images will use {OPENAI_AUTH_ENV} and separate API credit; confirm this billing choice."} if provider != "grok": raise ValueError(f"unknown provider: {provider}") try: diff --git a/sprite_gen/workflow/catalog.py b/sprite_gen/workflow/catalog.py index c54ea11..4970476 100644 --- a/sprite_gen/workflow/catalog.py +++ b/sprite_gen/workflow/catalog.py @@ -8,8 +8,28 @@ "grok-video": {"label": "그록 영상", "provider": "grok", "doc": "docs/video-pipeline.md", "steps": ["video-set"]}, } +# One label per registered provider. A provider with no label here is a bug, not +# a row to drop quietly: the mapping is checked rather than zipped positionally, +# so registering a backend in `gen.PROVIDERS` without naming it fails at import. +PROVIDER_LABELS = { + "codex": "지피티", + "grok": "그록", + "openai": "지피티 API (서버·SaaS 용, 호출당 과금)", +} +# Subscription-first (수홍 2026-09-20): the guided flow offers only the routes +# that run on a subscription the user already pays for. `openai` is a registered +# provider but never an offered, recommended or savable one — it spends metered +# API credit per call, so it has to be named on purpose (`--provider openai`). +GUIDED_PROVIDERS = ("codex", "grok") +_unlabelled = [provider for provider in PROVIDERS if provider not in PROVIDER_LABELS] +if _unlabelled: + raise RuntimeError(f"workflow catalog: no label for provider(s) {', '.join(_unlabelled)}") +_unregistered = [provider for provider in GUIDED_PROVIDERS if provider not in PROVIDERS] +if _unregistered: + raise RuntimeError(f"workflow catalog: guided provider(s) not registered: {', '.join(_unregistered)}") + FIELDS = { - "image_provider": {"options": dict(zip(PROVIDERS, ("지피티", "그록"))), + "image_provider": {"options": {provider: PROVIDER_LABELS[provider] for provider in GUIDED_PROVIDERS}, "question": "이미지를 지피티로 만들까요, 그록으로 만들까요?"}, "motion_method": {"options": {k: v["label"] for k, v in MOTION_METHODS.items()}, "question": "동작을 그록 영상으로 만들까요, 지피티 이미지 스프라이트로 만들까요?"}, diff --git a/tests/gen/test_gen.py b/tests/gen/test_gen.py index e444659..666435d 100644 --- a/tests/gen/test_gen.py +++ b/tests/gen/test_gen.py @@ -668,6 +668,21 @@ def test_real_providers_declare_their_transparency_strategy() -> None: assert grok_provider.GrokProvider.transparency == gen_base.TRANSPARENCY_CHROMA +@pytest.mark.parametrize("options, flag", [({"quality": "max"}, "--quality"), + ({"quality": "auto"}, "--quality"), + ({"resolution": "2k"}, "--resolution")]) +def test_codex_refuses_a_level_image_gen_cannot_carry(tmp_path: Path, monkeypatch, options, flag) -> None: + """image_gen has no effort or size dial, so the level is refused, never dropped: + a caller who paid attention to --quality must not get a default-effort image.""" + import subprocess + + monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("codex must not be spawned")) + with pytest.raises(SystemExit, match=flag) as error: + codex_provider.CodexProvider().generate(GenRequest("x", tmp_path / "raw.png", **options), tmp_path) + assert "openai" in str(error.value) + assert not (tmp_path / "raw.png").exists() + + def test_grok_refuses_a_native_alpha_request_before_upload(tmp_path: Path, monkeypatch) -> None: from sprite_gen.gen import grok_provider diff --git a/tests/gen/test_grok_provider.py b/tests/gen/test_grok_provider.py index 93c6860..b78e26b 100644 --- a/tests/gen/test_grok_provider.py +++ b/tests/gen/test_grok_provider.py @@ -10,6 +10,7 @@ from PIL import Image from sprite_gen import gen +from sprite_gen.gen import base as gen_base from sprite_gen.gen import grok_provider as grok, xai from sprite_gen.gen.base import GenRequest from sprite_gen.workflow import access, guide @@ -208,3 +209,105 @@ def test_guide_prefers_subscription_without_api_billing_question(tmp_path, api, assert all(item["billing"] == "subscription" for item in result["access"]) assert not any(question["field"] == "confirm_api_billing" for question in result["questions"]) assert api["calls"] == [] + + +def _sent(api, index=0): + return json.loads(api["calls"][index][0].data) + + +@pytest.mark.parametrize("resolution", grok.SUPPORTED_RESOLUTIONS) +@pytest.mark.parametrize("quality", grok.SUPPORTED_QUALITIES) +def test_every_priced_level_reaches_the_request_and_the_report(tmp_path, api, quality, resolution): + """The two knobs Imagine prices on arrive verbatim — no normalising, no default.""" + result = gen.generate_image("grok", "x", tmp_path / f"{quality}-{resolution}.png", + quality=quality, resolution=resolution) + body = _sent(api) + assert body["quality"] == quality and body["resolution"] == resolution + assert result.extra["quality"] == quality and result.extra["resolution"] == resolution + + +def test_asking_for_neither_leaves_the_request_and_report_as_they_were(tmp_path, api): + result = gen.generate_image("grok", "x", tmp_path / "out.png") + body = _sent(api) + assert "quality" not in body and "resolution" not in body + # The service default (`auto` = low here, medium on edits) is the service's to + # name; the report does not invent a level that was never requested. + assert "quality" not in result.extra and "resolution" not in result.extra + + +@pytest.mark.parametrize("options, expected", [ + ({"quality": "high"}, "openai"), + ({"quality": "xhigh"}, "openai"), + ({"quality": "max"}, "openai"), + ({"quality": "bogus"}, "openai"), + ({"resolution": "4k"}, "1.5k"), + ({"resolution": "1024"}, "1.5k"), + ({"resolution": "1K"}, "1.5k"), +]) +def test_a_level_grok_cannot_serve_fails_before_upload(tmp_path, api, options, expected): + with pytest.raises(SystemExit) as error: + grok.GrokProvider().generate(GenRequest("x", tmp_path / "raw.png", **options), tmp_path) + message = str(error.value) + assert next(iter(options.values())) in message and expected in message + assert api["calls"] == [] + + +def test_an_edit_carries_the_knobs_too(tmp_path, api): + ref = tmp_path / "ref.png" + Image.new("RGB", (12, 8), (0, 0, 200)).save(ref) + gen.generate_image("grok", "x", tmp_path / "edit.png", refs=[ref], quality="medium", resolution="2k") + request, _ = api["calls"][0] + assert request.full_url.endswith("/images/edits") + assert _sent(api)["quality"] == "medium" and _sent(api)["resolution"] == "2k" + + +def test_the_api_key_notice_names_what_the_image_is_priced_on(tmp_path, api, capsys): + """불변식 5: 2k medium bills twice 1k low, so the charge line says which one left.""" + gen.generate_image("grok", "x", tmp_path / "out.png", quality="medium", resolution="2k") + notice = [line for line in capsys.readouterr().err.splitlines() if "per-call API charge" in line] + assert len(notice) == 1 + assert "quality=medium" in notice[0] and "resolution=2k" in notice[0] + # The shared prefix already denies the subscription once; the grok detail adds + # the remedy, not a second denial (2026-09-20 실측 on the live XAI_API_KEY run: + # the line read "not a subscription (...), not your Grok subscription — ..."). + assert notice[0].count("not ") == 1 + assert "`grok login` signs your Grok subscription in." in notice[0] + + +def test_the_subscription_route_stays_silent_about_billing(tmp_path, api, monkeypatch, capsys): + _subscription_login(tmp_path, monkeypatch) + gen.generate_image("grok", "x", tmp_path / "out.png", quality="low", resolution="1k") + assert "per-call API charge" not in capsys.readouterr().err + assert _sent(api)["quality"] == "low" + + +@pytest.mark.parametrize("model, scoped", [("grok-2-image", True), (None, False)]) +def test_a_rejected_request_names_the_model_the_knobs_are_documented_for(tmp_path, api, model, scoped): + """docs.x.ai scopes quality/resolution to grok-imagine-image-2.0; a 400 on another + model should say so instead of leaving the caller with a bare status code.""" + api.update(status=400, body={"error": "synthetic-secret https://signed.invalid/private"}) + with pytest.raises(SystemExit, match="HTTP 400") as error: + gen.generate_image("grok", "x", tmp_path / "out.png", model=model, quality="low", resolution="2k") + message = str(error.value) + assert ("quality=low, resolution=2k" in message and grok.DEFAULT_MODEL in message) is scoped + assert "synthetic-secret" not in message + + +def test_cli_carries_quality_and_resolution_into_the_body(tmp_path, api): + assert gen.main(["--provider", "grok", "--prompt", "x", "--out", str(tmp_path / "cli.png"), + "--quality", "low", "--resolution", "1.5k"]) == 0 + assert _sent(api)["quality"] == "low" and _sent(api)["resolution"] == "1.5k" + + +def test_the_declared_subsets_are_the_servers_own_enums(): + """Pinned to what the live API answered on 2026-09-20, which is not what either + doc page says on its own: `high` deserializes but the model refuses it with + HTTP 400, and `1.5k` renders although the capability guide omits it. Widening + or narrowing these needs a fresh probe, not a doc reading — the service ignores + a field it does not know instead of refusing it.""" + assert grok.SUPPORTED_QUALITIES == ("auto", "low", "medium") + assert grok.SUPPORTED_RESOLUTIONS == ("1k", "1.5k", "2k") + # Both live inside the shared CLI vocabulary, so every level grok declares is + # one `--quality` / `--resolution` can actually pass in. + assert set(grok.SUPPORTED_QUALITIES) <= set(gen_base.QUALITIES) + assert set(grok.SUPPORTED_RESOLUTIONS) <= set(gen_base.RESOLUTIONS) diff --git a/tests/gen/test_openai_provider.py b/tests/gen/test_openai_provider.py new file mode 100644 index 0000000..c5d37dc --- /dev/null +++ b/tests/gen/test_openai_provider.py @@ -0,0 +1,353 @@ +# SPDX-License-Identifier: Apache-2.0 +"""OpenAI Images API contract, offline: no key, no network, no codex subprocess. + +The paid seam is the request body, so that is what is pinned here: which endpoint +is called, every field that decides what is billed (model / quality / size / +background / output_format), how the base64 answer becomes a verified PNG, and +which failures must never publish, retry or leak the key. +""" +import base64 +import io +import json +import os +import subprocess +import urllib.error + +import pytest +from PIL import Image + +from sprite_gen import gen +from sprite_gen.gen import grok_provider as grok +from sprite_gen.gen import openai_provider as openai +from sprite_gen.gen import xai +from sprite_gen.gen.base import GenRequest +from sprite_gen.workflow import access +from sprite_gen.workflow.catalog import FIELDS, GUIDED_PROVIDERS, PROVIDER_LABELS, validate_choices + +KEY = "synthetic-secret" + + +def encoded_image(fmt="PNG", *, alpha=False, key=False): + """A 12x8 fixture: a 4x4 subject block on a plain, transparent or magenta field.""" + if alpha: + image, subject = Image.new("RGBA", (12, 8), (0, 0, 0, 0)), (20, 90, 200, 255) + else: + image, subject = Image.new("RGB", (12, 8), (255, 0, 255) if key else (20, 90, 200)), (20, 90, 200) + for x in range(4, 8): + for y in range(2, 6): + image.putpixel((x, y), subject) + buf = io.BytesIO() + image.save(buf, format=fmt) + return base64.b64encode(buf.getvalue()).decode() + + +class _GrokResponse: + """Minimal 200 answer for the xai transport (a PNG, so chroma stays untouched).""" + + def __init__(self, request, api): + api["calls"].append((request, None)) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + status = 200 + + def read(self): + return json.dumps({"data": [{"b64_json": encoded_image()}]}).encode() + + +@pytest.fixture +def api(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", KEY) + monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("no provider subprocess may be spawned")) + state = {"status": 200, "body": {"data": [{"b64_json": encoded_image()}], + "usage": {"input_tokens": 12, "output_tokens": 340}}, "calls": []} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + @property + def status(self): + return state["status"] + + def read(self): + return json.dumps(state["body"]).encode() + + def open_request(request, *, timeout): + state["calls"].append((request, timeout)) + if state["status"] == 200: + return Response() + raise urllib.error.HTTPError(request.full_url, state["status"], "error", {}, + io.BytesIO(json.dumps(state["body"]).encode())) + + monkeypatch.setattr(openai.urllib.request, "urlopen", open_request) + return state + + +def _body(request): + return json.loads(request.data) + + +def test_generation_sends_the_billed_fields_and_publishes_a_png(tmp_path, api): + result = gen.generate_image("openai", "a blue square", tmp_path / "out.png") + request, timeout = api["calls"][0] + assert request.full_url == "https://api.openai.com/v1/images/generations" + assert request.get_header("Authorization") == f"Bearer {KEY}" + assert request.get_header("Content-type") == "application/json" + assert _body(request) == {"model": openai.DEFAULT_MODEL, "prompt": "a blue square", "n": 1, + "size": "1024x1024", "quality": "auto", + "output_format": "png", "background": "opaque"} + assert timeout == openai.GEN_TIMEOUT_SECONDS + # `response_format` is rejected for gpt-image models; the answer is always b64. + assert "response_format" not in _body(request) + with Image.open(result.out) as image: + assert image.format == "PNG" and image.size == (12, 8) + report = result.to_dict() + assert report["provider"] == "openai" and report["model"] == openai.DEFAULT_MODEL + assert report["extra"]["transport"] == "openai-api" + assert report["extra"]["auth_source"] == "OPENAI_API_KEY" + assert report["extra"]["usage"] == {"input_tokens": 12, "output_tokens": 340} + assert KEY not in json.dumps(report) + + +@pytest.mark.parametrize("quality", openai.SUPPORTED_QUALITIES) +def test_every_quality_level_reaches_the_request_verbatim(tmp_path, api, quality): + gen.generate_image("openai", "x", tmp_path / f"{quality}.png", quality=quality) + assert _body(api["calls"][0][0])["quality"] == quality + + +@pytest.mark.parametrize("ratio,size", sorted(openai.SIZES.items())) +def test_aspect_ratio_maps_to_a_legal_gpt_image_size(tmp_path, api, ratio, size): + gen.generate_image("openai", "x", tmp_path / "out.png", aspect_ratio=ratio) + assert _body(api["calls"][0][0])["size"] == size + if size == "auto": + return + width, height = (int(side) for side in size.split("x")) + # The documented custom-size constraints, checked on the table itself. + assert width % 16 == 0 and height % 16 == 0 + assert max(width, height) <= 3840 + assert 1 / 3 <= width / height <= 3 + assert 655_360 <= width * height <= 8_294_400 + + +def test_model_and_quality_overrides_are_not_second_guessed(tmp_path, api): + gen.generate_image("openai", "x", tmp_path / "out.png", model="gpt-image-2.5-sunburst", quality="max") + body = _body(api["calls"][0][0]) + assert body["model"] == "gpt-image-2.5-sunburst" and body["quality"] == "max" + + +def test_transparent_asks_for_a_transparent_background_and_keeps_the_alpha(tmp_path, api): + api["body"] = {"data": [{"b64_json": encoded_image(alpha=True)}]} + result = gen.generate_image("openai", "a blue square", tmp_path / "alpha.png", transparent=True) + body = _body(api["calls"][0][0]) + assert body["background"] == "transparent" and body["output_format"] == "png" + assert result.alpha["strategy"] == "native" and result.alpha["alpha_zero_pct"] > 0 + with Image.open(result.out) as image: + assert image.getpixel((0, 0)) == (0, 0, 0, 0) + assert image.getpixel((6, 4))[3] == 255 + + +def test_reference_edit_posts_multipart_images_in_order(tmp_path, api): + refs = [] + for index in range(3): + path = tmp_path / f"ref-{index}.png" + Image.new("RGB", (12, 8), (index * 40, 0, 0)).save(path) + refs.append(path) + gen.generate_image("openai", "combine these", tmp_path / "edit.png", refs=refs, quality="high") + request, _ = api["calls"][0] + assert request.full_url == "https://api.openai.com/v1/images/edits" + content_type = request.get_header("Content-type") + assert content_type.startswith("multipart/form-data; boundary=") + payload = request.data + assert payload.count(b'name="image[]"') == len(refs) + assert b'name="quality"\r\n\r\nhigh\r\n' in payload + assert b'name="model"\r\n\r\n' + openai.DEFAULT_MODEL.encode() in payload + positions = [payload.index(ref.read_bytes()) for ref in refs] + assert positions == sorted(positions), "reference images are read positionally; order must survive" + + +@pytest.mark.parametrize("status", [400, 401, 403, 429, 500]) +def test_api_failure_keeps_existing_output_and_does_not_retry(tmp_path, api, status): + api.update(status=status, body={"error": {"message": f"{KEY} https://signed.invalid/private"}}) + out = tmp_path / "existing.png" + out.write_bytes(b"existing") + with pytest.raises(SystemExit, match=f"HTTP {status}") as error: + gen.generate_image("openai", "x", out) + assert KEY not in str(error.value) + assert "signed.invalid" not in str(error.value) + assert out.read_bytes() == b"existing" + assert len(api["calls"]) == 1 + + +@pytest.mark.parametrize("body", [{}, {"data": []}, {"data": [None]}, {"data": [{}, {}]}, + {"data": [{"b64_json": "???"}]}, + {"data": [{"b64_json": base64.b64encode(b"invalid image").decode()}]}, + {"data": [{"url": "https://signed.invalid/private"}]}]) +def test_invalid_response_never_reuses_stale_raw(tmp_path, api, body): + api["body"] = body + raw = tmp_path / "raw.png" + raw.write_bytes(b"previous raw") + with pytest.raises(SystemExit): + openai.OpenAIProvider().generate(GenRequest("x", raw), tmp_path) + assert raw.read_bytes() == b"previous raw" + assert len(api["calls"]) == 1 + assert list(tmp_path.iterdir()) == [raw] + + +@pytest.mark.parametrize("value", [None, ""]) +def test_missing_key_names_the_variable_and_never_falls_back(tmp_path, api, monkeypatch, value): + if value is None: + monkeypatch.delenv("OPENAI_API_KEY") + else: + monkeypatch.setenv("OPENAI_API_KEY", value) + with pytest.raises(SystemExit, match="OPENAI_API_KEY") as error: + gen.generate_image("openai", "x", tmp_path / "out.png") + assert "codex" in str(error.value) # names the login it will NOT silently use + assert api["calls"] == [] + assert not (tmp_path / "out.png").exists() + + +def test_invalid_request_fails_before_any_billable_call(tmp_path, api): + bad_ref = tmp_path / "ref.png" + bad_ref.write_bytes(b"invalid") + good_ref = tmp_path / "good.png" + Image.new("RGB", (4, 4), (1, 2, 3)).save(good_ref) + for options in ({"prompt": " "}, {"refs": [bad_ref]}, {"refs": [good_ref] * (openai.MAX_REFS + 1)}, + {"aspect_ratio": "19.5:9"}, {"quality": "ultra"}, {"resolution": "2k"}): + prompt = options.pop("prompt", "x") + with pytest.raises(SystemExit): + openai.OpenAIProvider().generate(GenRequest(prompt, tmp_path / "raw.png", **options), tmp_path) + assert api["calls"] == [] + + +def test_timeout_does_not_repeat_billable_request(tmp_path, api, monkeypatch): + calls = [] + + def timeout(*args, **kwargs): + calls.append(args) + raise TimeoutError() + + monkeypatch.setattr(openai.urllib.request, "urlopen", timeout) + with pytest.raises(SystemExit, match="no automatic retry"): + gen.generate_image("openai", "x", tmp_path / "out.png") + assert len(calls) == 1 + + +def test_native_alpha_is_a_declared_capability(tmp_path, api): + assert openai.OpenAIProvider().transparency == "native" + # Forcing chroma stays legal (a prompt may already carry a key background). + api["body"] = {"data": [{"b64_json": encoded_image(key=True)}]} + result = gen.generate_image("openai", "blue on magenta", tmp_path / "keyed.png", + transparent=True, alpha_mode="chroma") + assert result.alpha["strategy"] == "chroma" + assert _body(api["calls"][0][0])["background"] == "opaque" + + +def test_provider_is_registered_everywhere_a_provider_must_be(api): + assert "openai" in gen.PROVIDERS + assert isinstance(gen._make_provider("openai", keep_session=False), openai.OpenAIProvider) + # Every registered backend is labelled, so a new one can never be dropped from + # the workflow surface by a silent positional zip. + assert set(PROVIDER_LABELS) == set(gen.PROVIDERS) + assert access.probe_access("openai") == {"provider": "openai", "login": "ready", "subscription": "unknown", + "quota": "unknown", "billing": "api-credit", + "reason": "OpenAI images will use OPENAI_API_KEY and separate " + "API credit; confirm this billing choice."} + + +# --- 구독 우선 불변식 (수홍 2026-09-20) -------------------------------------- +# sprite-gen runs on subscriptions people already pay for. The API-key backend is +# for servers and SaaS and is billed per call, so nothing may route to it on its +# own. These three tests are the guard rails for invariants 1, 2 and 3. + + +def test_openai_is_explicit_only_and_never_a_default(monkeypatch, api): + """불변식 1: no default, no saved preference, no guided-flow option.""" + assert gen.HARD_DEFAULT_PROVIDER == "codex" + assert gen.EXPLICIT_ONLY_PROVIDERS == ("openai",) + monkeypatch.delenv("SPRITE_GEN_DEFAULT_PROVIDER", raising=False) + monkeypatch.setattr(gen, "_codex_available", lambda: (True, "")) + assert gen.resolve_default_provider() == ("codex", None) + # The env knob cannot stand it up either. + monkeypatch.setenv("SPRITE_GEN_DEFAULT_PROVIDER", "openai") + with pytest.raises(SystemExit, match=r"--provider openai"): + gen.resolve_default_provider() + # Not offered by the guided flow, so it can never be saved as a preference. + assert "openai" not in GUIDED_PROVIDERS + assert "openai" not in FIELDS["image_provider"]["options"] + with pytest.raises(ValueError): + validate_choices("image", {"image_provider": "openai"}) + + +@pytest.mark.parametrize("configured", [None, "codex"]) +def test_an_api_key_in_the_environment_reroutes_nothing(monkeypatch, api, configured): + """불변식 2: a codex outage reaches grok, never the metered key.""" + assert "OPENAI_API_KEY" in os.environ # the fixture set it + if configured is None: + monkeypatch.delenv("SPRITE_GEN_DEFAULT_PROVIDER", raising=False) + else: + monkeypatch.setenv("SPRITE_GEN_DEFAULT_PROVIDER", configured) + monkeypatch.setattr(gen, "_codex_available", lambda: (False, "synthetic outage")) + provider, fallback = gen.resolve_default_provider() + assert provider == "grok" + assert fallback["from"] == "codex" and fallback["to"] == "grok" + assert api["calls"] == [] + + +def test_grok_login_still_outranks_its_api_key(tmp_path, api, monkeypatch, capsys): + """불변식 3: the Grok subscription login wins over XAI_API_KEY.""" + monkeypatch.setenv("XAI_API_KEY", "xai-api-secret") + monkeypatch.setenv("GROK_HOME", str(tmp_path)) + (tmp_path / "auth.json").write_text(json.dumps({"account": { + "key": "subscription-token", "expires_at": "2100-01-01T00:00:00Z"}})) + assert xai.resolve_credential().source == xai.AUTH_SOURCE_GROK_LOGIN + monkeypatch.setattr(xai.urllib.request, "urlopen", + lambda request, *, timeout: _GrokResponse(request, api)) + result = gen.generate_image("grok", "x", tmp_path / "grok.png") + assert result.extra["auth_source"] == xai.AUTH_SOURCE_GROK_LOGIN + # A subscription call says nothing about API billing; only the key route does. + assert "per-call API charge" not in capsys.readouterr().err + + +def test_every_api_key_call_announces_the_charge_before_it_leaves(tmp_path, api, capsys): + """불변식 5: one stderr line, before the request, naming the billing route.""" + gen.generate_image("openai", "x", tmp_path / "out.png", quality="low") + notice = [line for line in capsys.readouterr().err.splitlines() if "per-call API charge" in line] + assert len(notice) == 1 + assert "OPENAI_API_KEY" in notice[0] and "quality=low" in notice[0] + + +def test_grok_on_an_api_key_announces_the_charge_too(tmp_path, api, monkeypatch, capsys): + monkeypatch.setenv("XAI_API_KEY", "xai-api-secret") + monkeypatch.setenv("GROK_HOME", str(tmp_path / "no-login")) + monkeypatch.setattr(xai.urllib.request, "urlopen", + lambda request, *, timeout: _GrokResponse(request, api)) + gen.generate_image("grok", "x", tmp_path / "grok.png") + notice = [line for line in capsys.readouterr().err.splitlines() if "per-call API charge" in line] + assert len(notice) == 1 and "XAI_API_KEY" in notice[0] + + +def test_cli_accepts_provider_and_quality(tmp_path, api): + report = tmp_path / "report.json" + assert gen.main(["--provider", "openai", "--prompt", "x", "--out", str(tmp_path / "cli.png"), + "--quality", "low", "--aspect-ratio", "16:9", "--report", str(report)]) == 0 + body = _body(api["calls"][0][0]) + assert body["quality"] == "low" and body["size"] == "1536x864" + payload = json.loads(report.read_text(encoding="utf-8")) + assert payload["provider"] == "openai" and payload["provider_resolved_from"] == "explicit" + + +def test_a_grok_resolution_is_refused_rather_than_dropped(tmp_path, api): + """gpt-image has no long-edge preset: --resolution would be paid for and ignored.""" + with pytest.raises(SystemExit, match="aspect-ratio") as error: + gen.generate_image("openai", "x", tmp_path / "out.png", resolution="2k") + assert "2k" in str(error.value) + assert api["calls"] == [] + assert not (tmp_path / "out.png").exists() diff --git a/tests/gen/test_video.py b/tests/gen/test_video.py index 4e131f8..da0e4f2 100644 --- a/tests/gen/test_video.py +++ b/tests/gen/test_video.py @@ -342,3 +342,88 @@ def test_refresh_prescription_is_a_non_agent_command_run_outside_the_repo(tmp_pa assert video.GROK_REFRESH_WHERE in message assert "grok -p ok" not in message.replace("`grok -p …`", "") assert "coding agent" in message + + +# --- the API-credit notice (구독 우선 불변식 5) --------------------------------- + + +def _billed_run(verb: str, tmp_path: Path, **transport): + """Submit one job of `verb` through the fake transport.""" + if verb == "video": + return video.generate_video(_request(tmp_path, duration=3, resolution="480p"), **transport) + clip = tmp_path / "in.mp4" + clip.write_bytes(MP4) + transport = {**transport, "probe": lambda path: 6.0} # ffprobe stand-in: input and staged alike + if verb == "video-extend": + return video.extend_video(video.ExtendRequest(video=clip, prompt="she presses on", out=tmp_path / "longer.mp4", duration=4), **transport) + return video.edit_video(video.EditRequest(video=clip, prompt="make the hakama black", out=tmp_path / "edited.mp4"), **transport) + + +@pytest.mark.parametrize("verb, priced", [ + ("video", " (duration=3s, resolution=480p)"), + ("video-extend", " (duration=4s)"), + ("video-edit", ""), # the edit body sends neither knob, so the line names neither +]) +def test_every_verb_announces_the_charge_before_the_request_leaves(tmp_path: Path, capsys, verb, priced) -> None: + """A clip on XAI_API_KEY is metered API credit; the payer hears it before the upload, + not on the invoice. Imagine prices a clip by length x output size, so the knobs that + set the amount ride along with the charge.""" + api = _FakeApi() + heard: list[str] = [] + + def call(method, url, token, body): + if method == "POST": + heard.append(capsys.readouterr().err) # everything said before the job was submitted + return api.call(method, url, token, body) + + _billed_run(verb, tmp_path, credential=video.Credential("console-key", video.AUTH_SOURCE_API_KEY), + call=call, download=api.download, sleep=lambda s: None) + + notice = [line for line in heard[0].splitlines() if "per-call API charge" in line] + assert len(notice) == 1 + assert notice[0].startswith(f"[gen] {verb}: running on {video.AUTH_ENV} ") + assert f"not a subscription{priced} — " in notice[0] + # The shared prefix denies the subscription once; the detail carries the remedy only + # (the grok image notice read "not a subscription (...), not your Grok subscription"). + assert notice[0].count("not ") == 1 + assert f"`{video.GROK_LOGIN_COMMAND}` signs your Grok subscription in." in notice[0] + + +@pytest.mark.parametrize("verb", ["video", "video-extend", "video-edit"]) +def test_the_subscription_route_stays_silent_about_billing(tmp_path: Path, capsys, verb) -> None: + api = _FakeApi() + _billed_run(verb, tmp_path, credential=video.Credential("tok", video.AUTH_SOURCE_GROK_LOGIN), + call=api.call, download=api.download, sleep=lambda s: None) + assert "per-call API charge" not in capsys.readouterr().err + + +def test_a_request_that_never_leaves_never_announces_a_charge(tmp_path: Path, monkeypatch, capsys) -> None: + """The line tracks charges, not intentions: a body refused locally costs nothing.""" + monkeypatch.setenv("GROK_HOME", str(tmp_path / "no-login")) + monkeypatch.setenv("XAI_API_KEY", "console-key") + api = _FakeApi() + with pytest.raises(SystemExit, match="--duration must be"): + video.generate_video(_request(tmp_path, duration=99), call=api.call, download=api.download, sleep=lambda s: None) + assert api.calls == [] + assert "per-call API charge" not in capsys.readouterr().err + + +def test_the_cli_announces_on_stderr_leaving_the_report_on_stdout(tmp_path: Path, monkeypatch, capsys) -> None: + """The credential the environment actually hands the CLI is what decides, and the + notice goes to stderr so a caller parsing stdout still reads one JSON report.""" + monkeypatch.setenv("GROK_HOME", str(tmp_path / "no-login")) + monkeypatch.setenv("XAI_API_KEY", "console-key") + api = _FakeApi() + monkeypatch.setattr(video, "http_json", api.call) + monkeypatch.setattr(video, "http_download", api.download) + monkeypatch.setattr(video.time, "sleep", lambda s: None) + + rc = video.run(image=_still(tmp_path), prompt="sway", prompt_file=None, out=tmp_path / "clip.mp4", + duration=6, resolution="720p", aspect_ratio=None, model=video.DEFAULT_MODEL, + generate_audio=None, report=None) + + captured = capsys.readouterr() + assert rc == 0 + assert len([line for line in captured.err.splitlines() if "per-call API charge" in line]) == 1 + assert json.loads(captured.out)["auth_source"] == "XAI_API_KEY" + assert "console-key" not in captured.err + captured.out # the key itself is never printed