
Codex Image
- 5 installs
- 5 repo stars
- Updated July 17, 2026
- lwmxiaobei/xiaobei-skills
Helps with ai & agent building tasks.
About
codex-image is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- codex-image
- AI & Agent Building
- AI-coding skill
Codex Image by the numbers
- 5 all-time installs (skills.sh)
- Ranked #13,065 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lwmxiaobei/xiaobei-skills --skill codex-imageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 5 |
| Last updated | July 17, 2026 |
| Repository | lwmxiaobei/xiaobei-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
codex-image
Generate or edit images via OpenAI's Responses API by reusing the user's ChatGPT subscription — no OPENAI_API_KEY needed. The skill speaks to /v1/responses with the image_generation hosted tool while presenting an HTTP fingerprint indistinguishable from the official codex CLI.
When to invoke
Invoke this skill when the user asks to generate or edit an image and:
- They explicitly mention
codex-image/ "codex image" / "use my ChatGPT
quota", or
- They have an active
codexlogin (~/.codex/auth.jsonexists) and have
not asked for an API-key based generator, or
- They explicitly want to avoid paying for
OPENAI_API_KEYusage.
Do not use this skill when:
- The user has an
OPENAI_API_KEYset and wants to use it directly (use a
generic Images API skill instead).
- The user wants Google / Gemini / Adobe / other vendor image models.
- The user wants pure local background removal (
imagegenskill covers that).
Commands
codex-image generate "<prompt>" [--output-format png|webp|jpeg] [--out PATH] [--force]
codex-image edit --input REF1.png [--input REF2.png ...] "<prompt>" [--out PATH] [--force]
codex-image login
codex-image logout
codex-image statusRun codex-image --help for full flags. See `references/cli.md` for details and examples.
Prompting
See `references/prompting.md`. The guidance is compatible with the sibling imagegen skill so prompts can be reused.
Auth
The skill resolves an access token like so:
1. Read $CODEX_HOME/auth.json (default ~/.codex/auth.json). 2. If the access token is close to expiry, refresh it. 3. If the file is missing or refresh fails, launch a PKCE OAuth browser flow (binds 127.0.0.1:1455, falling back to :1457). On success, write auth.json so subsequent calls — including codex itself — reuse it.
Use codex-image login to force the OAuth flow. Use codex-image logout to clear tokens (keeps the file shape; codex will treat it as logged-out). Use codex-image status to inspect the current credential.
Output
By default images are written to:
$CODEX_HOME/generated_images/codex-image/<UTC>-<slug>.<ext>with file mode 0o644. Pass --out PATH for an explicit destination.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 2 | User input error |
| 3 | File-system error |
| 4 | Auth/plan error (HTTP 403) |
| 5 | Network / rate-limit error (retries exhausted) |
| 6 | Partial success (stream broken) |
| 7 | API business error (response.failed) |
| 130 | User interrupt (Ctrl+C) |
Legal
Unofficial skill. Read `LEGAL.md` before use.
platform: openai
endpoint: https://api.openai.com/v1/responses
auth: chatgpt-oauth
models:
- id: gpt-5.5
role: top-level
note: passed as the Responses API top-level "model" field.
- id: gpt-image-2
role: hosted-tool-default
note: |
Not sent explicitly; the Responses API picks the default image model
when "tools[0].model" is absent. Mirrors codex's behaviour.
tools:
- type: image_generation
client_id: eci-prd-pub-codex-123
scopes:
- openid
- profile
- email
- offline_access
- api.connectors.read
- api.connectors.invoke
fingerprint:
originator: codex_cli_rs
pretend_version: 0.45.0
user_agent_template: "codex_cli_rs/{version} ({os_type} {os_ver}; {arch}) {terminal}"
codex-image Skill — 设计文档
| 日期 | 2026-05-20 |
| 作者 | 北哥 + Claude(brainstorming 阶段) |
| 状态 | 待实现 |
| 下一步 | 移交 superpowers:writing-plans 生成实现计划 |
---
1. 目标
提供一个通用 skill(任何支持标准 skill 协议的 agent 都能加载),让用户在不持有 OPENAI_API_KEY 的前提下,用 ChatGPT 账号订阅额度调用 OpenAI Responses API 的 image_generation hosted tool 生成或编辑图片。
skill 通过完美伪装成 codex CLI 客户端完成请求——所有 HTTP 指纹(OAuth 客户端 ID、originator、User-Agent、请求体结构、SSE 事件解析)与 codex 一致。
1.1 必须满足的硬约束(北哥指定)
1. 完美伪装 codex 客户端:OpenAI 后端无法从 HTTP 指纹层面区分该 skill 与官方 codex CLI 的请求。 2. 鉴权稳定:优先复用 $CODEX_HOME/auth.json;缺失则脚本自动拉起 PKCE OAuth 浏览器授权流程;token 过期自动 refresh;refresh 失败兜底再次 OAuth。
1.2 非目标
- 不支持
OPENAI_API_KEY模式(codex 源码spec_plan.rs:295-309显式禁用 API Key 模式访问image_generationhosted tool;我们的 skill 完全跟随这个限制)。 - 不支持批量生成(
batch能力初版砍掉;YAGNI)。 - 不支持纯本地透明背景抠图(这是
imagegenskill 的能力,不重复)。 - 不内置 prompt 增强 schema(与
imagegenskill 的references/prompting.md兼容即可)。
---
2. 关键决策(已锁定)
| 决策项 | 选择 | 替代方案 | 理由 |
|---|---|---|---|
| 宿主环境 | 标准通用 skill | Claude Code 专用 / Codex 专用 | 一份代码服务所有 agent 平台 |
| 鉴权 | 优先 auth.json,缺失则 PKCE OAuth | shell out codex login / 仅依赖 codex | 用户原话"如果没有则脚本拉起授权" |
| API 端点 | POST /v1/responses + image_generation hosted tool | /v1/images/generations Images API | hosted tool 才能走 ChatGPT 额度;Images API 必须 API Key |
| 顶层 model | gpt-5.5 | gpt-5.1-codex / 让服务端默认 | 北哥指定 |
| 工具层 model | 不传(靠后端默认 = gpt-image-2) | 显式 model: "gpt-image-2" | 与 codex 默认请求体一字不差,伪装最大化 |
| 能力 | generate / edit / login / logout / status | 加 batch | 范围克制 |
| logout 语义 | 只清 tokens 字段,文件保留 | 整文件删 | codex 也能正确识别"未登录" |
| 401 重试 | 强制 refresh + 重试一次 | 多次重试 / 不重试 | 平衡稳定性与简洁性 |
edit 输入图 | data URI 内嵌(>5MB 自动缩至长边 2048px) | files API 上传 | 减少 round trip,简化代码 |
| 实现语言 | Python | Node / Rust | 与现有 imagegen skill 一致,依赖少 |
---
3. 架构
3.1 目录布局
codex-image-skill/ # skill 根目录(即将作为新 skill 发布)
├── SKILL.md # skill 元数据 + 调用指南
├── agents/openai.yaml # 平台清单
├── assets/
│ ├── codex-image.png # 大图标
│ └── codex-image-small.svg # 小图标
├── scripts/
│ ├── codex_image.py # CLI 主入口(argparse 分发)
│ ├── auth.py # 鉴权:读 auth.json / refresh / OAuth 兜底
│ ├── responses_client.py # /v1/responses SSE 流式客户端
│ ├── http_client.py # 共享 requests Session(统一 header 指纹)
│ └── output.py # base64 解码 + 文件命名 + 落盘
├── references/
│ ├── prompting.md # 共享提示词指南(与 imagegen skill 对齐)
│ ├── cli.md # 子命令、参数、示例
│ └── fingerprint.md # codex 伪装指纹规范(开发者文档)
├── tests/
│ ├── test_auth.py
│ ├── test_responses_client.py
│ └── test_output.py
├── docs/superpowers/specs/ # 本目录(本设计文档所在)
├── pyproject.toml # uv 管理的依赖
├── LEGAL.md # 非官方 skill 免责声明(见 §10)
└── README.md3.2 SKILL.md frontmatter
---
name: "codex-image"
description: "Generate or edit images via OpenAI's Responses API using ChatGPT account auth reused from Codex CLI's auth.json, with built-in PKCE OAuth fallback. Use when the user wants OpenAI's hosted image_generation without an OPENAI_API_KEY, leveraging their ChatGPT subscription quota. Provides generate, edit, login, logout, status."
------
4. 组件契约
4.1 auth.py — 鉴权层
# Public API
def get_access_token() -> str: ...
def interactive_login() -> Credentials: ...
def refresh_tokens(refresh_token: str, *, force: bool = False) -> Credentials: ...
def status() -> dict: ... # { email, plan, expires_at, account_id, source }
def logout() -> None: ... # 只清 tokens 字段
# Internal
def _load_auth_json() -> dict | None: ...
def _write_auth_json(data: dict) -> None: ... # mode 0o600
def _decode_jwt_exp(jwt: str) -> int: ... # 不验签,仅解 exp
def _start_callback_server(port: int) -> CallbackServer: ...`get_access_token()` 流程:
1. 读 $CODEX_HOME/auth.json($CODEX_HOME 缺省 ~/.codex) 2. 文件不存在 / JSON 损坏 → 备份后转 interactive_login() 3. 解析 tokens.access_token 的 JWT exp 4. 若 exp < now + 5min 且有 refresh_token → refresh_tokens() 5. refresh 失败 → interactive_login() 6. 返回 access_token 字符串
`interactive_login()` 流程:
1. 生成 PKCE pair:code_verifier (43 字节随机)、code_challenge (S256)、state (32 字节随机 base64url) 2. 启动 http.server.HTTPServer on 127.0.0.1:1455,端口占用则降级 1457 3. 构造 authorize URL(见 §6.1),webbrowser.open(url) 4. 阻塞等待回调(默认超时 90s) 5. 校验回调 state 字段 6. POST https://auth.openai.com/oauth/token 换 access_token / refresh_token / id_token 7. 写回 $CODEX_HOME/auth.json(结构与 codex 完全一致,见 §6.4) 8. 返回 Credentials
4.2 responses_client.py — API 层
@dataclass
class GenerateResult:
image_b64: str
revised_prompt: str | None
call_id: str
model: str # 顶层 model 回显
raw_events: list[dict] # debug 用,可选关闭
def generate(
prompt: str,
*,
access_token: str,
input_images: list[Path] | None = None,
output_format: str = "png",
image_model: str | None = None, # 不传则不加进 tool spec(默认伪装)
) -> GenerateResult: ...
class UnauthorizedError(Exception): ...
class RateLimitedError(Exception): ...
class ApiError(Exception): ...`generate()` 行为:
1. 构造请求体(见 §5.1) 2. POST https://api.openai.com/v1/responses with stream=True,header 见 §6.2 3. 行级解析 SSE:识别 event: 和 data: 字段 4. 累积事件直到 response.completed 或 response.failed 5. 在 response.output_item.done 事件里找 item.type == "image_generation_call",取 item.result (base64) 6. 401 → raise UnauthorizedError(CLI 层负责 refresh + 重试一次) 7. 429 → 读 Retry-After,最多内部重试 3 次(指数退避 2s/4s/8s) 8. 5xx → 同上指数退避重试 3 次 9. 不消化 response.image_generation_call.partial_image 事件(如有)——初版只关心最终结果
4.3 http_client.py — 共享 Session
提供 make_session(*, with_auth: bool = True) -> requests.Session,统一注入 §6.2 列出的所有"伪装 header",让 auth.py 和 responses_client.py 都用同一份指纹。
4.4 output.py — 落盘
def save(image_b64: str, out_path: Path | None, *, force: bool = False) -> Path: ...
def default_path(slug: str, ext: str) -> Path: ...
# $CODEX_HOME/generated_images/codex-image/<UTC>-<slug>.<ext>- 默认目录自动
mkdir -p out_path父目录不存在 → 报错退出(避免误写)- 同名文件 + 未传
--force→ 追加-v2/-v3... 自动避让 - base64 decode → 写文件 → 返回绝对路径
4.5 codex_image.py — CLI 表层
codex-image generate "PROMPT" [--output-format png|webp|jpeg] [--out PATH] [--force]
[--image-model MODEL] # 高级隐藏
codex-image edit --input REF.png [--input REF2.png ...] "PROMPT" [--out PATH] [--force]
codex-image login
codex-image logout
codex-image status退出码约定:
| Code | 含义 |
|---|---|
| 0 | 成功 |
| 2 | 用户输入错误(缺参数、参考图不存在、size 非法等) |
| 3 | 文件系统错误(权限、磁盘满) |
| 4 | 权限或计划错误(API 403) |
| 5 | 网络或限流错误(重试耗尽) |
| 6 | 部分成功(SSE 流断开但有 partial) |
| 7 | API 业务错误(response.failed) |
| 130 | 用户 Ctrl+C |
---
5. 数据流
5.1 generate 请求体(与 codex 默认 image_gen 调用一字不差)
{
"model": "gpt-5.5",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "<用户 prompt>"}
]
}
],
"tools": [
{"type": "image_generation", "output_format": "png"}
],
"stream": true
}注意:
- 顶层
model = gpt-5.5(北哥指定) tools[0]不传model字段,靠后端默认走gpt-image-2(与 codex 一致)- 不传
tool_choice、不传instructions、不传previous_response_id、不传store(codex 默认调用也不传)
5.2 edit 请求体(与 generate 的差异)
{
"model": "gpt-5.5",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "<prompt>"},
{"type": "input_image", "image_url": "data:image/png;base64,<...>"},
{"type": "input_image", "image_url": "data:image/png;base64,<...>"}
]
}
],
"tools": [{"type": "image_generation", "output_format": "png"}],
"stream": true
}预处理:参考图 >5MB 自动用 PIL 缩到长边 2048px;缩完仍 >10MB 直接报错退出 2。
5.3 端到端流程
$ codex-image generate "a red panda" --out panda.png
argparse → dispatch("generate")
↓
auth.get_access_token()
├── 读 ~/.codex/auth.json
├── 检查 JWT exp
├── 临近过期 → refresh
└── 缺失/失败 → interactive_login()
↓
responses_client.generate(prompt, access_token=...)
├── POST /v1/responses(SSE)
├── 解析事件流到 response.completed
└── 返回 GenerateResult
↓
catch UnauthorizedError → auth.refresh_tokens(force=True) → 重试一次
↓
output.save(b64, Path("panda.png"))
↓
print: "Saved: /abs/path/panda.png"---
6. 完美伪装 codex 的指纹规范
所有数值来自 codex Rust 源码 /Users/linweimin/codes/agent-learn/claude-code-mini/codex,每项已附文件:行号。6.1 OAuth 授权 URL 查询参数
| 参数 | 值 |
|---|---|
response_type | code |
client_id | eci-prd-pub-codex-123 (rmcp-client/src/perform_oauth_login.rs:720) |
redirect_uri | http://localhost:1455/auth/callback (备用 1457;login/src/server.rs:55,57,156) |
scope | openid profile email offline_access api.connectors.read api.connectors.invoke (login/src/server.rs:496-498) |
code_challenge | PKCE 计算值 |
code_challenge_method | S256 (login/src/server.rs:504) |
state | 32 字节随机 base64url |
id_token_add_organizations | true (codex 特有,login/src/server.rs:505-512) |
codex_cli_simplified_flow | true (codex 特有,同上) |
originator | codex_cli_rs (同上) |
完整示例 URL:
https://auth.openai.com/oauth/authorize
?response_type=code
&client_id=eci-prd-pub-codex-123
&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback
&scope=openid+profile+email+offline_access+api.connectors.read+api.connectors.invoke
&code_challenge=<PKCE>
&code_challenge_method=S256
&state=<32B>
&id_token_add_organizations=true
&codex_cli_simplified_flow=true
&originator=codex_cli_rs6.2 /v1/responses HTTP 请求头(伪装核心)
| Header | 值 | codex 出处 |
|---|---|---|
Authorization | Bearer <access_token> | responses-api-proxy/src/lib.rs:196-221 |
originator | codex_cli_rs | login/src/auth/default_client.rs:36,234 |
User-Agent | codex_cli_rs/<version> (<os_type> <os_ver>; <arch>) <terminal_info> | login/src/auth/default_client.rs:133-157 |
Accept | text/event-stream | codex-api/src/endpoint/responses.rs:139 |
Content-Type | application/json | (reqwest 默认 + body) |
session-id | UUIDv4 每次启动新生成 | codex-api/src/requests/headers.rs:8 |
x-codex-installation-id | UUIDv4 持久化到 $XDG_CACHE_HOME/codex-image/installation_id 或 ~/.codex-image/installation_id | core/src/client.rs:135 |
不发的 header(codex 内部多轮状态机用,单次调用发了反而暴露):
x-codex-turn-statex-codex-turn-metadatax-openai-subagentx-openai-memgen-requestx-responsesapi-include-timing-metricsthread-idx-client-request-idx-openai-internal-codex-residency
6.3 User-Agent 字段拼装规则
USER_AGENT_TEMPLATE = "codex_cli_rs/{version} ({os_type} {os_ver}; {arch}) {terminal}"
def build_user_agent() -> str:
return USER_AGENT_TEMPLATE.format(
version=CODEX_PRETEND_VERSION, # 硬编码当前 codex release tag
os_type=platform.system(), # "Darwin" / "Linux"
os_ver=platform.release(), # "24.3.0"
arch=platform.machine(), # "arm64"
terminal=detect_terminal() or "unknown", # $TERM_PROGRAM
)
CODEX_PRETEND_VERSION = "0.45.0" # TODO: 定期跟进 codex release,建议季度 review6.4 auth.json 文件结构(与 codex 完全一致)
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": "<JWT>",
"access_token": "<JWT>",
"refresh_token": "<string>",
"account_id": "<workspace_id>"
},
"last_refresh": "<ISO8601 UTC>"
}文件权限:0o600。
logout() 把 tokens 字段整个删除(保留 auth_mode 和 last_refresh),codex 读到无 tokens 会自动当作未登录。
6.5 Token refresh 请求
POST https://auth.openai.com/oauth/token:
{
"client_id": "eci-prd-pub-codex-123",
"grant_type": "refresh_token",
"refresh_token": "<token>"
}错误处理:
refresh_token_expired/refresh_token_reused/refresh_token_invalidated→ 删 tokens 字段 → 转interactive_login()- 其他 4xx → 同上
- 5xx → 重试 3 次(2s/4s/8s)
---
7. 错误处理
详细错误处理矩阵已在 §4 各组件中说明,此处汇总优先级:
1. 鉴权链路所有失败 → 兜底到 `interactive_login()`,永不进死循环(OAuth 失败直接退出 2) 2. API 401 → 强制 refresh + 重试一次,仍 401 直接报错(不无限重试,避免风暴) 3. API 429/5xx → 指数退避重试 3 次 4. SSE 流断开 → 不重试(重试可能产生重复扣额度),直接报错并退出 6 5. 任何文件系统错 → 报错带 errno 退出 3,不静默吞错 6. 用户 Ctrl+C → 优雅关闭 OAuth 回调服务器和 SSE 连接
---
8. 测试策略
8.1 单元测试(pytest)
| 模块 | 测试范围 |
|---|---|
auth.py | mock auth.json 多种状态(不存在/损坏/正常/过期);mock JWT exp 边界;mock refresh 流程的 200/400/refresh_token_expired 三种响应 |
responses_client.py | mock httpx server 返回预录的 SSE 流(成功/失败/401/429/5xx/断流);验证事件解析正确性;验证重试逻辑 |
output.py | 临时目录 + 命名冲突 + force 行为 + 父目录不存在 |
http_client.py | 验证所有伪装 header 实际出现在请求里(用 httpretty 或 responses 拦截) |
8.2 集成测试(手动一次性,不入 CI)
1. 新用户全流程:删 ~/.codex/auth.json → 跑 codex-image login → 浏览器授权 → 跑 codex-image generate "test" → 验证图片落盘 2. 复用 codex 登录:用 codex login 登录后跑 codex-image generate,应无 OAuth 弹窗 3. token 过期:手动改 auth.json 把 last_refresh 改到 9 天前,跑 codex-image generate,应静默 refresh 4. edit 流程:传入两张参考图跑 codex-image edit 5. logout:跑 codex-image logout,验证 tokens 字段被清空但文件保留 6. status:跑 codex-image status,验证打印的 email/plan/expires_at 正确
8.3 指纹验证(关键)
用 mitmproxy 抓 codex 原生 image_gen 调用 + 抓本 skill 的请求,逐字段 diff 两份 HTTP request,确保:
- method、path、HTTP 协议版本相同
- 所有 header 名/值集合相同(除
session-id、x-codex-installation-id等天然变化的 UUID) - request body JSON 字段集合相同(除
input.content[0].text)
这一步必须在初版上线前手动跑过一次,写一份 diff 报告进 references/fingerprint.md。
---
9. 依赖
# pyproject.toml
[project]
dependencies = [
"requests>=2.31", # HTTP
"pyjwt>=2.8", # 解析 JWT exp(不验签)
"pillow>=10", # edit 模式参考图缩放
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-mock",
"responses>=0.25", # HTTP mocking
]用 uv 管理(与现有 imagegen skill 一致):
uv pip install -e .---
10. 开放问题(不阻塞实现)
1. codex 版本号维护:CODEX_PRETEND_VERSION 硬编码会随 codex 升级过时;建议每季度 review 一次,或写一个 scripts/check_codex_version.py 拉取 GitHub release 自动提示。 2. 合规性灰区:复用 codex 的 client_id (eci-prd-pub-codex-123) 是公开常量,但伪装请求本质上是"非官方客户端使用 ChatGPT 订阅额度"——OpenAI ToS 上属灰色地带。skill 自带一份 LEGAL.md 提示用户该 skill 非 OpenAI 官方,由用户自担风险。 3. 图像模型固化策略:当前不传 tools[0].model 靠后端默认;如未来后端默认换成非 gpt-image-2 的模型,可考虑在 CLI 暴露 --image-model 改为默认传值。 4. 多账号支持:当前仅支持单一 auth.json;如有多账号需求,需引入 --profile 概念存到 ~/.codex-image/profiles/<name>.json。初版砍掉。
---
11. 下一步
完成本设计文档审阅后,立即移交 superpowers:writing-plans skill 生成实现计划。计划应:
- 拆分 phase(建议:①骨架 ②auth.py ③http_client.py ④responses_client.py ⑤output.py ⑥codex_image.py CLI 集成 ⑦集成测试 ⑧指纹 diff 验证 ⑨SKILL.md + 文档)
- 每个 phase 内部使用 TDD(先写测试再写实现)
- 在 ④ 完成后引入
mitmproxy指纹 diff 作为 review checkpoint
---
_文档结束_
Legal & Disclaimer
This skill is not affiliated with, endorsed by, or sponsored by OpenAI.
It is an unofficial client that:
- Reuses the OAuth
client_idpublished in the open-sourcecodexCLI
(eci-prd-pub-codex-123).
- Reuses the
auth.jsonfile produced bycodex login. - Sends HTTP requests to
api.openai.comandauth.openai.comthat mimic
those of the official codex CLI.
By using this skill you acknowledge that:
1. Its use of your ChatGPT account quota for image generation is outside the scope of the official `codex` product. OpenAI's Terms of Service may prohibit or restrict such use. 2. You are solely responsible for ensuring your use complies with the OpenAI Terms of Service applicable to your account, and for any consequences that may result (account flags, suspensions, etc.). 3. The skill is provided AS IS, without warranty of any kind. 4. Trademarks such as "OpenAI", "ChatGPT", and "Codex" belong to their respective owners and are used here for descriptive purposes only.
If you do not agree with these terms, do not use this skill.
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "codex-image"
version = "0.1.0"
description = "Generate or edit images via OpenAI's Responses API using ChatGPT account auth reused from Codex CLI."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "codex-image contributors" }]
dependencies = [
"requests>=2.31",
"httpx[http2,socks]>=0.27",
"pyjwt>=2.8",
"pillow>=10",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"pytest-mock",
"responses>=0.25",
]
[project.scripts]
codex-image = "codex_image:main"
[tool.setuptools]
package-dir = { "" = "scripts" }
py-modules = ["codex_image", "auth", "responses_client", "http_client", "output"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["scripts"]
codex-image
一个 Claude / Codex Skill,让 AI 助手能够直接使用你的 ChatGPT 订阅账号额度 来生成或编辑图片,无需 `OPENAI_API_KEY`。
这是一个 skill(技能),不是给你手工敲命令的 CLI 工具。你只需要在和 AI 助手对话时用自然语言提出需求,助手会自动判断、调用并完成任务。
English version: `README.md`
这是什么
codex-image 是给具备 Skill 能力的 AI 助手(如 Claude Code、Codex 等)安装的一个扩展。安装后,当你在对话中表达"生成图片 / 编辑图片"的意图,助手会:
1. 识别意图并自动激活本 skill 2. 复用本机已有的 codex 登录态(~/.codex/auth.json),没有时自动拉起一次浏览器 OAuth 登录 3. 通过 OpenAI Responses API 的 image_generation 托管工具生成 / 编辑图片 4. 把图片保存到本地并把路径返回给你
整个过程消耗的是你 ChatGPT 订阅(Plus / Pro / Business) 的使用额度,不走 API key 计费。
⚠️ 这是非官方 skill,使用前请阅读 `LEGAL.md`。
安装
方式一:通过 npx skills add(推荐)
一行命令从 GitHub 仓库安装到本地 skill 目录:
npx skills add https://github.com/lwmxiaobei/xiaobei-skills执行后会拉取仓库中的 skill 集合(含本 codex-image),并放到 AI 助手能识别的位置。重启 / 新开会话即可触发。
方式二:手动安装
把本目录放到 AI 助手识别 skill 的位置(以 Claude Code 为例,通常是 ~/.claude/skills/ 或项目级 .claude/skills/),助手启动时会自动加载 SKILL.md。
底层依赖
无论哪种方式安装,底层都依赖 Python 环境,建议先准备好:
uv pip install -e .或者把 scripts/ 目录加进 PATH。AI 助手在真正调用 skill 时会自动跑底层脚本,你平时不需要直接执行。
如何使用(用自然语言驱动)
不要去背命令,直接和 AI 助手说话就行。下面是一些典型触发用法。
1. 生成新图片
「帮我生成一张小熊猫吃竹子的图,保存到 panda.png」>
「Generate an image of a cyberpunk cat sitting on a neon-lit rooftop」
>
「用 gpt-image 给我画一张极简风格的山水画」
助手识别到生成意图后,会激活本 skill 并把图片产出到你指定的路径(或默认输出目录)。
2. 编辑 / 改图(基于参考图)
「把这张照片改成夜景效果」(附带 photo.jpg)>
「以ref1.png和ref2.png为参考,融合成一张新海报」
>
「Edit this image to make the background snowy」
只要你给出参考图路径并描述修改意图,助手会调用 skill 的 edit 流程。
3. 账号管理
「检查一下我的 codex 登录状态」
>
「我要重新登录 ChatGPT 账号」
>
「退出当前账号」
助手会通过 skill 的 login / logout / status 子命令完成相应操作。
触发条件(Skill 何时会被激活)
SKILL.md 已声明触发规则,AI 助手会基于此判断是否调用:
会触发:
- 你提到生成 / 编辑图片、画图、改图、出图
- 你点名
codex-image/ "用我的 ChatGPT 额度" - 你本机已有
codex登录态,且需求是图片生成
不会触发:
- 你明确希望走
OPENAI_API_KEY(请用通用 Images API skill) - 你想用 Google / Gemini / Adobe 等其他厂商的图像模型
- 你只需要本地抠图 / 去背景(用
imagegenskill)
输出位置
默认产出路径:
$CODEX_HOME/generated_images/codex-image/<UTC时间>-<slug>.<扩展名>也可以在对话里指定文件名,例如「保存到 ~/Desktop/out.png」,助手会把它作为 --out 传下去。
工作原理(简介)
Skill 底层会向 https://api.openai.com/v1/responses 发起请求,使用 image_generation 这个 hosted tool;HTTP 指纹与官方 codex CLI 完全对齐,从而能复用 ChatGPT 订阅鉴权。详细字节级规格见 `references/fingerprint.md`。
进一步阅读
- `SKILL.md` — skill 元信息与触发说明(AI 助手实际读取的入口)
- `references/cli.md` — 底层脚本的完整参数(助手会自动用,无需手敲)
- `references/prompting.md` — 写好图片提示词的方法
- `references/fingerprint.md` — HTTP 指纹与官方 CLI 对齐细节
- `LEGAL.md` — 法律声明与免责条款
目录结构
codex-image/
├── SKILL.md # skill 元数据 + 触发指南(AI 助手读取入口)
├── scripts/ # Python 实现(助手自动调用)
├── references/ # 提示词 / CLI / 指纹文档
├── agents/ # 子代理定义
├── tests/ # pytest 单元测试
├── pyproject.toml
├── LEGAL.md
└── README.mdcodex-image
A Claude / Codex Skill that lets an AI assistant generate or edit images using your ChatGPT subscription quota — no `OPENAI_API_KEY` required.
This is a skill, not a CLI you operate by hand. Just describe what you want in natural language to your AI assistant; it will detect the intent, invoke this skill, and complete the task for you.
中文版本:`README_CN.md`
What is this
codex-image is an extension you install into Skill-capable AI assistants (e.g. Claude Code, Codex). Once installed, whenever you tell your assistant something like "generate an image" or "edit this photo", it will:
1. Detect the intent and auto-activate this skill 2. Reuse the existing codex login on your machine (~/.codex/auth.json); if absent, automatically launch a one-time browser OAuth flow 3. Call OpenAI's Responses API via the image_generation hosted tool to generate / edit the image 4. Save the result locally and return the file path
The whole flow consumes your ChatGPT subscription (Plus / Pro / Business) quota — it does not bill an API key.
⚠️ This is an unofficial skill. Please read `LEGAL.md` before use.
Installation
Option 1: npx skills add (recommended)
One-liner to install from the GitHub repo into your local skill directory:
npx skills add https://github.com/lwmxiaobei/xiaobei-skillsThis pulls the skill collection (including this codex-image) into a location your AI assistant can discover. Restart / open a new session to make it available.
Option 2: Manual install
Drop this directory into wherever your AI assistant looks for skills (for Claude Code, that's typically ~/.claude/skills/ or a project-level .claude/skills/). The assistant loads SKILL.md on startup.
Runtime dependency
Either way, the underlying scripts need a Python environment. It is recommended to prepare one ahead of time:
uv pip install -e .Or just add scripts/ to your PATH. The AI assistant runs the underlying script automatically when invoking the skill — you don't need to run anything manually.
How to use (drive it with natural language)
You don't need to memorize commands — just talk to your AI assistant. Here are typical triggers.
1. Generate a new image
"Generate an image of a red panda eating bamboo and save it to panda.png">
"Generate an image of a cyberpunk cat sitting on a neon-lit rooftop"
>
"Use gpt-image to draw me a minimalist landscape painting"
Once the assistant detects a generation intent, it activates this skill and writes the image to your specified path (or the default output directory).
2. Edit / modify an image (with reference images)
"Turn this photo into a nighttime scene" (with photo.jpg attached)>
"Combineref1.pngandref2.pnginto a new poster"
>
"Edit this image to make the background snowy"
As long as you provide reference image paths and describe the edit, the assistant runs the skill's edit flow.
3. Account management
"Check my codex login status"
>
"I want to log in to my ChatGPT account again"
>
"Log out of the current account"
The assistant will dispatch the skill's login / logout / status sub-commands accordingly.
When the skill triggers
SKILL.md declares the trigger rules; the AI assistant uses them to decide whether to invoke:
Will trigger when:
- You mention generating / editing / drawing / modifying / producing an image
- You explicitly name
codex-image/ "use my ChatGPT quota" - You already have a
codexlogin locally and the request is for image generation
Will not trigger when:
- You explicitly want to use
OPENAI_API_KEY(use a generic Images API skill instead) - You want Google / Gemini / Adobe / other vendors' image models
- You only need local background removal (use the
imagegenskill)
Output location
Default output path:
$CODEX_HOME/generated_images/codex-image/<UTC-timestamp>-<slug>.<ext>You can also specify a filename in the conversation, e.g. "save it to ~/Desktop/out.png", and the assistant will forward it as --out.
How it works (in short)
Under the hood, the skill sends requests to https://api.openai.com/v1/responses using the image_generation hosted tool. Its HTTP fingerprint is byte-for-byte aligned with the official codex CLI, which is what allows it to reuse ChatGPT subscription auth. See `references/fingerprint.md` for the spec.
Further reading
- `SKILL.md` — skill metadata and trigger guide (the entry point the AI assistant actually reads)
- `references/cli.md` — full flags of the underlying script (the assistant calls it for you; no need to memorize)
- `references/prompting.md` — how to write effective image prompts
- `references/fingerprint.md` — HTTP fingerprint alignment with the official CLI
- `LEGAL.md` — legal notice and disclaimer
Directory layout
codex-image/
├── SKILL.md # skill metadata + trigger guide (AI assistant entry point)
├── scripts/ # Python implementation (auto-invoked by the assistant)
├── references/ # prompting / CLI / fingerprint docs
├── agents/ # sub-agent definitions
├── tests/ # pytest unit tests
├── pyproject.toml
├── LEGAL.md
└── README.mdCLI reference
codex-image <subcommand> [args...]generate
codex-image generate "<prompt>" [options]| Flag | Default | Description |
|---|---|---|
--output-format | png | png / webp / jpeg |
--out | auto | Output path. If omitted, written to $CODEX_HOME/generated_images/codex-image/<UTC>-<slug>.<ext>. |
--force | off | Overwrite an existing file at --out. |
--image-model | unset | Advanced. When set, sent as tools[0].model. Default is unset so the request body matches codex byte-for-byte. |
Example:
codex-image generate "a red panda eating bamboo" --out panda.pngedit
codex-image edit --input REF [--input REF ...] "<prompt>" [options]| Flag | Default | Description |
|---|---|---|
--input | required, repeatable | Path to a reference image (PNG / JPEG / WEBP). Multiple --input flags allowed. |
--output-format | png | Same as generate. |
--out | auto | Same as generate. |
--force | off | Same as generate. |
--image-model | unset | Same as generate. |
Reference images larger than 5 MB are automatically downscaled to a maximum long edge of 2048 px before being embedded. If downscaling cannot bring them below 10 MB the command fails with exit code 2.
Example:
codex-image edit \
--input ref1.png \
--input ref2.jpg \
"redraw in watercolor style, keep facial features intact" \
--out edited.pnglogin
codex-image loginForce the PKCE OAuth browser flow even if a usable auth.json already exists. The resulting tokens are written to $CODEX_HOME/auth.json and will be picked up by both this skill and the official codex CLI.
logout
codex-image logoutRemoves the tokens field from $CODEX_HOME/auth.json while keeping auth_mode and last_refresh. codex will then treat the user as logged-out without losing the file shape.
status
codex-image statusPrints a JSON document describing the current credential, e.g.:
{
"email": "user@example.com",
"plan": "chatgpt-plus",
"account_id": "ws_...",
"expires_at": "2026-05-21T01:23:45+00:00",
"source": "/Users/you/.codex/auth.json"
}Returns exit code 4 if no credential is present.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 2 | User input error |
| 3 | File-system error |
| 4 | Auth / plan error (HTTP 403) |
| 5 | Network / rate-limit error (retries exhausted) |
| 6 | Partial success (stream broken mid-flight) |
| 7 | API business error (response.failed) |
| 130 | User interrupt (Ctrl+C) |
codex fingerprint specification
Byte-level spec of the HTTP fingerprint this skill must reproduce. All values are taken from the open-source codex Rust client. Source file references are relative to the codex repository checkout in /Users/linweimin/codes/agent-learn/claude-code-mini/codex.
1. OAuth authorize URL
GET https://auth.openai.com/oauth/authorize
| Query | Value |
|---|---|
response_type | code |
client_id | eci-prd-pub-codex-123 (rmcp-client/src/perform_oauth_login.rs:720) |
redirect_uri | http://localhost:1455/auth/callback (fallback 1457) (login/src/server.rs:55,57,156) |
scope | openid profile email offline_access api.connectors.read api.connectors.invoke (login/src/server.rs:496-498) |
code_challenge | PKCE S256 of code_verifier |
code_challenge_method | S256 (login/src/server.rs:504) |
state | 32-byte random base64url |
id_token_add_organizations | true (login/src/server.rs:505-512) |
codex_cli_simplified_flow | true (same) |
originator | codex_cli_rs (same) |
2. /v1/responses request headers
| Header | Value | codex source |
|---|---|---|
Authorization | Bearer <access_token> | responses-api-proxy/src/lib.rs:196-221 |
originator | codex_cli_rs | login/src/auth/default_client.rs:36,234 |
User-Agent | codex_cli_rs/<version> (<os_type> <os_ver>; <arch>) <terminal> | login/src/auth/default_client.rs:133-157 |
Accept | text/event-stream | codex-api/src/endpoint/responses.rs:139 |
Content-Type | application/json | (set by reqwest body) |
session-id | UUIDv4, regenerated per CLI invocation | codex-api/src/requests/headers.rs:8 |
x-codex-installation-id | UUIDv4, persisted to disk | core/src/client.rs:135 |
Headers we must NOT send
These are emitted by codex only in multi-turn / internal contexts. Sending them in a single-shot Responses call would itself become a fingerprint:
x-codex-turn-statex-codex-turn-metadatax-openai-subagentx-openai-memgen-requestx-responsesapi-include-timing-metricsthread-idx-client-request-idx-openai-internal-codex-residency
3. User-Agent template
codex_cli_rs/{version} ({os_type} {os_ver}; {arch}) {terminal}version— hardcodedCODEX_PRETEND_VERSIONconstant. Currently
0.45.0. Review quarterly against https://github.com/openai/codex/releases.
os_type—platform.system()(Darwin/Linux).os_ver—platform.release().arch—platform.machine()(arm64/x86_64).terminal—$TERM_PROGRAMorunknown.
4. auth.json file shape
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": "<JWT>",
"access_token": "<JWT>",
"refresh_token": "<string>",
"account_id": "<workspace_id>"
},
"last_refresh": "<ISO8601 UTC>"
}File mode: 0o600.
logout removes the tokens key but keeps auth_mode and last_refresh. codex correctly interprets a tokens-less file as "logged out".
5. Token refresh
POST https://auth.openai.com/oauth/token
{
"client_id": "eci-prd-pub-codex-123",
"grant_type": "refresh_token",
"refresh_token": "<token>"
}Errors:
refresh_token_expired/refresh_token_reused/
refresh_token_invalidated — delete tokens and fall back to interactive login.
- Other 4xx — same.
- 5xx — exponential back-off retry up to 3 times (2 s / 4 s / 8 s).
6. /v1/responses request body — generate
{
"model": "gpt-5.5",
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "<prompt>"}
]
}
],
"tools": [
{"type": "image_generation", "output_format": "png"}
],
"stream": true
}- Top-level
modelisgpt-5.5. tools[0]does not includemodel; the backend chooses the default
(gpt-image-2), matching codex byte-for-byte.
- No
tool_choice,instructions,previous_response_id, orstore
fields are sent.
7. /v1/responses request body — edit
Same as above but with extra input_image items in content:
{
"type": "input_image",
"image_url": "data:image/png;base64,<...>"
}Reference images are downscaled to a max long edge of 2048 px when source exceeds 5 MB. The skill rejects any single reference still over 10 MB after downscaling (exit code 2).
8. Verification protocol
Before declaring fingerprint parity, capture both a codex-native image_gen request and this skill's request with mitmproxy. Diff field-by-field:
- HTTP method, path, version — must match.
- Header set — must match (UUID-bearing headers differ in value only).
- Request body JSON keys — must match (prompt text and image data differ).
Record the diff in a follow-up section of this file when the spec is re-verified.
Prompting guide
This guide is compatible with the sibling imagegen skill so prompts can be ported between the two.
Generate
A strong prompt usually contains three layers:
1. Subject — what is in the frame (a red panda, a runway model). 2. Composition / style — camera angle, lighting, art style (extreme close-up, golden-hour lighting, shallow depth of field, flat illustration, bold outlines, pastel palette). 3. Mood / context — atmosphere or storytelling beat (peaceful, contemplative, urgent, neon-lit cyberpunk alley).
Example:
codex-image generate \
"A red panda eating bamboo, extreme close-up, golden-hour lighting,
shallow depth of field, peaceful mood" \
--out panda.pngEdit
Provide one or more reference images plus a prompt describing the change you want — not the entire scene.
codex-image edit \
--input ref.png \
"make it nighttime, add lantern lighting, keep the subject pose unchanged" \
--out edited.pngMultiple references (e.g. subject + style sheet) can be combined:
codex-image edit \
--input subject.png --input style-sheet.png \
"redraw subject.png in the visual style of style-sheet.png" \
--out merged.pngReference images larger than 5 MB are automatically downscaled to a maximum long-edge of 2048 px before being embedded as data URIs. Files that still exceed 10 MB after downscaling are rejected (exit code 2).
Output format
--output-format png # default; lossless, supports alpha
--output-format webp # smaller; supports alpha
--output-format jpeg # smallest; no alphaNegative prompting
The Responses API hosted image_generation tool does not expose a negative prompt field. Bake exclusions into positive language instead (e.g. write clean background rather than no clutter).
"""Authentication layer.
Resolves a ChatGPT access token, with three strategies in order:
1. Read ``$CODEX_HOME/auth.json``.
2. If close to expiry, ``refresh_tokens()``.
3. If still no luck, ``interactive_login()`` via a PKCE OAuth browser flow.
The on-disk file format matches the official ``codex`` CLI so the two
clients share credentials transparently.
"""
from __future__ import annotations
import base64
import hashlib
import http.server
import json
import os
import secrets
import socket
import threading
import time
import urllib.parse
import webbrowser
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import jwt as pyjwt
import requests
from http_client import OAUTH_CLIENT_ID, OAUTH_SCOPE, ORIGINATOR, make_session
# ---------------------------------------------------------------------------
# Constants.
# ---------------------------------------------------------------------------
AUTH_HOST = "https://auth.openai.com"
AUTHORIZE_URL = f"{AUTH_HOST}/oauth/authorize"
TOKEN_URL = f"{AUTH_HOST}/oauth/token"
REDIRECT_PORTS = (1455, 1457)
REDIRECT_PATH = "/auth/callback"
REFRESH_LEEWAY_SECONDS = 5 * 60 # refresh when access token has <5min left
OAUTH_TIMEOUT_SECONDS = 90
UNRECOVERABLE_REFRESH_ERRORS = {
"refresh_token_expired",
"refresh_token_reused",
"refresh_token_invalidated",
"invalid_grant",
}
# ---------------------------------------------------------------------------
# Data types.
# ---------------------------------------------------------------------------
@dataclass
class Credentials:
access_token: str
refresh_token: str | None
id_token: str | None
account_id: str | None
last_refresh: str
source: Path
extra: dict[str, Any] = field(default_factory=dict)
class AuthError(RuntimeError):
"""Raised when no usable credential can be obtained."""
# ---------------------------------------------------------------------------
# auth.json paths.
# ---------------------------------------------------------------------------
def codex_home() -> Path:
explicit = os.environ.get("CODEX_HOME")
if explicit:
return Path(explicit).expanduser()
return Path.home() / ".codex"
def auth_path() -> Path:
return codex_home() / "auth.json"
# ---------------------------------------------------------------------------
# Low-level helpers.
# ---------------------------------------------------------------------------
def _load_auth_json() -> dict | None:
path = auth_path()
try:
raw = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError as exc:
raise AuthError(f"failed to read {path}: {exc}") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# Don't silently overwrite; back up the bad file so user can inspect.
backup = path.with_suffix(path.suffix + f".broken-{int(time.time())}")
try:
path.rename(backup)
except OSError:
pass
raise AuthError(
f"{path} contained invalid JSON, backed up to {backup}: {exc}"
) from exc
def _write_auth_json(data: dict) -> None:
path = auth_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
def _decode_jwt_exp(token: str) -> int | None:
"""Return the ``exp`` claim of *token*, or ``None`` if not a JWT."""
try:
claims = pyjwt.decode(token, options={"verify_signature": False})
except pyjwt.PyJWTError:
return None
exp = claims.get("exp")
return int(exp) if isinstance(exp, (int, float)) else None
def _now() -> int:
return int(time.time())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _credentials_from_data(data: dict) -> Credentials | None:
tokens = data.get("tokens")
if not tokens or not isinstance(tokens, dict):
return None
access = tokens.get("access_token")
if not access:
return None
return Credentials(
access_token=access,
refresh_token=tokens.get("refresh_token"),
id_token=tokens.get("id_token"),
account_id=tokens.get("account_id"),
last_refresh=data.get("last_refresh") or _now_iso(),
source=auth_path(),
extra={k: v for k, v in data.items() if k not in {"tokens", "last_refresh"}},
)
def _data_from_credentials(creds: Credentials) -> dict:
data: dict = {"auth_mode": "Chatgpt"}
data.update(creds.extra)
data["tokens"] = {
"id_token": creds.id_token,
"access_token": creds.access_token,
"refresh_token": creds.refresh_token,
"account_id": creds.account_id,
}
data["last_refresh"] = creds.last_refresh
return data
# ---------------------------------------------------------------------------
# Public API.
# ---------------------------------------------------------------------------
def get_access_token() -> str:
"""Return a usable access token, refreshing or logging in as needed."""
return get_access_credentials().access_token
def get_access_credentials() -> Credentials:
"""Return usable credentials including ``account_id``.
Callers that need to attach ``chatgpt-account-id`` to outgoing requests
(e.g. the ChatGPT-account ``responses`` endpoint) should prefer this
helper over :func:`get_access_token` so the id stays in sync with the
token that was actually selected/refreshed.
"""
return _resolve_credentials()
def _resolve_credentials() -> Credentials:
try:
data = _load_auth_json()
except AuthError:
# Corrupted file already backed up — go straight to OAuth.
return interactive_login()
creds = _credentials_from_data(data) if data else None
if creds is None:
return interactive_login()
exp = _decode_jwt_exp(creds.access_token)
if exp is not None and exp - _now() > REFRESH_LEEWAY_SECONDS:
return creds
if creds.refresh_token:
try:
return refresh_tokens(creds.refresh_token, force=False)
except AuthError:
return interactive_login()
return interactive_login()
def refresh_tokens(refresh_token: str, *, force: bool = False) -> Credentials:
"""Exchange *refresh_token* for a fresh access token.
Parameters
----------
force:
If True, perform the refresh even when the current token still has
plenty of validity. Used by the CLI's 401 recovery path.
"""
payload = {
"client_id": OAUTH_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
session = make_session()
last_error: str | None = None
for attempt in range(3):
try:
response = session.post(TOKEN_URL, json=payload, timeout=30)
except requests.RequestException as exc:
last_error = str(exc)
time.sleep(2 ** (attempt + 1))
continue
if response.status_code == 200:
return _store_refreshed_tokens(response.json(), refresh_token)
# Hard failures — never retry, propagate.
if response.status_code in (400, 401, 403):
body: dict[str, Any] = {}
try:
body = response.json()
except ValueError:
pass
error_raw = body.get("error") or ""
error_code = error_raw if isinstance(error_raw, str) else error_raw.get("code", "")
if error_code in UNRECOVERABLE_REFRESH_ERRORS:
_clear_tokens_field()
raise AuthError(
f"refresh failed ({response.status_code}): {body or response.text!r}"
)
# 5xx — back off and retry.
last_error = f"HTTP {response.status_code}: {response.text!r}"
time.sleep(2 ** (attempt + 1))
if force:
raise AuthError(f"refresh failed after retries: {last_error}")
raise AuthError(f"refresh failed after retries: {last_error}")
def _store_refreshed_tokens(payload: dict, prior_refresh: str) -> Credentials:
existing = _load_auth_json() or {}
extra = {k: v for k, v in existing.items() if k not in {"tokens", "last_refresh"}}
tokens = existing.get("tokens") or {}
access = payload.get("access_token") or tokens.get("access_token")
if not access:
raise AuthError(f"refresh response missing access_token: {payload!r}")
refresh = payload.get("refresh_token") or prior_refresh
id_token = payload.get("id_token") or tokens.get("id_token")
account_id = payload.get("account_id") or tokens.get("account_id")
creds = Credentials(
access_token=access,
refresh_token=refresh,
id_token=id_token,
account_id=account_id,
last_refresh=_now_iso(),
source=auth_path(),
extra=extra if extra else {"auth_mode": "Chatgpt"},
)
_write_auth_json(_data_from_credentials(creds))
return creds
def _clear_tokens_field() -> None:
"""Remove the ``tokens`` field from auth.json without deleting the file."""
try:
data = _load_auth_json()
except AuthError:
return
if not data:
return
data.pop("tokens", None)
data["auth_mode"] = data.get("auth_mode", "Chatgpt")
data.setdefault("last_refresh", _now_iso())
try:
_write_auth_json(data)
except OSError:
pass
def logout() -> None:
"""Strip tokens from auth.json. File shape and other fields are kept."""
_clear_tokens_field()
def status() -> dict:
"""Return a non-secret summary of the current credential, or raise."""
data = _load_auth_json()
if not data:
raise AuthError("no credential found")
creds = _credentials_from_data(data)
if not creds:
raise AuthError("auth.json present but no tokens stored")
info: dict[str, Any] = {
"source": str(creds.source),
"account_id": creds.account_id,
"last_refresh": creds.last_refresh,
}
exp = _decode_jwt_exp(creds.access_token)
if exp is not None:
info["expires_at"] = datetime.fromtimestamp(exp, timezone.utc).isoformat(
timespec="seconds"
)
if creds.id_token:
try:
claims = pyjwt.decode(creds.id_token, options={"verify_signature": False})
except pyjwt.PyJWTError:
claims = {}
info["email"] = claims.get("email")
ns = claims.get("https://api.openai.com/auth") or {}
if isinstance(ns, dict):
info["plan"] = ns.get("chatgpt_plan_type") or ns.get("plan_type")
return info
# ---------------------------------------------------------------------------
# PKCE OAuth flow.
# ---------------------------------------------------------------------------
def _make_pkce_pair() -> tuple[str, str]:
verifier = base64.urlsafe_b64encode(secrets.token_bytes(43)).rstrip(b"=").decode()
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
def _make_state() -> str:
return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
server_version = "codex-image-callback/1.0"
def do_GET(self) -> None: # noqa: N802 — required name
parsed = urllib.parse.urlparse(self.path)
if parsed.path != REDIRECT_PATH:
self.send_response(404)
self.end_headers()
return
query = urllib.parse.parse_qs(parsed.query)
result: dict[str, str | None] = {
"code": (query.get("code") or [None])[0],
"state": (query.get("state") or [None])[0],
"error": (query.get("error") or [None])[0],
"error_description": (query.get("error_description") or [None])[0],
}
# Stash on the server instance so the main thread can pick it up.
self.server.oauth_result = result # type: ignore[attr-defined]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
if result["error"]:
body = f"<h1>Login failed</h1><p>{result['error']}</p>"
else:
body = "<h1>codex-image login complete</h1><p>You can close this tab.</p>"
self.wfile.write(body.encode("utf-8"))
def log_message(self, *_args, **_kwargs) -> None: # noqa: D401
# Silence the default stderr logger.
return
def _bind_callback_server() -> tuple[http.server.HTTPServer, int]:
last_exc: OSError | None = None
for port in REDIRECT_PORTS:
try:
server = http.server.HTTPServer(("127.0.0.1", port), _CallbackHandler)
except OSError as exc:
last_exc = exc
continue
server.oauth_result = None # type: ignore[attr-defined]
return server, port
raise AuthError(
f"could not bind any of {REDIRECT_PORTS!r} for OAuth callback: {last_exc}"
)
def interactive_login() -> Credentials:
"""Drive the PKCE OAuth flow end-to-end and persist the result."""
verifier, challenge = _make_pkce_pair()
state = _make_state()
server, port = _bind_callback_server()
redirect_uri = f"http://localhost:{port}{REDIRECT_PATH}"
params = {
"response_type": "code",
"client_id": OAUTH_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": OAUTH_SCOPE,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"originator": ORIGINATOR,
}
url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"Opening browser for OAuth login...\nIf nothing opens, visit:\n{url}")
try:
webbrowser.open(url)
except Exception: # noqa: BLE001 — best effort
pass
deadline = time.monotonic() + OAUTH_TIMEOUT_SECONDS
try:
while time.monotonic() < deadline:
result = getattr(server, "oauth_result", None)
if result is not None:
break
time.sleep(0.2)
else:
raise AuthError("OAuth login timed out waiting for callback")
finally:
server.shutdown()
server.server_close()
if result["error"]:
raise AuthError(
f"OAuth error: {result['error']} {result.get('error_description') or ''}"
)
if result["state"] != state:
raise AuthError("OAuth state mismatch — possible CSRF, aborting")
if not result["code"]:
raise AuthError("OAuth callback missing code parameter")
token_payload = {
"client_id": OAUTH_CLIENT_ID,
"grant_type": "authorization_code",
"code": result["code"],
"redirect_uri": redirect_uri,
"code_verifier": verifier,
}
session = make_session()
response = session.post(TOKEN_URL, json=token_payload, timeout=30)
if response.status_code != 200:
raise AuthError(
f"token exchange failed ({response.status_code}): {response.text!r}"
)
payload = response.json()
access = payload.get("access_token")
if not access:
raise AuthError(f"token response missing access_token: {payload!r}")
creds = Credentials(
access_token=access,
refresh_token=payload.get("refresh_token"),
id_token=payload.get("id_token"),
account_id=payload.get("account_id"),
last_refresh=_now_iso(),
source=auth_path(),
extra={"auth_mode": "Chatgpt"},
)
_write_auth_json(_data_from_credentials(creds))
return creds
def _port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex(("127.0.0.1", port)) == 0
"""``codex-image`` command-line entry point."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Iterable
import auth
import output
import responses_client as rc
# ---------------------------------------------------------------------------
# Exit codes.
# ---------------------------------------------------------------------------
EXIT_OK = 0
EXIT_USER_ERROR = 2
EXIT_FS_ERROR = 3
EXIT_AUTH_ERROR = 4
EXIT_NETWORK_ERROR = 5
EXIT_PARTIAL = 6
EXIT_API_ERROR = 7
EXIT_INTERRUPT = 130
# ---------------------------------------------------------------------------
# Argument parser.
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="codex-image",
description=(
"Generate or edit images via OpenAI's Responses API using "
"ChatGPT-account auth reused from the codex CLI."
),
)
subparsers = parser.add_subparsers(dest="command", required=True)
gen = subparsers.add_parser("generate", help="generate a new image")
_add_common_image_flags(gen)
gen.add_argument("prompt", help="prompt describing the desired image")
edit = subparsers.add_parser("edit", help="edit reference image(s)")
_add_common_image_flags(edit)
edit.add_argument(
"--input",
dest="inputs",
action="append",
type=Path,
required=True,
help="path to a reference image (repeatable)",
)
edit.add_argument("prompt", help="prompt describing the change to apply")
subparsers.add_parser("login", help="force PKCE OAuth browser flow")
subparsers.add_parser("logout", help="clear tokens from auth.json")
subparsers.add_parser("status", help="print current credential metadata")
return parser
def _add_common_image_flags(sub: argparse.ArgumentParser) -> None:
sub.add_argument(
"--output-format",
choices=("png", "webp", "jpeg"),
default="png",
)
sub.add_argument("--out", type=Path, default=None, help="output path")
sub.add_argument(
"--force",
action="store_true",
help="overwrite existing file at --out",
)
sub.add_argument(
"--image-model",
default=None,
help=(
"advanced: set tools[0].model. Default unset to match codex "
"byte-for-byte."
),
)
# ---------------------------------------------------------------------------
# Dispatch.
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
try:
if args.command == "generate":
return _cmd_generate(args)
if args.command == "edit":
return _cmd_edit(args)
if args.command == "login":
return _cmd_login()
if args.command == "logout":
return _cmd_logout()
if args.command == "status":
return _cmd_status()
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
return EXIT_INTERRUPT
parser.error(f"unknown command {args.command!r}")
return EXIT_USER_ERROR
# ---------------------------------------------------------------------------
# Subcommand handlers.
# ---------------------------------------------------------------------------
def _cmd_generate(args: argparse.Namespace) -> int:
return _run_image_call(
prompt=args.prompt,
inputs=None,
output_format=args.output_format,
out=args.out,
force=args.force,
image_model=args.image_model,
)
def _cmd_edit(args: argparse.Namespace) -> int:
inputs: list[Path] = list(args.inputs or [])
for path in inputs:
if not path.exists():
print(f"error: reference image not found: {path}", file=sys.stderr)
return EXIT_USER_ERROR
return _run_image_call(
prompt=args.prompt,
inputs=inputs,
output_format=args.output_format,
out=args.out,
force=args.force,
image_model=args.image_model,
)
def _run_image_call(
*,
prompt: str,
inputs: Iterable[Path] | None,
output_format: str,
out: Path | None,
force: bool,
image_model: str | None,
) -> int:
try:
creds = auth.get_access_credentials()
except auth.AuthError as exc:
print(f"auth error: {exc}", file=sys.stderr)
return EXIT_AUTH_ERROR
try:
result = _call_generate_with_refresh(
token=creds.access_token,
account_id=creds.account_id,
prompt=prompt,
inputs=inputs,
output_format=output_format,
image_model=image_model,
)
except rc.UnauthorizedError as exc:
print(f"auth error: {exc}", file=sys.stderr)
return EXIT_AUTH_ERROR
except rc.ForbiddenError as exc:
print(f"forbidden: {exc}", file=sys.stderr)
return EXIT_AUTH_ERROR
except rc.RateLimitedError as exc:
print(f"rate limited: {exc}", file=sys.stderr)
return EXIT_NETWORK_ERROR
except rc.StreamBrokenError as exc:
print(f"stream broken: {exc}", file=sys.stderr)
return EXIT_PARTIAL
except rc.ApiError as exc:
print(f"api error: {exc}", file=sys.stderr)
return EXIT_API_ERROR
except ValueError as exc:
print(f"input error: {exc}", file=sys.stderr)
return EXIT_USER_ERROR
try:
saved = output.save(
result.image_b64,
out,
force=force,
slug_source=prompt,
output_format=output_format,
)
except FileNotFoundError as exc:
print(f"filesystem error: {exc}", file=sys.stderr)
return EXIT_FS_ERROR
except (PermissionError, OSError) as exc:
print(f"filesystem error: {exc}", file=sys.stderr)
return EXIT_FS_ERROR
except ValueError as exc:
print(f"api returned invalid data: {exc}", file=sys.stderr)
return EXIT_API_ERROR
print(f"Saved: {saved}")
if result.revised_prompt:
print(f"Revised prompt: {result.revised_prompt}")
return EXIT_OK
def _call_generate_with_refresh(
*,
token: str,
account_id: str | None,
prompt: str,
inputs: Iterable[Path] | None,
output_format: str,
image_model: str | None,
) -> rc.GenerateResult:
"""Run generate(); on 401 force-refresh once and retry."""
try:
return rc.generate(
prompt,
access_token=token,
account_id=account_id,
input_images=list(inputs) if inputs else None,
output_format=output_format,
image_model=image_model,
)
except rc.UnauthorizedError:
refreshed = _force_refresh_credentials()
return rc.generate(
prompt,
access_token=refreshed.access_token,
account_id=refreshed.account_id,
input_images=list(inputs) if inputs else None,
output_format=output_format,
image_model=image_model,
)
def _force_refresh_credentials() -> auth.Credentials:
data = auth._load_auth_json() # noqa: SLF001 — intentional internal call
refresh = (data or {}).get("tokens", {}).get("refresh_token") if data else None
if refresh:
try:
return auth.refresh_tokens(refresh, force=True)
except auth.AuthError:
pass
return auth.interactive_login()
def _cmd_login() -> int:
try:
creds = auth.interactive_login()
except auth.AuthError as exc:
print(f"login failed: {exc}", file=sys.stderr)
return EXIT_AUTH_ERROR
print(f"Logged in. Tokens written to {creds.source}.")
return EXIT_OK
def _cmd_logout() -> int:
auth.logout()
print("Logged out (tokens cleared, auth.json shape preserved).")
return EXIT_OK
def _cmd_status() -> int:
try:
info = auth.status()
except auth.AuthError as exc:
print(f"status error: {exc}", file=sys.stderr)
return EXIT_AUTH_ERROR
print(json.dumps(info, indent=2, sort_keys=True, default=str))
return EXIT_OK
if __name__ == "__main__":
raise SystemExit(main())
"""Shared HTTP session factory.
Centralises all "fingerprint" headers so :mod:`auth` and
:mod:`responses_client` send byte-identical metadata.
The codex CLI emits a small, deterministic set of headers; this module
constructs the same set and explicitly removes the headers that
``requests`` adds by default but ``codex`` does not.
"""
from __future__ import annotations
import os
import platform
import uuid
from pathlib import Path
import requests
# ---------------------------------------------------------------------------
# Fingerprint constants — keep in sync with references/fingerprint.md.
# ---------------------------------------------------------------------------
ORIGINATOR = "codex_cli_rs"
CODEX_PRETEND_VERSION = "0.130.0"
# OAuth client_id used by the official codex CLI (codex-rs/login/src/auth/manager.rs).
OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
OAUTH_SCOPE = (
"openid profile email offline_access "
"api.connectors.read api.connectors.invoke"
)
USER_AGENT_TEMPLATE = "codex_cli_rs/{version} ({os_type} {os_ver}; {arch}) {terminal}"
# Required by chatgpt.com/backend-api/codex/responses for ChatGPT-account tokens.
OPENAI_BETA_HEADER_VALUE = "responses=experimental"
def _detect_terminal() -> str:
return os.environ.get("TERM_PROGRAM") or "unknown"
def build_user_agent() -> str:
return USER_AGENT_TEMPLATE.format(
version=CODEX_PRETEND_VERSION,
os_type=platform.system(),
os_ver=platform.release(),
arch=platform.machine(),
terminal=_detect_terminal(),
)
# ---------------------------------------------------------------------------
# Installation id — UUIDv4 persisted on disk so successive invocations look
# like the same physical client. Stored separately from auth.json so logout
# does not invalidate it.
# ---------------------------------------------------------------------------
def _installation_id_path() -> Path:
explicit = os.environ.get("CODEX_IMAGE_INSTALLATION_ID_FILE")
if explicit:
return Path(explicit).expanduser()
cache_root = os.environ.get("XDG_CACHE_HOME")
if cache_root:
return Path(cache_root).expanduser() / "codex-image" / "installation_id"
return Path.home() / ".codex-image" / "installation_id"
def get_installation_id() -> str:
"""Return a stable installation UUIDv4, creating it on first use."""
path = _installation_id_path()
try:
existing = path.read_text(encoding="utf-8").strip()
if existing:
return existing
except FileNotFoundError:
pass
except OSError:
# Disk error reading the file — fall through and try to recreate it.
pass
new_id = str(uuid.uuid4())
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(new_id, encoding="utf-8")
except OSError:
# Best effort. If the disk is unwritable we still return a UUID for
# this invocation — the fingerprint then only differs across runs.
pass
return new_id
# Session-id is per process, not per disk.
_SESSION_ID = str(uuid.uuid4())
def get_session_id() -> str:
return _SESSION_ID
# ---------------------------------------------------------------------------
# Session factory.
# ---------------------------------------------------------------------------
def make_session(
*,
with_auth_token: str | None = None,
account_id: str | None = None,
include_chatgpt_beta: bool = False,
) -> requests.Session:
"""Return a :class:`requests.Session` carrying the codex fingerprint.
Parameters
----------
with_auth_token:
Optional bearer token to inject as ``Authorization`` header. The
OAuth token endpoint must not receive a bearer header, so callers
for that path pass ``None``.
account_id:
Optional ChatGPT account id. When provided, attaches the
``chatgpt-account-id`` header expected by
``chatgpt.com/backend-api/codex/responses``.
include_chatgpt_beta:
When True, attaches ``OpenAI-Beta: responses=experimental`` which
the ChatGPT-account responses endpoint requires.
"""
session = requests.Session()
# Drop requests defaults that codex never sends.
for header in ("User-Agent", "Accept", "Accept-Encoding"):
session.headers.pop(header, None)
session.headers.update(
{
"User-Agent": build_user_agent(),
"originator": ORIGINATOR,
"session-id": get_session_id(),
"x-codex-installation-id": get_installation_id(),
}
)
if with_auth_token is not None:
session.headers["Authorization"] = f"Bearer {with_auth_token}"
if account_id:
session.headers["chatgpt-account-id"] = account_id
if include_chatgpt_beta:
session.headers["OpenAI-Beta"] = OPENAI_BETA_HEADER_VALUE
return session
"""Filesystem output helpers."""
from __future__ import annotations
import base64
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from auth import codex_home
EXTENSION_BY_FORMAT = {
"png": ".png",
"webp": ".webp",
"jpeg": ".jpg",
"jpg": ".jpg",
}
def default_output_dir() -> Path:
return codex_home() / "generated_images" / "codex-image"
def _slugify(text: str, *, max_length: int = 48) -> str:
cleaned = re.sub(r"[^A-Za-z0-9]+", "-", text.strip().lower()).strip("-")
if not cleaned:
cleaned = "image"
return cleaned[:max_length].rstrip("-") or "image"
def default_path(slug_source: str, output_format: str) -> Path:
ext = EXTENSION_BY_FORMAT.get(output_format.lower())
if ext is None:
raise ValueError(f"unsupported output format {output_format!r}")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
slug = _slugify(slug_source)
return default_output_dir() / f"{timestamp}-{slug}{ext}"
def _avoid_collision(path: Path) -> Path:
if not path.exists():
return path
parent = path.parent
stem = path.stem
suffix = path.suffix
n = 2
while True:
candidate = parent / f"{stem}-v{n}{suffix}"
if not candidate.exists():
return candidate
n += 1
def save(
image_b64: str,
out_path: Path | None,
*,
force: bool = False,
slug_source: str | None = None,
output_format: str = "png",
) -> Path:
"""Decode *image_b64* and write it to disk. Returns the absolute path."""
try:
data = base64.b64decode(image_b64, validate=True)
except (ValueError, TypeError) as exc:
raise ValueError(f"invalid base64 payload: {exc}") from exc
if out_path is None:
target = default_path(slug_source or "image", output_format)
target.parent.mkdir(parents=True, exist_ok=True)
else:
target = out_path.expanduser().resolve()
if not target.parent.exists():
raise FileNotFoundError(
f"output directory does not exist: {target.parent}"
)
if target.exists() and not force:
target = _avoid_collision(target)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, target)
return target.resolve()
"""Streaming client for the ChatGPT-account ``responses`` endpoint.
Speaks Server-Sent Events, mirrors the codex CLI's request shape exactly,
and extracts the final base64 image from the ``image_generation_call``
output item.
ChatGPT-account tokens (issued by the official ``codex`` CLI's OAuth flow)
can only reach the ``/v1/responses`` surface via
``https://chatgpt.com/backend-api/codex/responses``. The traditional
``api.openai.com/v1/responses`` endpoint rejects them with HTTP 401
``Missing scopes: api.responses.write`` because the token's ``scp`` only
contains ``api.connectors.*``.
"""
from __future__ import annotations
import base64
import io
import json
import mimetypes
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
import httpx
from http_client import (
ORIGINATOR,
OPENAI_BETA_HEADER_VALUE,
build_user_agent,
get_installation_id,
get_session_id,
)
# ChatGPT-account responses endpoint (codex-rs/model-provider-info/src/lib.rs
# CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex").
RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
# Default model accepted by Codex-with-ChatGPT-account; gpt-5.4 and gpt-5.2
# pass model gating, others return "model is not supported".
TOP_LEVEL_MODEL = "gpt-5.4"
DEFAULT_INSTRUCTIONS = (
"You are an image-generation assistant. When the user asks for an image, "
"immediately call the image_generation tool with the user's full "
"description as the prompt. Do not ask follow-up questions and do not "
"emit any text output beyond what the tool returns."
)
MAX_INPUT_BYTES_BEFORE_RESIZE = 5 * 1024 * 1024
MAX_INPUT_BYTES_HARD = 10 * 1024 * 1024
MAX_INPUT_LONG_EDGE = 2048
RETRY_BACKOFF_SECONDS = (2.0, 4.0, 8.0)
# How many times to retry when the SSE stream is cut mid-generation (e.g.,
# Cloudflare / local proxy idle-killing the connection before the image is
# emitted). Each attempt re-POSTs from scratch since ``store=false`` means
# the previous response cannot be resumed by id.
MAX_STREAM_RESTARTS = 3
class ResponsesError(Exception):
"""Base error for the Responses API client."""
class UnauthorizedError(ResponsesError):
"""HTTP 401 — caller should refresh the token and retry once."""
class ForbiddenError(ResponsesError):
"""HTTP 403 — usually a plan/permission issue."""
class RateLimitedError(ResponsesError):
"""HTTP 429 retries exhausted."""
class ApiError(ResponsesError):
"""Other HTTP / business error returned by the backend."""
class StreamBrokenError(ResponsesError):
"""SSE stream ended before ``response.completed``."""
# ---------------------------------------------------------------------------
# Result type.
# ---------------------------------------------------------------------------
@dataclass
class GenerateResult:
image_b64: str
revised_prompt: str | None
call_id: str
model: str
raw_events: list[dict] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Request body builders.
# ---------------------------------------------------------------------------
def _tool_spec(output_format: str, image_model: str | None) -> dict:
spec: dict[str, Any] = {"type": "image_generation", "output_format": output_format}
if image_model:
spec["model"] = image_model
return spec
def _build_generate_body(
prompt: str, *, output_format: str, image_model: str | None
) -> dict:
return {
"model": TOP_LEVEL_MODEL,
"store": False,
"stream": True,
"instructions": DEFAULT_INSTRUCTIONS,
"input": [
{
"role": "user",
"content": [{"type": "input_text", "text": prompt}],
}
],
"tools": [_tool_spec(output_format, image_model)],
}
def _build_edit_body(
prompt: str,
input_images: Iterable[Path],
*,
output_format: str,
image_model: str | None,
) -> dict:
content: list[dict[str, Any]] = [{"type": "input_text", "text": prompt}]
for path in input_images:
content.append(
{"type": "input_image", "image_url": _image_to_data_uri(path)}
)
return {
"model": TOP_LEVEL_MODEL,
"store": False,
"stream": True,
"instructions": DEFAULT_INSTRUCTIONS,
"input": [{"role": "user", "content": content}],
"tools": [_tool_spec(output_format, image_model)],
}
def _image_to_data_uri(path: Path) -> str:
raw = path.read_bytes()
mime, _ = mimetypes.guess_type(path.name)
if mime is None or not mime.startswith("image/"):
mime = "image/png"
if len(raw) > MAX_INPUT_BYTES_BEFORE_RESIZE:
raw, mime = _downscale(raw, mime)
if len(raw) > MAX_INPUT_BYTES_HARD:
raise ValueError(
f"reference image {path} is {len(raw)} bytes after downscaling, "
f"exceeds hard limit of {MAX_INPUT_BYTES_HARD} bytes"
)
encoded = base64.b64encode(raw).decode("ascii")
return f"data:{mime};base64,{encoded}"
def _downscale(raw: bytes, mime: str) -> tuple[bytes, str]:
from PIL import Image # imported lazily so tests without Pillow still work
img = Image.open(io.BytesIO(raw))
img.load()
longest = max(img.size)
if longest <= MAX_INPUT_LONG_EDGE:
return raw, mime
scale = MAX_INPUT_LONG_EDGE / float(longest)
new_size = (max(1, int(img.width * scale)), max(1, int(img.height * scale)))
img = img.resize(new_size, Image.LANCZOS)
buf = io.BytesIO()
fmt = "PNG" if mime == "image/png" else "JPEG"
if fmt == "JPEG" and img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buf, format=fmt)
new_mime = "image/png" if fmt == "PNG" else "image/jpeg"
return buf.getvalue(), new_mime
# ---------------------------------------------------------------------------
# Public entrypoints.
# ---------------------------------------------------------------------------
def generate(
prompt: str,
*,
access_token: str,
account_id: str | None,
input_images: Iterable[Path] | None = None,
output_format: str = "png",
image_model: str | None = None,
) -> GenerateResult:
"""Call the ChatGPT-account ``responses`` endpoint and return the image.
Parameters
----------
prompt:
Natural-language description of the image to generate, or — when
editing — the change to apply to the reference image(s).
access_token:
Bearer token resolved by :mod:`auth`.
account_id:
ChatGPT account id (``tokens.account_id`` in ``auth.json``). The
endpoint requires it as the ``chatgpt-account-id`` header; missing
or stale ids surface as HTTP 401.
input_images:
Optional iterable of reference image paths. When non-empty the call
is treated as an *edit*.
output_format:
``png`` / ``webp`` / ``jpeg``.
image_model:
Advanced override of the ``image_generation`` tool's ``model``
field. ``None`` (default) lets the server pick.
"""
if input_images:
body = _build_edit_body(
prompt,
list(input_images),
output_format=output_format,
image_model=image_model,
)
else:
body = _build_generate_body(
prompt, output_format=output_format, image_model=image_model
)
headers = _build_headers(access_token, account_id)
return _stream_with_restarts(body, headers)
def _build_headers(access_token: str, account_id: str | None) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": build_user_agent(),
"originator": ORIGINATOR,
"session-id": get_session_id(),
"x-codex-installation-id": get_installation_id(),
"OpenAI-Beta": OPENAI_BETA_HEADER_VALUE,
}
if account_id:
headers["chatgpt-account-id"] = account_id
return headers
def _stream_with_restarts(body: dict, headers: dict[str, str]) -> GenerateResult:
"""POST + consume SSE, restarting from scratch when the stream is cut.
``store=false`` means a broken stream cannot be resumed by response id,
so the only recovery is to re-POST. We do this up to
``MAX_STREAM_RESTARTS`` times before giving up.
"""
timeout = httpx.Timeout(connect=30.0, read=900.0, write=60.0, pool=30.0)
last_partial_error: Exception | None = None
with httpx.Client(
http2=True,
timeout=timeout,
trust_env=True,
follow_redirects=False,
) as client:
for restart in range(MAX_STREAM_RESTARTS + 1):
try:
return _post_once(client, body, headers)
except StreamBrokenError as exc:
last_partial_error = exc
if restart >= MAX_STREAM_RESTARTS:
raise
delay = RETRY_BACKOFF_SECONDS[
min(restart, len(RETRY_BACKOFF_SECONDS) - 1)
]
time.sleep(delay)
continue
# Loop only exits via return or raise; this is defensive.
raise ApiError(f"stream restarts exhausted: {last_partial_error}")
def _post_once(
client: httpx.Client, body: dict, headers: dict[str, str]
) -> GenerateResult:
"""Single POST attempt with HTTP-status retries (excluding mid-stream cuts)."""
last_error: Exception | None = None
for attempt in range(len(RETRY_BACKOFF_SECONDS) + 1):
try:
with client.stream(
"POST", RESPONSES_URL, json=body, headers=headers
) as response:
status = response.status_code
if status == 200:
return _consume_sse(response)
if status == 401:
raise UnauthorizedError(
f"access token rejected: {_safe_read(response)}"
)
if status == 403:
raise ForbiddenError(f"forbidden: {_safe_read(response)}")
if status == 429:
if attempt >= len(RETRY_BACKOFF_SECONDS):
raise RateLimitedError("rate-limit retries exhausted")
delay = _retry_after(response, attempt)
time.sleep(delay)
continue
if 500 <= status < 600:
text = _safe_read(response)
if attempt >= len(RETRY_BACKOFF_SECONDS):
raise ApiError(f"server error {status}: {text}")
time.sleep(RETRY_BACKOFF_SECONDS[attempt])
continue
raise ApiError(f"unexpected status {status}: {_safe_read(response)}")
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
last_error = exc
if attempt < len(RETRY_BACKOFF_SECONDS):
time.sleep(RETRY_BACKOFF_SECONDS[attempt])
continue
raise ApiError(f"network error: {exc}") from exc
raise ApiError(f"retries exhausted: {last_error}")
def _retry_after(response: httpx.Response, attempt: int) -> float:
raw = response.headers.get("Retry-After")
if not raw:
return RETRY_BACKOFF_SECONDS[attempt]
try:
return float(raw)
except ValueError:
return RETRY_BACKOFF_SECONDS[attempt]
def _safe_read(response: httpx.Response) -> str:
try:
return response.read().decode("utf-8", errors="replace")[:500]
except Exception: # noqa: BLE001
return "<unreadable body>"
# ---------------------------------------------------------------------------
# SSE consumer.
# ---------------------------------------------------------------------------
def _iter_sse_events(response: httpx.Response) -> Iterable[dict]:
"""Yield decoded JSON payloads from an SSE response.
Re-raises transport-level errors as :class:`StreamBrokenError` so the
caller can decide whether to restart the request.
"""
event_name: str | None = None
data_chunks: list[str] = []
line_source = response.iter_lines()
while True:
try:
line = next(line_source)
except StopIteration:
break
except (
httpx.RemoteProtocolError,
httpx.ReadError,
httpx.ReadTimeout,
httpx.ProtocolError,
) as exc:
raise StreamBrokenError(f"transport error mid-stream: {exc}") from exc
if line is None:
continue
if line == "":
if data_chunks:
raw = "\n".join(data_chunks)
data_chunks.clear()
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {"_raw": raw}
if event_name and "type" not in payload:
payload["type"] = event_name
event_name = None
yield payload
else:
event_name = None
continue
if line.startswith(":"):
# Comment / keepalive — ignore but the heartbeat keeps the
# HTTP/2 stream open.
continue
if line.startswith("event:"):
event_name = line[len("event:") :].strip()
continue
if line.startswith("data:"):
data_chunks.append(line[len("data:") :].lstrip())
continue
def _consume_sse(response: httpx.Response) -> GenerateResult:
raw_events: list[dict] = []
final_image: str | None = None
revised_prompt: str | None = None
call_id: str | None = None
failure: dict | None = None
for event in _iter_sse_events(response):
raw_events.append(event)
etype = event.get("type")
if etype == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "image_generation_call":
final_image = item.get("result") or final_image
revised_prompt = item.get("revised_prompt") or revised_prompt
call_id = item.get("id") or call_id
elif etype == "response.completed":
response_obj = event.get("response") or {}
for item in response_obj.get("output") or []:
if item.get("type") == "image_generation_call":
final_image = item.get("result") or final_image
revised_prompt = item.get("revised_prompt") or revised_prompt
call_id = item.get("id") or call_id
elif etype == "response.failed":
failure = event.get("response") or event
elif etype == "error":
failure = event
if failure:
raise ApiError(f"response failed: {failure}")
if not final_image:
raise StreamBrokenError("stream ended without producing an image")
return GenerateResult(
image_b64=final_image,
revised_prompt=revised_prompt,
call_id=call_id or "",
model=TOP_LEVEL_MODEL,
raw_events=raw_events,
)
"""Shared pytest fixtures."""
from __future__ import annotations
import sys
from pathlib import Path
# Make ``scripts/`` importable as top-level modules.
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
"""Unit tests for :mod:`auth`."""
from __future__ import annotations
import base64
import json
import time
from pathlib import Path
import jwt as pyjwt
import pytest
import responses
import auth
# ---------------------------------------------------------------------------
# Helpers.
# ---------------------------------------------------------------------------
def _make_jwt(exp_offset_seconds: int, extra: dict | None = None) -> str:
claims = {"exp": int(time.time()) + exp_offset_seconds}
if extra:
claims.update(extra)
return pyjwt.encode(claims, "secret", algorithm="HS256")
@pytest.fixture
def codex_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "codex"
monkeypatch.setenv("CODEX_HOME", str(home))
return home
def _write_auth(codex_dir: Path, payload: dict) -> Path:
codex_dir.mkdir(parents=True, exist_ok=True)
path = codex_dir / "auth.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return path
# ---------------------------------------------------------------------------
# _load_auth_json
# ---------------------------------------------------------------------------
def test_load_auth_json_missing(codex_dir: Path) -> None:
assert auth._load_auth_json() is None
def test_load_auth_json_corrupt_backs_up(codex_dir: Path) -> None:
codex_dir.mkdir(parents=True, exist_ok=True)
bad = codex_dir / "auth.json"
bad.write_text("not json{", encoding="utf-8")
with pytest.raises(auth.AuthError):
auth._load_auth_json()
# Original should have been renamed.
assert not bad.exists()
backups = list(codex_dir.glob("auth.json.broken-*"))
assert backups, "broken file should be backed up"
# ---------------------------------------------------------------------------
# _decode_jwt_exp
# ---------------------------------------------------------------------------
def test_decode_jwt_exp_returns_int() -> None:
token = _make_jwt(60)
assert auth._decode_jwt_exp(token) is not None
def test_decode_jwt_exp_invalid_returns_none() -> None:
assert auth._decode_jwt_exp("not-a-jwt") is None
# ---------------------------------------------------------------------------
# logout
# ---------------------------------------------------------------------------
def test_logout_strips_tokens_but_keeps_file(codex_dir: Path) -> None:
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {"access_token": "x", "refresh_token": "y"},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
auth.logout()
data = json.loads((codex_dir / "auth.json").read_text())
assert "tokens" not in data
assert data["auth_mode"] == "Chatgpt"
assert data["last_refresh"] == "2025-01-01T00:00:00+00:00"
def test_logout_when_no_file_is_noop(codex_dir: Path) -> None:
auth.logout() # should not raise
assert not (codex_dir / "auth.json").exists()
# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------
def test_status_missing_raises(codex_dir: Path) -> None:
with pytest.raises(auth.AuthError):
auth.status()
def test_status_reports_email_plan_and_expiry(codex_dir: Path) -> None:
id_token = _make_jwt(
3600,
{
"email": "user@example.com",
"https://api.openai.com/auth": {"chatgpt_plan_type": "plus"},
},
)
access_token = _make_jwt(3600)
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": id_token,
"access_token": access_token,
"refresh_token": "r",
"account_id": "acct_1",
},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
info = auth.status()
assert info["email"] == "user@example.com"
assert info["plan"] == "plus"
assert info["account_id"] == "acct_1"
assert info["expires_at"].startswith("20")
# ---------------------------------------------------------------------------
# refresh_tokens — happy path
# ---------------------------------------------------------------------------
@responses.activate
def test_refresh_tokens_success_persists(codex_dir: Path) -> None:
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": _make_jwt(60),
"access_token": _make_jwt(60),
"refresh_token": "old-refresh",
"account_id": "acct_1",
},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
new_access = _make_jwt(3600)
responses.add(
responses.POST,
auth.TOKEN_URL,
json={
"access_token": new_access,
"refresh_token": "new-refresh",
"id_token": _make_jwt(3600),
},
status=200,
)
creds = auth.refresh_tokens("old-refresh", force=True)
assert creds.access_token == new_access
assert creds.refresh_token == "new-refresh"
on_disk = json.loads((codex_dir / "auth.json").read_text())
assert on_disk["tokens"]["access_token"] == new_access
assert on_disk["tokens"]["refresh_token"] == "new-refresh"
@responses.activate
def test_refresh_tokens_expired_clears_tokens(codex_dir: Path) -> None:
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": "x",
"access_token": "y",
"refresh_token": "old",
"account_id": "z",
},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
responses.add(
responses.POST,
auth.TOKEN_URL,
json={"error": "refresh_token_expired"},
status=400,
)
with pytest.raises(auth.AuthError):
auth.refresh_tokens("old", force=True)
on_disk = json.loads((codex_dir / "auth.json").read_text())
assert "tokens" not in on_disk
assert on_disk["auth_mode"] == "Chatgpt"
# ---------------------------------------------------------------------------
# _resolve_credentials — token still fresh
# ---------------------------------------------------------------------------
def test_resolve_credentials_returns_fresh_token(
codex_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
fresh = _make_jwt(3600)
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": _make_jwt(3600),
"access_token": fresh,
"refresh_token": "r",
"account_id": "acct",
},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
def _boom(*_args, **_kwargs):
raise AssertionError("should not refresh when token is fresh")
monkeypatch.setattr(auth, "refresh_tokens", _boom)
monkeypatch.setattr(auth, "interactive_login", _boom)
assert auth.get_access_token() == fresh
# ---------------------------------------------------------------------------
# _resolve_credentials — token near expiry triggers refresh
# ---------------------------------------------------------------------------
def test_resolve_credentials_triggers_refresh_when_near_expiry(
codex_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
soon = _make_jwt(60) # within 5min leeway → refresh
_write_auth(
codex_dir,
{
"auth_mode": "Chatgpt",
"tokens": {
"id_token": _make_jwt(60),
"access_token": soon,
"refresh_token": "rrr",
"account_id": "acct",
},
"last_refresh": "2025-01-01T00:00:00+00:00",
},
)
refreshed_creds = auth.Credentials(
access_token="REFRESHED",
refresh_token="rrr2",
id_token=None,
account_id="acct",
last_refresh=auth._now_iso(),
source=codex_dir / "auth.json",
)
called = {"n": 0}
def fake_refresh(refresh_token: str, *, force: bool = False):
called["n"] += 1
assert refresh_token == "rrr"
return refreshed_creds
monkeypatch.setattr(auth, "refresh_tokens", fake_refresh)
monkeypatch.setattr(
auth,
"interactive_login",
lambda: pytest.fail("should not fall back to OAuth"),
)
assert auth.get_access_token() == "REFRESHED"
assert called["n"] == 1
# ---------------------------------------------------------------------------
# PKCE helpers — light sanity check
# ---------------------------------------------------------------------------
def test_pkce_pair_uses_s256() -> None:
import hashlib
verifier, challenge = auth._make_pkce_pair()
digest = hashlib.sha256(verifier.encode()).digest()
expected = (
base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
)
assert challenge == expected
"""Unit tests for :mod:`output`."""
from __future__ import annotations
import base64
from pathlib import Path
import pytest
import output
def _payload(data: bytes = b"hello-bytes") -> str:
return base64.b64encode(data).decode("ascii")
def test_save_explicit_path(tmp_path: Path) -> None:
target = tmp_path / "out.png"
written = output.save(_payload(b"abc"), target, output_format="png")
assert written == target.resolve()
assert target.read_bytes() == b"abc"
def test_save_collision_appends_version(tmp_path: Path) -> None:
target = tmp_path / "out.png"
target.write_bytes(b"old")
written = output.save(_payload(b"new"), target, output_format="png")
assert written.name == "out-v2.png"
assert target.read_bytes() == b"old"
assert written.read_bytes() == b"new"
def test_save_force_overwrites(tmp_path: Path) -> None:
target = tmp_path / "out.png"
target.write_bytes(b"old")
written = output.save(_payload(b"new"), target, force=True, output_format="png")
assert written == target.resolve()
assert target.read_bytes() == b"new"
def test_save_missing_parent_raises(tmp_path: Path) -> None:
target = tmp_path / "nope" / "out.png"
with pytest.raises(FileNotFoundError):
output.save(_payload(), target, output_format="png")
def test_save_default_path_uses_codex_home(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CODEX_HOME", str(tmp_path))
written = output.save(_payload(b"x"), None, output_format="png", slug_source="A panda!")
assert written.parent == (tmp_path / "generated_images" / "codex-image").resolve()
assert written.suffix == ".png"
assert "a-panda" in written.name
def test_save_rejects_unsupported_format(tmp_path: Path) -> None:
with pytest.raises(ValueError):
output.default_path("x", "gif")
def test_save_bad_base64_raises(tmp_path: Path) -> None:
with pytest.raises(ValueError):
output.save("###not-base64###", tmp_path / "out.png", output_format="png")
def test_default_path_supports_webp_and_jpeg(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("CODEX_HOME", str(tmp_path))
assert output.default_path("foo", "webp").suffix == ".webp"
assert output.default_path("foo", "jpeg").suffix == ".jpg"
"""Unit tests for :mod:`responses_client`."""
from __future__ import annotations
import base64
import json
from pathlib import Path
from typing import Iterable
import pytest
import responses
import http_client
import responses_client as rc
# ---------------------------------------------------------------------------
# Helpers — build an SSE response body.
# ---------------------------------------------------------------------------
def _sse(events: Iterable[tuple[str, dict]]) -> str:
lines: list[str] = []
for event_name, payload in events:
lines.append(f"event: {event_name}")
lines.append("data: " + json.dumps(payload))
lines.append("") # blank line terminates an event
return "\n".join(lines) + "\n"
def _image_b64() -> str:
# Minimal valid base64.
return base64.b64encode(b"\x89PNG\r\n\x1a\n-fake").decode("ascii")
# ---------------------------------------------------------------------------
# Request body shape.
# ---------------------------------------------------------------------------
def test_build_generate_body_matches_codex_default() -> None:
body = rc._build_generate_body(
"hello", output_format="png", image_model=None
)
assert body == {
"model": "gpt-5.5",
"input": [
{
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
}
],
"tools": [{"type": "image_generation", "output_format": "png"}],
"stream": True,
}
# Critical: tools[0] must NOT contain a "model" key by default.
assert "model" not in body["tools"][0]
def test_build_generate_body_includes_image_model_when_explicit() -> None:
body = rc._build_generate_body(
"hi", output_format="png", image_model="gpt-image-2"
)
assert body["tools"][0]["model"] == "gpt-image-2"
# ---------------------------------------------------------------------------
# SSE consumption.
# ---------------------------------------------------------------------------
@responses.activate
def test_generate_extracts_image_from_output_item_done() -> None:
img_b64 = _image_b64()
body = _sse(
[
(
"response.output_item.done",
{
"item": {
"id": "ig_1",
"type": "image_generation_call",
"result": img_b64,
"revised_prompt": "a refined prompt",
}
},
),
("response.completed", {"response": {"output": []}}),
]
)
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=body,
status=200,
content_type="text/event-stream",
)
result = rc.generate("a panda", access_token="tok")
assert result.image_b64 == img_b64
assert result.revised_prompt == "a refined prompt"
assert result.call_id == "ig_1"
@responses.activate
def test_generate_extracts_image_from_response_completed() -> None:
img_b64 = _image_b64()
body = _sse(
[
(
"response.completed",
{
"response": {
"output": [
{
"id": "ig_42",
"type": "image_generation_call",
"result": img_b64,
}
]
}
},
)
]
)
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=body,
status=200,
content_type="text/event-stream",
)
result = rc.generate("x", access_token="tok")
assert result.image_b64 == img_b64
assert result.call_id == "ig_42"
@responses.activate
def test_generate_raises_on_response_failed() -> None:
body = _sse(
[("response.failed", {"response": {"error": {"message": "boom"}}})]
)
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=body,
status=200,
content_type="text/event-stream",
)
with pytest.raises(rc.ApiError):
rc.generate("p", access_token="tok")
@responses.activate
def test_generate_raises_stream_broken_when_no_image() -> None:
body = _sse([("response.completed", {"response": {"output": []}})])
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=body,
status=200,
content_type="text/event-stream",
)
with pytest.raises(rc.StreamBrokenError):
rc.generate("p", access_token="tok")
# ---------------------------------------------------------------------------
# HTTP status handling.
# ---------------------------------------------------------------------------
@responses.activate
def test_401_raises_unauthorized() -> None:
responses.add(
responses.POST,
rc.RESPONSES_URL,
json={"error": "invalid_token"},
status=401,
)
with pytest.raises(rc.UnauthorizedError):
rc.generate("p", access_token="tok")
@responses.activate
def test_403_raises_forbidden() -> None:
responses.add(
responses.POST,
rc.RESPONSES_URL,
json={"error": "plan"},
status=403,
)
with pytest.raises(rc.ForbiddenError):
rc.generate("p", access_token="tok")
@responses.activate
def test_500_then_success_retries(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(rc.time, "sleep", lambda *_a, **_kw: None)
img_b64 = _image_b64()
responses.add(
responses.POST, rc.RESPONSES_URL, body="boom", status=500
)
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=_sse(
[
(
"response.output_item.done",
{
"item": {
"id": "ig_2",
"type": "image_generation_call",
"result": img_b64,
}
},
)
]
),
status=200,
content_type="text/event-stream",
)
result = rc.generate("p", access_token="tok")
assert result.image_b64 == img_b64
assert len(responses.calls) == 2
@responses.activate
def test_500_persistent_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(rc.time, "sleep", lambda *_a, **_kw: None)
for _ in range(4):
responses.add(
responses.POST, rc.RESPONSES_URL, body="boom", status=500
)
with pytest.raises(rc.ApiError):
rc.generate("p", access_token="tok")
@responses.activate
def test_429_persistent_raises_rate_limited(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(rc.time, "sleep", lambda *_a, **_kw: None)
for _ in range(4):
responses.add(
responses.POST,
rc.RESPONSES_URL,
json={"error": "rate"},
status=429,
)
with pytest.raises(rc.RateLimitedError):
rc.generate("p", access_token="tok")
# ---------------------------------------------------------------------------
# Fingerprint headers.
# ---------------------------------------------------------------------------
@responses.activate
def test_request_carries_fingerprint_headers() -> None:
img_b64 = _image_b64()
responses.add(
responses.POST,
rc.RESPONSES_URL,
body=_sse(
[
(
"response.output_item.done",
{
"item": {
"id": "ig_x",
"type": "image_generation_call",
"result": img_b64,
}
},
)
]
),
status=200,
content_type="text/event-stream",
)
rc.generate("p", access_token="THE_TOKEN")
request = responses.calls[0].request
assert request.headers["Authorization"] == "Bearer THE_TOKEN"
assert request.headers["originator"] == "codex_cli_rs"
assert request.headers["Accept"] == "text/event-stream"
assert request.headers["Content-Type"] == "application/json"
assert request.headers["User-Agent"].startswith("codex_cli_rs/")
assert "session-id" in request.headers
assert "x-codex-installation-id" in request.headers
# Forbidden headers must NOT be present.
forbidden = [
"x-codex-turn-state",
"x-codex-turn-metadata",
"x-openai-subagent",
"x-openai-memgen-request",
"x-responsesapi-include-timing-metrics",
"thread-id",
"x-client-request-id",
"x-openai-internal-codex-residency",
]
for header in forbidden:
assert header not in request.headers
# ---------------------------------------------------------------------------
# Edit body.
# ---------------------------------------------------------------------------
def test_edit_body_embeds_data_uri(tmp_path: Path) -> None:
img = tmp_path / "ref.png"
img.write_bytes(b"hello-png")
body = rc._build_edit_body(
"make blue", [img], output_format="png", image_model=None
)
content = body["input"][0]["content"]
assert content[0] == {"type": "input_text", "text": "make blue"}
assert content[1]["type"] == "input_image"
assert content[1]["image_url"].startswith("data:image/png;base64,")
# ---------------------------------------------------------------------------
# Header sanity — User-Agent template.
# ---------------------------------------------------------------------------
def test_user_agent_template_uses_codex_pretend_version() -> None:
ua = http_client.build_user_agent()
assert ua.startswith(f"codex_cli_rs/{http_client.CODEX_PRETEND_VERSION}")