Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Set options in `.env` or pass environment variables:
- `CHATGPT_LOCAL_ENABLE_WEB_SEARCH`: `true|false` to enable default web search tool
- `CHATGPT_LOCAL_MODEL_SYNC`: `true|false` to discover account models automatically (default `true`)
- `CHATGPT_LOCAL_MODEL_REFRESH_INTERVAL`: model catalog refresh interval in seconds (default `3600`)
- `CHATGPT_LOCAL_IMAGE_MODEL`: model that orchestrates image requests (default `gpt-5.4-mini`)

## Logs
Set `VERBOSE=true` to include extra logging for troubleshooting upstream or chat app requests. Please include and use these logs when submitting bug reports.
Expand Down
124 changes: 124 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Fork: geração de imagem no ChatMock

Fork de [RayBytes/ChatMock](https://github.com/RayBytes/ChatMock) (v1.40) com um
objetivo só: **fazer o ChatMock devolver imagens**.

O backend do Codex já sabia desenhar — quem não sabia receber era o ChatMock.

## O que foi descoberto

O endpoint `https://chatgpt.com/backend-api/codex/responses`, o mesmo que o
ChatMock já usava para texto, aceita o tool `image_generation` e devolve a imagem
em base64. Medido, não deduzido:

- modelo que desenha: `gpt-image-2-codex` (não dá para trocar);
- `tool_choice: {"type": "image_generation"}` funciona e força a geração;
- o consumo aparece em `tool_usage.image_gen` da resposta — ou seja, **sai da
cota do plano ChatGPT/Codex**, não de crédito de API;
- `n` no tool é recusado (`Unknown parameter: 'tools[0].n'`): uma imagem por
requisição;
- `size` e `quality` são aceitos mas ignorados — o echo volta sempre `"auto"`.
A resolução é escolhida a partir do prompt: pedindo 1024x1024 veio 1254x1254,
pedindo 1536x1024 veio 1536x1024 na mosca. Ou seja, dá para pedir — só não dá
para garantir.

## O bug que estava por baixo

`response.completed` do backend do Codex vem com `output: []`. Sempre. Os itens
de saída só existem nos eventos `response.output_item.done`.

Como `aggregate_response_from_sse()` devolvia o objeto do evento final, **todo
`POST /v1/responses` com `stream: false` respondia com output vazio** — inclusive
para texto puro, sem imagem nenhuma. A resposta parecia bem-sucedida (`status:
completed`, `usage` preenchido) e não tinha conteúdo.

Corrigido em `chatmock/responses_api.py`: os itens são acumulados por
`output_index` e remontados no objeto final quando ele vem vazio.

## O que mudou

| Arquivo | Mudança |
|---|---|
| `chatmock/images_api.py` | **novo** — monta o payload e converte para o formato da Images API. Sem import interno de propósito: `utils.py` importa este módulo, e `model_catalog -> utils` fecharia o ciclo |
| `chatmock/responses_api.py` | remonta `output` a partir dos `output_item.done` (o bug acima); `collect_images_from_sse()`, que fica aqui porque é este o módulo que já lê SSE |
| `chatmock/routes_openai.py` | rota `/v1/images/generations`; `image_generation` liberado em `responses_tools`; imagem vira data-url no `/v1/chat/completions` |
| `chatmock/utils.py` | imagem como delta de conteúdo no chat streaming, **depois** de fechar o `<think>` — o fechamento virou o helper `_close_think_tag()`, usado nos três pontos que antes repetiam o mesmo bloco |
| `chatmock/app.py`, `chatmock/cli.py` | flag `--image-model` / env `CHATGPT_LOCAL_IMAGE_MODEL` |
| `tests/test_routes.py` | 8 testes novos |

## Uso

```bash
python chatmock.py serve --port 8000
```

### `POST /v1/images/generations`

Compatível com a Images API da OpenAI, então SDK oficial, n8n e afins falam com
ela sem adaptação:

```bash
curl http://127.0.0.1:8000/v1/images/generations \
-H "Content-Type: application/json" \
-d '{"prompt":"logo minimalista de uma raposa geometrica","size":"1024x1024"}'
```

```json
{ "created": 1786806013,
"data": [{ "b64_json": "...", "revised_prompt": "...", "size": "1254x1254" }],
"usage": { "input_tokens": 52, "output_tokens": 915, "total_tokens": 967 } }
```

Parâmetros:

- `prompt` — obrigatório.
- `n` — de 1 a 4. Cada unidade é **uma requisição a mais** na sua cota, porque o
backend não aceita `n` no tool.
- `size` — vira instrução em texto ("quadrada", "horizontal", "vertical" +
os pixels pedidos), já que o backend ignora o parâmetro. Trate como pedido,
não como garantia: a proporção costuma sair certa, a resolução exata não.
- `quality`, `output_format`, `output_compression`, `background`, `moderation` —
repassados ao tool. Se o backend recusar algum com `unknown_parameter`, a
requisição é refeita uma vez com o tool pelado, para o cliente receber a imagem
em vez de um 400.
- `image` — data-url (ou lista delas) para usar como referência. Não é a
`/v1/images/edits` multipart da OpenAI, é um atalho em JSON.
- `chat_model` — troca o modelo que orquestra só nesta chamada.
- `response_format: "url"` responde 400: a imagem vem em base64 e o ChatMock não
hospeda arquivo — igual ao comportamento do `gpt-image-1` na API real.

### Pelo `/v1/chat/completions`

```json
{ "model": "gpt-5.4-mini",
"messages": [{"role": "user", "content": "gere uma imagem de um cubo azul"}],
"responses_tools": [{"type": "image_generation"}] }
```

A imagem chega embutida no `content` como `![alt](data:image/png;base64,...)`,
em streaming ou não. Serve para UIs de chat que renderizam markdown.

Com `--reasoning-compat think-tags` (o padrão), a imagem sai **depois** do
`</think>`. Sem isso ela cairia dentro do bloco de raciocínio e sumiria em
qualquer cliente que esconde o `<think>` — que é justamente o motivo do modo
existir.

## Limites conhecidos

- **Peso.** Uma imagem passa de 2,5 MB em base64. Não ligue `--verbose` nessas
rotas: o log imprime o corpo inteiro.
- Rotas Ollama (`/api/chat`) não foram tocadas — continuam só texto.
- O modelo de imagem é escolhido pelo backend. `model` no corpo da requisição não
troca nada; existe para não quebrar cliente que sempre manda `gpt-image-1`.

## Rebase

As mudanças em arquivos que já existiam são pequenas e localizadas — o grosso
está em `images_api.py`, que é arquivo novo. Ao subir de versão, os pontos de
atrito são `aggregate_response_from_sse()`, os dois trechos de
`response.output_item.done` (em `routes_openai.py` e `utils.py`) e o
`_close_think_tag()`, que substituiu duas cópias de um bloco que o upstream
repetia dentro de `sse_translate_chat()`.

Comentários e mensagens de erro do código estão em inglês, como o resto do
repositório — só este arquivo está em português.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ account. The current catalog commonly includes:

- Tool / function calling
- Vision / image input
- Image generation (`/v1/images/generations`, or as a tool in a chat request)
- Thinking summaries (via think tags)
- Configurable thinking effort
- Fast mode for supported models
Expand All @@ -128,6 +129,7 @@ All flags go after `chatmock serve`. These can also be set as environment variab
| `--expose-reasoning-models` | `CHATGPT_LOCAL_EXPOSE_REASONING_MODELS` | true/false | false | List each reasoning level as its own model |
| `--model-sync` | `CHATGPT_LOCAL_MODEL_SYNC` | true/false | true | Discover account models automatically |
| `--model-refresh-interval` | `CHATGPT_LOCAL_MODEL_REFRESH_INTERVAL` | seconds | 3600 | Refresh interval for model discovery |
| `--image-model` | `CHATGPT_LOCAL_IMAGE_MODEL` | model slug | gpt-5.4-mini | Model that orchestrates image requests |

<details>
<summary><b>Web search in a request</b></summary>
Expand All @@ -143,6 +145,53 @@ All flags go after `chatmock serve`. These can also be set as environment variab

</details>

<details>
<summary><b>Generating an image</b></summary>

`/v1/images/generations` mirrors the OpenAI Images API, so existing clients work
unchanged:

```bash
curl http://127.0.0.1:8000/v1/images/generations \
-H "Content-Type: application/json" \
-d '{"prompt": "a minimalist geometric fox logo", "size": "1024x1024"}'
```

```json
{ "created": 1786806013,
"data": [{ "b64_json": "...", "revised_prompt": "...", "size": "1254x1254" }],
"usage": { "input_tokens": 52, "output_tokens": 915, "total_tokens": 967 } }
```

The same tool works inside a chat request on `/v1/chat/completions` and
`/api/chat`, where the image comes back embedded in the message content as
`![alt](data:image/png;base64,...)`:

```json
{
"model": "gpt-5.4-mini",
"messages": [{"role": "user", "content": "draw a blue cube"}],
"responses_tools": [{"type": "image_generation"}]
}
```

Worth knowing:

- The picture is always drawn by the backend's own image model. `model` in the
request body changes nothing; it is accepted so clients that always send
`gpt-image-1` keep working.
- `n` goes up to 4, and each unit is a separate upstream request, because the
backend refuses `n` inside the tool.
- `size` is passed along but the backend decides the final resolution from the
prompt, so it is written into the instructions as well. Treat it as a request,
not a guarantee.
- `response_format: "url"` is rejected: the backend returns base64 and ChatMock
hosts no files.
- One image is a couple of megabytes of base64. `--verbose` prints request bodies,
so leave it off when passing reference images.

</details>

<details>
<summary><b>Fast mode in a request</b></summary>

Expand Down
7 changes: 7 additions & 0 deletions chatmock/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from flask_sock import Sock

from .http import build_cors_headers
from .images_api import DEFAULT_IMAGE_ORCHESTRATOR_MODEL
from .model_catalog import DEFAULT_REFRESH_INTERVAL_SECONDS, ModelCatalog
from .routes_openai import openai_bp
from .routes_ollama import ollama_bp
Expand All @@ -24,8 +25,13 @@ def create_app(
default_web_search: bool = False,
model_sync: bool | None = None,
model_refresh_interval: float | None = None,
image_orchestrator_model: str | None = None,
) -> Flask:
app = Flask(__name__)
if not (isinstance(image_orchestrator_model, str) and image_orchestrator_model.strip()):
image_orchestrator_model = (
os.getenv("CHATGPT_LOCAL_IMAGE_MODEL") or DEFAULT_IMAGE_ORCHESTRATOR_MODEL
)
if model_sync is None:
model_sync = (os.getenv("CHATGPT_LOCAL_MODEL_SYNC") or "true").strip().lower() in (
"1",
Expand Down Expand Up @@ -53,6 +59,7 @@ def create_app(
DEFAULT_WEB_SEARCH=bool(default_web_search),
MODEL_SYNC=bool(model_sync),
MODEL_REFRESH_INTERVAL=float(model_refresh_interval),
IMAGE_ORCHESTRATOR_MODEL=image_orchestrator_model.strip(),
)
app.extensions["chatmock_model_catalog"] = ModelCatalog(
enabled=bool(model_sync),
Expand Down
14 changes: 14 additions & 0 deletions chatmock/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .app import create_app
from .config import CLIENT_ID_DEFAULT
from .images_api import DEFAULT_IMAGE_ORCHESTRATOR_MODEL
from .limits import RateLimitWindow, compute_reset_at, load_rate_limit_snapshot
from .oauth import OAuthHTTPServer, OAuthHandler, REQUIRED_PORT, URL_BASE, run_device_code_login
from .utils import eprint, get_home_dir, load_chatgpt_tokens, parse_jwt_claims, read_auth_file
Expand Down Expand Up @@ -242,6 +243,7 @@ def cmd_serve(
default_web_search: bool,
model_sync: bool = True,
model_refresh_interval: float = 3600,
image_orchestrator_model: str | None = None,
) -> int:
app = create_app(
verbose=verbose,
Expand All @@ -255,6 +257,7 @@ def cmd_serve(
default_web_search=default_web_search,
model_sync=model_sync,
model_refresh_interval=model_refresh_interval,
image_orchestrator_model=image_orchestrator_model,
)

app.run(host=host, use_reloader=False, port=port, threaded=True)
Expand Down Expand Up @@ -345,6 +348,16 @@ def main() -> None:
help="Refresh the ChatGPT model catalog after this many seconds (default: 3600).",
)

p_serve.add_argument(
"--image-model",
default=os.getenv("CHATGPT_LOCAL_IMAGE_MODEL", DEFAULT_IMAGE_ORCHESTRATOR_MODEL),
metavar="MODEL",
help=(
"Model that orchestrates /v1/images/generations. The picture itself is always drawn "
f"by the backend's image model, so the cheapest one does (default: {DEFAULT_IMAGE_ORCHESTRATOR_MODEL})."
),
)

p_info = sub.add_parser("info", help="Print current stored tokens and derived account id")
p_info.add_argument("--json", action="store_true", help="Output raw auth.json contents")

Expand All @@ -368,6 +381,7 @@ def main() -> None:
default_web_search=args.enable_web_search,
model_sync=args.model_sync,
model_refresh_interval=args.model_refresh_interval,
image_orchestrator_model=args.image_model,
)
)
elif args.command == "info":
Expand Down
Loading