codex-as-api 0.6.3__tar.gz → 0.6.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/PKG-INFO +55 -12
  2. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/README.md +54 -11
  3. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/RELEASES.md +38 -0
  4. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/pyproject.toml +1 -1
  5. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/__init__.py +1 -1
  6. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/model_capabilities.py +13 -6
  7. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/provider.py +19 -9
  8. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/server.py +92 -3
  9. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_provider.py +76 -2
  10. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_server.py +237 -0
  11. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/.gitignore +0 -0
  12. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/LICENSE +0 -0
  13. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/THIRD_PARTY_NOTICES.md +0 -0
  14. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/config/model-capabilities.json +0 -0
  15. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/config/o200k_base.tiktoken +0 -0
  16. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/requirements-server.txt +0 -0
  17. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/requirements.txt +0 -0
  18. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/anthropic_adapter.py +0 -0
  19. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/auth.py +0 -0
  20. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/codex_config.py +0 -0
  21. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/messages.py +0 -0
  22. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/o200k_tokenizer.py +0 -0
  23. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/src/codex_as_api/protocol.py +0 -0
  24. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/__init__.py +0 -0
  25. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/conftest.py +0 -0
  26. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/fixtures/o200k_base_encode_ordinary.json +0 -0
  27. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_anthropic_adapter.py +0 -0
  28. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_auth.py +0 -0
  29. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_codex_config.py +0 -0
  30. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_messages.py +0 -0
  31. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_model_capabilities.py +0 -0
  32. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_o200k_tokenizer.py +0 -0
  33. {codex_as_api-0.6.3 → codex_as_api-0.6.4}/tests/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-as-api
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server.
5
5
  Project-URL: Homepage, https://github.com/Eunho-J/codex-as-api
6
6
  Project-URL: Repository, https://github.com/Eunho-J/codex-as-api
@@ -49,7 +49,7 @@ Use ChatGPT / Codex OAuth as a local OpenAI-compatible API server.
49
49
  - **Tool calling** — function calls, tool results, and parallel tool calls
50
50
  - **Image support** — generation, inspection, multimodal Chat input, and capability-gated `original` image detail in classic Responses
51
51
  - **Reasoning** — configurable effort/context, `standard` mode compatibility, persisted reasoning, and streaming thinking content
52
- - **Codex features** — `prompt_cache_key`, process-local `response_id` continuation, subagent headers, and remote compaction
52
+ - **Codex features** — session-aware `prompt_cache_key`, process-local Chat `response_id` continuation, subagent headers, and remote compaction
53
53
  - **Codex config aware** — reads `CODEX_HOME` / `~/.codex/config.toml` for model, reasoning-effort, and context-window settings
54
54
  - **Token estimate & compaction helpers** — Anthropic-compatible `/v1/messages/count_tokens` and `/v1/messages/compact`
55
55
  - **Auto auth** — reads `~/.codex/auth.json` and auto-refreshes OAuth tokens
@@ -310,13 +310,36 @@ curl -N http://localhost:18080/v1/messages \
310
310
  }'
311
311
  ```
312
312
 
313
+ ### Identifier and cache behavior
314
+
315
+ The two compatibility facades intentionally use the similarly named fields for
316
+ different jobs:
317
+
318
+ | Input or output | `/v1/chat/completions` | `/v1/messages` |
319
+ |---|---|---|
320
+ | `client_metadata.session_id` | Forwarded to Codex, used as the default `prompt_cache_key`, and required when Codex metadata mode is enabled | Not used or forwarded |
321
+ | `client_metadata.thread_id` | Codex metadata thread identity; defaults to `session_id` for a root request and preserves an explicit child thread | Not used or synthesized |
322
+ | `prompt_cache_key` | Explicit value wins; otherwise a non-empty `client_metadata.session_id` is used; otherwise omitted | Optional proxy extension; explicit value wins, otherwise the Claude Code session header is hashed |
323
+ | `x-claude-code-session-id` | Not used | SHA-256 of `codex-as-api:claude-code-session:<exact header value>` supplies cache affinity only |
324
+ | `previous_response_id` | Resolves a known entry from the 256-chain process-local history and replays full input/output over HTTP; never forwarded | Non-null values return Anthropic-style HTTP 400 because normal Messages is stateless |
325
+ | `cache_control` | Not an OpenAI Chat control | Validated as an Anthropic compatibility hint, then stripped before Codex transport |
326
+
327
+ Anthropic `cache_control` is a cache-boundary annotation, not an identifier.
328
+ This proxy accepts only `{"type":"ephemeral"}` with optional `ttl: "5m"` or
329
+ `"1h"` at request, system, message, content-block, and tool locations. The
330
+ private Codex request has no Anthropic breakpoint or TTL fields, so accepted
331
+ hints do not reproduce Anthropic caching semantics. Normal `/v1/messages`
332
+ responses do not expose the Chat-only `response_id`. The custom
333
+ `/v1/messages/compact` endpoint retains its documented local
334
+ `previous_response_id` support.
335
+
313
336
  ### `POST /v1/messages/count_tokens`
314
337
 
315
338
  Anthropic-compatible token counting helper. Codex OAuth does not expose a count-only endpoint equivalent to Anthropic's native API, so this route counts text locally and returns context-window metadata for the effective backend model. Normalized model-visible messages, tool calls, tool-result metadata, reasoning, and tool schemas are counted once. Request-envelope and generation-control fields are excluded, and image inputs use a separate fixed estimate so inline base64 data is not counted as text.
316
339
 
317
340
  GPT-5-family text uses a bundled port of official `tiktoken` `o200k_base` `encode_ordinary`: the same Unicode pre-tokenization regex, byte-pair merge algorithm, and merge-rank data are implemented in Python, TypeScript, and Rust. Official `tiktoken` maps `gpt-5` and its `gpt-5-`-prefixed variants to [`o200k_base`](https://github.com/openai/tiktoken/blob/08a5f3b2c987ada4fc5aa1f16c643c203fa8acaa/tiktoken/model.py#L7-L20); this project applies that GPT-5-family encoding to the bundled `gpt-5.*` Codex model IDs. The project does not depend on a `tiktoken` package or download encoding data at runtime. The last upstream synchronization check was **2026-07-14**, against [`tiktoken` 0.13.0 at `08a5f3b`](https://github.com/openai/tiktoken/tree/08a5f3b2c987ada4fc5aa1f16c643c203fa8acaa); the bundled rank file SHA-256 is `446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d`.
318
341
 
319
- Official Codex also has a [`ceil(UTF-8 bytes / 4)` truncation helper](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/utils/string/src/truncate.rs#L4-L84), but Codex documents the history estimate using that helper as [a coarse lower bound rather than a tokenizer-accurate count](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/core/src/context_manager/history.rs#L160-L185). This endpoint therefore uses exact `o200k_base` ordinary text tokenization instead. The complete request count remains an estimate because protocol-wrapper overhead and image cost are local constants, but the former byte-as-token and raw-payload double count that could overstate ordinary Claude Code requests by about 8x is removed.
342
+ Official Codex also has a [`ceil(UTF-8 bytes / 4)` truncation helper](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/utils/string/src/truncate.rs#L4-L84), but Codex documents the history estimate using that helper as [a coarse lower bound rather than a tokenizer-accurate count](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/core/src/context_manager/history.rs#L162-L186). This endpoint therefore uses exact `o200k_base` ordinary text tokenization instead. The complete request count remains an estimate because protocol-wrapper overhead and image cost are local constants, but the former byte-as-token and raw-payload double count that could overstate ordinary Claude Code requests by about 8x is removed.
320
343
 
321
344
  ```bash
322
345
  curl http://localhost:18080/v1/messages/count_tokens \
@@ -399,7 +422,7 @@ These features are extensions beyond the standard OpenAI API, designed for Codex
399
422
 
400
423
  `prompt_cache_key` keeps related prefixes in the same backend cache family. Use one stable, privacy-safe key per conversation or application prefix.
401
424
 
402
- Official Codex [defaults this field to its own per-conversation `thread_id`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/core/src/client.rs#L469-L473). This multi-client facade cannot infer that boundary safely and does not reuse its process-level metadata thread ID as a cache key, which could mix unrelated callers. Set `prompt_cache_key` explicitly when stable cache affinity is required.
425
+ Official Codex 0.145.0 and current `main` [default this field to Responses metadata `session_id`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/core/src/client.rs#L483-L486), unless an explicit override is present. This proxy follows the same precedence on Chat requests: explicit `prompt_cache_key`, then a non-empty `client_metadata.session_id`, then omission. It does not generate a process-wide session or reuse `thread_id` as the cache key.
403
426
 
404
427
  The private Codex OAuth HTTP and WebSocket request structures do not contain public GPT-5.6 `prompt_cache_options` or content-block `prompt_cache_breakpoint` fields. This proxy treats explicit `null` as omitted and rejects non-null controls with HTTP 400 instead of forwarding a request that the private route rejects or silently pretending the requested cache policy was applied. Use a public Responses API client when explicit cache policy or breakpoints are required. See OpenAI's [public prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints).
405
428
 
@@ -454,7 +477,7 @@ The mapping is intentionally transport-aware:
454
477
  |---|---|
455
478
  | `reasoning.mode: "standard"` | Omit mode; send the resolved effort/context |
456
479
  | `reasoning.mode: "pro"` | HTTP 400; no official Codex alias |
457
- | `prompt_cache_key` | Forward unchanged |
480
+ | `prompt_cache_key` | Forward an explicit value; otherwise use non-empty `client_metadata.session_id` |
458
481
  | Non-null `prompt_cache_options` / breakpoint | HTTP 400; `null` is omitted and there is no private field |
459
482
  | Non-null `safety_identifier` | HTTP 400; `null` is omitted and OAuth account/thread IDs are not semantic aliases |
460
483
  | Non-empty `stop` / `stop_sequences` | HTTP 400; no private field |
@@ -462,7 +485,7 @@ The mapping is intentionally transport-aware:
462
485
  | `service_tier: "fast"` | Send `service_tier: "priority"` |
463
486
  | `service_tier: "default"` | Omit the field |
464
487
 
465
- Authenticated tests on July 10, 2026 verified both official continuation paths with `gpt-5.6-sol`: a direct private Responses WebSocket completed a second delta request using the exact prior `response.id`, and the private HTTP endpoint completed the same continuation when the full prior input/output history was replayed. The proxy implements the HTTP replay strategy. Direct HTTP forwarding of `previous_response_id`, Pro, public cache controls, and `safety_identifier` was rejected and is not used. See OpenAI's [reasoning mode documentation](https://developers.openai.com/api/docs/guides/reasoning#reasoning-mode) and the official Codex [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/common.rs#L215-L293).
488
+ Authenticated tests on July 10, 2026 verified both official continuation paths with `gpt-5.6-sol`: a direct private Responses WebSocket completed a second delta request using the exact prior `response.id`, and the private HTTP endpoint completed the same continuation when the full prior input/output history was replayed. The proxy implements the HTTP replay strategy. Direct HTTP forwarding of `previous_response_id`, Pro, public cache controls, and `safety_identifier` was rejected and is not used. See OpenAI's [reasoning mode documentation](https://developers.openai.com/api/docs/guides/reasoning#reasoning-mode) and the official Codex [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/common.rs#L251-L307).
466
489
 
467
490
  ### `responses_lite`
468
491
 
@@ -508,15 +531,15 @@ Set `parallel_tool_calls: true` to request parallel tool calls when the selected
508
531
 
509
532
  ### `client_metadata` and `codex_metadata`
510
533
 
511
- `client_metadata` is forwarded to the Codex backend. Set `codex_metadata: true` or `CODEX_AS_API_CODEX_METADATA=on` to overlay Codex-style turn metadata keys such as `turn_id`, `session_id`, `thread_id`, and `x-codex-turn-metadata`.
534
+ `client_metadata` is forwarded to the Codex backend. Set `codex_metadata: true` or `CODEX_AS_API_CODEX_METADATA=on` to add Codex-style turn metadata. Metadata mode requires a non-empty caller-supplied `client_metadata.session_id`; it preserves that session, defaults a missing root `thread_id` to the session, and preserves an explicit child `thread_id`.
512
535
 
513
- Do not rely on user-supplied values for those reserved Codex metadata keys when metadata mode is enabled; this package regenerates them per turn. Metadata `thread_id` is not a `previous_response_id` alias and is not automatically reused as `prompt_cache_key` by this multi-client proxy.
536
+ The installation ID and process window ID remain stable, while `turn_id` and `x-codex-turn-metadata` are regenerated for each request. Metadata `thread_id` is neither a `previous_response_id` alias nor a cache key. An explicit `prompt_cache_key` wins; otherwise the non-empty session ID supplies cache affinity.
514
537
 
515
538
  ### `previous_response_id`
516
539
 
517
540
  Non-streaming responses and the final streaming finish chunk expose the real upstream Responses ID as `response_id`. The provider keeps up to 256 completed chains in a process-local LRU store. Passing a known ID as `previous_response_id` prepends the saved semantic input and exact prior `response.output_item.done` items—including encrypted reasoning and tool items—to the new input, then sends one full private HTTP request. The ID itself is never forwarded and is never converted to `thread_id`.
518
541
 
519
- That event source is intentional: the official Codex SSE parser [takes semantic items from `response.output_item.done`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/sse/responses.rs#L326-L337), while its [`response.completed` shape contains the response ID, usage, and `end_turn`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/sse/responses.rs#L112-L120), not replay history. The authenticated private HTTP rollout likewise returned an empty `response.completed.output`; the proxy therefore commits the completed output-item events instead of treating that extra field as conversation state.
542
+ That event source is intentional: the official Codex SSE parser [takes semantic items from `response.output_item.done`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/sse/responses.rs#L327-L337), while its [`response.completed` shape contains the response ID, usage, and `end_turn`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/sse/responses.rs#L112-L120), not replay history. The authenticated private HTTP rollout likewise returned an empty `response.completed.output`; the proxy therefore commits the completed output-item events instead of treating that extra field as conversation state.
520
543
 
521
544
  Only a real `response.completed` event commits a chain. Branches from an older retained ID are supported. Restarting the server or evicting an old entry removes that local state; an unknown ID returns HTTP 400 before any upstream request.
522
545
 
@@ -642,7 +665,7 @@ curl -N http://localhost:18080/v1/chat/completions \
642
665
 
643
666
  ## Using with Claude Code
644
667
 
645
- The `/v1/messages` endpoint implements the Anthropic Messages gateway shape used by Claude Code. Compatibility was validated with Claude Code `2.1.209`, including streaming, real Codex OAuth chat, request-level GPT model routing, adaptive thinking, `--effort max`, and Fast Mode.
668
+ The `/v1/messages` endpoint implements the Anthropic Messages gateway shape used by Claude Code. The current request shape was reproduced with Claude Code `2.1.220`; streaming, real Codex OAuth chat, request-level GPT model routing, adaptive thinking, `--effort max`, and Fast Mode remain covered.
646
669
 
647
670
  Start the proxy first. `CODEX_AS_API_MODEL` is the fallback used when Claude Code sends a built-in Anthropic model name:
648
671
 
@@ -666,7 +689,14 @@ CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
666
689
  claude
667
690
  ```
668
691
 
669
- Run `/model` and select **GPT-5.6 Sol**. The custom entry is appended to the built-in rows rather than replacing them. Claude Code `2.1.209` recognizes this GPT model ID without a custom capability declaration: `/effort low|medium|high|xhigh|max`, the picker effort control, and `claude --effort ...` are translated from Claude Code's `output_config.effort` to the Codex reasoning effort. Claude Code Fast Mode's `speed: "fast"` is translated to the Codex `priority` service tier.
692
+ Run `/model` and select **GPT-5.6 Sol**. The custom entry is appended to the built-in rows rather than replacing them. Claude Code `2.1.220` recognizes this GPT model ID without a custom capability declaration: `/effort low|medium|high|xhigh|max`, the picker effort control, and `claude --effort ...` are translated from Claude Code's `output_config.effort` to the Codex reasoning effort. Claude Code Fast Mode's `speed: "fast"` is translated to the Codex `priority` service tier.
693
+
694
+ Claude Code sends `x-claude-code-session-id` for gateway request grouping. This
695
+ proxy hashes the exact header value into a stable `prompt_cache_key`; it does
696
+ not turn the header into Codex `session_id` or `thread_id` metadata. A
697
+ top-level `prompt_cache_key` is supported as a proxy extension and takes
698
+ precedence. Normal Messages requests remain stateless, so callers must send
699
+ full message history.
670
700
 
671
701
  If managed settings define an `availableModels` allowlist, that list must include the exact `ANTHROPIC_CUSTOM_MODEL_OPTION` value, such as `gpt-5.6-sol`; otherwise Claude Code hides or rejects the custom row.
672
702
 
@@ -724,7 +754,7 @@ The provider handles:
724
754
  ## Release & package publishing
725
755
 
726
756
  - Bump versions in `pyproject.toml`, `src/codex_as_api/__init__.py`, `src/codex_as_api/server.py`, `ts/package.json`, `ts/package-lock.json`, `rust/Cargo.toml`, and `rust/Cargo.lock`.
727
- - Publish a GitHub Release such as `v0.6.3` from the matching commit.
757
+ - Publish a GitHub Release such as `v0.6.4` from the matching commit.
728
758
  - The manually-dispatched `Publish npm packages` workflow builds/tests the TypeScript package, runs `npm pack --dry-run`, publishes `codex-as-api` to npmjs when `NPM_TOKEN` is configured, and publishes `@eunho-j/codex-as-api` to GitHub Packages with `GITHUB_TOKEN`.
729
759
 
730
760
  Publishing to npmjs requires an authenticated npm session (`npm login` beforehand; `npm whoami` should succeed). From the repository root, the publish itself is one command:
@@ -767,6 +797,19 @@ npm test
767
797
 
768
798
  ## Release Notes
769
799
 
800
+ ### v0.6.4
801
+
802
+ - Remove process-global generated Codex session/thread identities and require
803
+ explicit session identity when Codex metadata mode is enabled.
804
+ - Resolve Chat cache affinity as explicit `prompt_cache_key`, then
805
+ `client_metadata.session_id`, then omission.
806
+ - Derive Claude Code cache affinity from `x-claude-code-session-id` without
807
+ fabricating Codex metadata; keep normal Anthropic Messages stateless.
808
+ - Validate and strip Anthropic `cache_control` compatibility hints without
809
+ claiming unavailable breakpoint or TTL semantics.
810
+ - Update current compatibility evidence to Codex `0.145.0`, Codex `main` at
811
+ `bd2de422aa287b97b06ca6425a10935bcf1b3731`, and Claude Code `2.1.220`.
812
+
770
813
  ### v0.6.3
771
814
 
772
815
  - Accept Claude Code auxiliary requests that combine ambient `output_config.effort` with call-level `thinking.disabled`.
@@ -15,7 +15,7 @@ Use ChatGPT / Codex OAuth as a local OpenAI-compatible API server.
15
15
  - **Tool calling** — function calls, tool results, and parallel tool calls
16
16
  - **Image support** — generation, inspection, multimodal Chat input, and capability-gated `original` image detail in classic Responses
17
17
  - **Reasoning** — configurable effort/context, `standard` mode compatibility, persisted reasoning, and streaming thinking content
18
- - **Codex features** — `prompt_cache_key`, process-local `response_id` continuation, subagent headers, and remote compaction
18
+ - **Codex features** — session-aware `prompt_cache_key`, process-local Chat `response_id` continuation, subagent headers, and remote compaction
19
19
  - **Codex config aware** — reads `CODEX_HOME` / `~/.codex/config.toml` for model, reasoning-effort, and context-window settings
20
20
  - **Token estimate & compaction helpers** — Anthropic-compatible `/v1/messages/count_tokens` and `/v1/messages/compact`
21
21
  - **Auto auth** — reads `~/.codex/auth.json` and auto-refreshes OAuth tokens
@@ -276,13 +276,36 @@ curl -N http://localhost:18080/v1/messages \
276
276
  }'
277
277
  ```
278
278
 
279
+ ### Identifier and cache behavior
280
+
281
+ The two compatibility facades intentionally use the similarly named fields for
282
+ different jobs:
283
+
284
+ | Input or output | `/v1/chat/completions` | `/v1/messages` |
285
+ |---|---|---|
286
+ | `client_metadata.session_id` | Forwarded to Codex, used as the default `prompt_cache_key`, and required when Codex metadata mode is enabled | Not used or forwarded |
287
+ | `client_metadata.thread_id` | Codex metadata thread identity; defaults to `session_id` for a root request and preserves an explicit child thread | Not used or synthesized |
288
+ | `prompt_cache_key` | Explicit value wins; otherwise a non-empty `client_metadata.session_id` is used; otherwise omitted | Optional proxy extension; explicit value wins, otherwise the Claude Code session header is hashed |
289
+ | `x-claude-code-session-id` | Not used | SHA-256 of `codex-as-api:claude-code-session:<exact header value>` supplies cache affinity only |
290
+ | `previous_response_id` | Resolves a known entry from the 256-chain process-local history and replays full input/output over HTTP; never forwarded | Non-null values return Anthropic-style HTTP 400 because normal Messages is stateless |
291
+ | `cache_control` | Not an OpenAI Chat control | Validated as an Anthropic compatibility hint, then stripped before Codex transport |
292
+
293
+ Anthropic `cache_control` is a cache-boundary annotation, not an identifier.
294
+ This proxy accepts only `{"type":"ephemeral"}` with optional `ttl: "5m"` or
295
+ `"1h"` at request, system, message, content-block, and tool locations. The
296
+ private Codex request has no Anthropic breakpoint or TTL fields, so accepted
297
+ hints do not reproduce Anthropic caching semantics. Normal `/v1/messages`
298
+ responses do not expose the Chat-only `response_id`. The custom
299
+ `/v1/messages/compact` endpoint retains its documented local
300
+ `previous_response_id` support.
301
+
279
302
  ### `POST /v1/messages/count_tokens`
280
303
 
281
304
  Anthropic-compatible token counting helper. Codex OAuth does not expose a count-only endpoint equivalent to Anthropic's native API, so this route counts text locally and returns context-window metadata for the effective backend model. Normalized model-visible messages, tool calls, tool-result metadata, reasoning, and tool schemas are counted once. Request-envelope and generation-control fields are excluded, and image inputs use a separate fixed estimate so inline base64 data is not counted as text.
282
305
 
283
306
  GPT-5-family text uses a bundled port of official `tiktoken` `o200k_base` `encode_ordinary`: the same Unicode pre-tokenization regex, byte-pair merge algorithm, and merge-rank data are implemented in Python, TypeScript, and Rust. Official `tiktoken` maps `gpt-5` and its `gpt-5-`-prefixed variants to [`o200k_base`](https://github.com/openai/tiktoken/blob/08a5f3b2c987ada4fc5aa1f16c643c203fa8acaa/tiktoken/model.py#L7-L20); this project applies that GPT-5-family encoding to the bundled `gpt-5.*` Codex model IDs. The project does not depend on a `tiktoken` package or download encoding data at runtime. The last upstream synchronization check was **2026-07-14**, against [`tiktoken` 0.13.0 at `08a5f3b`](https://github.com/openai/tiktoken/tree/08a5f3b2c987ada4fc5aa1f16c643c203fa8acaa); the bundled rank file SHA-256 is `446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d`.
284
307
 
285
- Official Codex also has a [`ceil(UTF-8 bytes / 4)` truncation helper](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/utils/string/src/truncate.rs#L4-L84), but Codex documents the history estimate using that helper as [a coarse lower bound rather than a tokenizer-accurate count](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/core/src/context_manager/history.rs#L160-L185). This endpoint therefore uses exact `o200k_base` ordinary text tokenization instead. The complete request count remains an estimate because protocol-wrapper overhead and image cost are local constants, but the former byte-as-token and raw-payload double count that could overstate ordinary Claude Code requests by about 8x is removed.
308
+ Official Codex also has a [`ceil(UTF-8 bytes / 4)` truncation helper](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/utils/string/src/truncate.rs#L4-L84), but Codex documents the history estimate using that helper as [a coarse lower bound rather than a tokenizer-accurate count](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/core/src/context_manager/history.rs#L162-L186). This endpoint therefore uses exact `o200k_base` ordinary text tokenization instead. The complete request count remains an estimate because protocol-wrapper overhead and image cost are local constants, but the former byte-as-token and raw-payload double count that could overstate ordinary Claude Code requests by about 8x is removed.
286
309
 
287
310
  ```bash
288
311
  curl http://localhost:18080/v1/messages/count_tokens \
@@ -365,7 +388,7 @@ These features are extensions beyond the standard OpenAI API, designed for Codex
365
388
 
366
389
  `prompt_cache_key` keeps related prefixes in the same backend cache family. Use one stable, privacy-safe key per conversation or application prefix.
367
390
 
368
- Official Codex [defaults this field to its own per-conversation `thread_id`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/core/src/client.rs#L469-L473). This multi-client facade cannot infer that boundary safely and does not reuse its process-level metadata thread ID as a cache key, which could mix unrelated callers. Set `prompt_cache_key` explicitly when stable cache affinity is required.
391
+ Official Codex 0.145.0 and current `main` [default this field to Responses metadata `session_id`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/core/src/client.rs#L483-L486), unless an explicit override is present. This proxy follows the same precedence on Chat requests: explicit `prompt_cache_key`, then a non-empty `client_metadata.session_id`, then omission. It does not generate a process-wide session or reuse `thread_id` as the cache key.
369
392
 
370
393
  The private Codex OAuth HTTP and WebSocket request structures do not contain public GPT-5.6 `prompt_cache_options` or content-block `prompt_cache_breakpoint` fields. This proxy treats explicit `null` as omitted and rejects non-null controls with HTTP 400 instead of forwarding a request that the private route rejects or silently pretending the requested cache policy was applied. Use a public Responses API client when explicit cache policy or breakpoints are required. See OpenAI's [public prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching#prompt-cache-breakpoints).
371
394
 
@@ -420,7 +443,7 @@ The mapping is intentionally transport-aware:
420
443
  |---|---|
421
444
  | `reasoning.mode: "standard"` | Omit mode; send the resolved effort/context |
422
445
  | `reasoning.mode: "pro"` | HTTP 400; no official Codex alias |
423
- | `prompt_cache_key` | Forward unchanged |
446
+ | `prompt_cache_key` | Forward an explicit value; otherwise use non-empty `client_metadata.session_id` |
424
447
  | Non-null `prompt_cache_options` / breakpoint | HTTP 400; `null` is omitted and there is no private field |
425
448
  | Non-null `safety_identifier` | HTTP 400; `null` is omitted and OAuth account/thread IDs are not semantic aliases |
426
449
  | Non-empty `stop` / `stop_sequences` | HTTP 400; no private field |
@@ -428,7 +451,7 @@ The mapping is intentionally transport-aware:
428
451
  | `service_tier: "fast"` | Send `service_tier: "priority"` |
429
452
  | `service_tier: "default"` | Omit the field |
430
453
 
431
- Authenticated tests on July 10, 2026 verified both official continuation paths with `gpt-5.6-sol`: a direct private Responses WebSocket completed a second delta request using the exact prior `response.id`, and the private HTTP endpoint completed the same continuation when the full prior input/output history was replayed. The proxy implements the HTTP replay strategy. Direct HTTP forwarding of `previous_response_id`, Pro, public cache controls, and `safety_identifier` was rejected and is not used. See OpenAI's [reasoning mode documentation](https://developers.openai.com/api/docs/guides/reasoning#reasoning-mode) and the official Codex [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/common.rs#L215-L293).
454
+ Authenticated tests on July 10, 2026 verified both official continuation paths with `gpt-5.6-sol`: a direct private Responses WebSocket completed a second delta request using the exact prior `response.id`, and the private HTTP endpoint completed the same continuation when the full prior input/output history was replayed. The proxy implements the HTTP replay strategy. Direct HTTP forwarding of `previous_response_id`, Pro, public cache controls, and `safety_identifier` was rejected and is not used. See OpenAI's [reasoning mode documentation](https://developers.openai.com/api/docs/guides/reasoning#reasoning-mode) and the official Codex [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/common.rs#L251-L307).
432
455
 
433
456
  ### `responses_lite`
434
457
 
@@ -474,15 +497,15 @@ Set `parallel_tool_calls: true` to request parallel tool calls when the selected
474
497
 
475
498
  ### `client_metadata` and `codex_metadata`
476
499
 
477
- `client_metadata` is forwarded to the Codex backend. Set `codex_metadata: true` or `CODEX_AS_API_CODEX_METADATA=on` to overlay Codex-style turn metadata keys such as `turn_id`, `session_id`, `thread_id`, and `x-codex-turn-metadata`.
500
+ `client_metadata` is forwarded to the Codex backend. Set `codex_metadata: true` or `CODEX_AS_API_CODEX_METADATA=on` to add Codex-style turn metadata. Metadata mode requires a non-empty caller-supplied `client_metadata.session_id`; it preserves that session, defaults a missing root `thread_id` to the session, and preserves an explicit child `thread_id`.
478
501
 
479
- Do not rely on user-supplied values for those reserved Codex metadata keys when metadata mode is enabled; this package regenerates them per turn. Metadata `thread_id` is not a `previous_response_id` alias and is not automatically reused as `prompt_cache_key` by this multi-client proxy.
502
+ The installation ID and process window ID remain stable, while `turn_id` and `x-codex-turn-metadata` are regenerated for each request. Metadata `thread_id` is neither a `previous_response_id` alias nor a cache key. An explicit `prompt_cache_key` wins; otherwise the non-empty session ID supplies cache affinity.
480
503
 
481
504
  ### `previous_response_id`
482
505
 
483
506
  Non-streaming responses and the final streaming finish chunk expose the real upstream Responses ID as `response_id`. The provider keeps up to 256 completed chains in a process-local LRU store. Passing a known ID as `previous_response_id` prepends the saved semantic input and exact prior `response.output_item.done` items—including encrypted reasoning and tool items—to the new input, then sends one full private HTTP request. The ID itself is never forwarded and is never converted to `thread_id`.
484
507
 
485
- That event source is intentional: the official Codex SSE parser [takes semantic items from `response.output_item.done`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/sse/responses.rs#L326-L337), while its [`response.completed` shape contains the response ID, usage, and `end_turn`](https://github.com/openai/codex/blob/393f64565ab46f09d99ca4d9bd973537e72a114b/codex-rs/codex-api/src/sse/responses.rs#L112-L120), not replay history. The authenticated private HTTP rollout likewise returned an empty `response.completed.output`; the proxy therefore commits the completed output-item events instead of treating that extra field as conversation state.
508
+ That event source is intentional: the official Codex SSE parser [takes semantic items from `response.output_item.done`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/sse/responses.rs#L327-L337), while its [`response.completed` shape contains the response ID, usage, and `end_turn`](https://github.com/openai/codex/blob/bd2de422aa287b97b06ca6425a10935bcf1b3731/codex-rs/codex-api/src/sse/responses.rs#L112-L120), not replay history. The authenticated private HTTP rollout likewise returned an empty `response.completed.output`; the proxy therefore commits the completed output-item events instead of treating that extra field as conversation state.
486
509
 
487
510
  Only a real `response.completed` event commits a chain. Branches from an older retained ID are supported. Restarting the server or evicting an old entry removes that local state; an unknown ID returns HTTP 400 before any upstream request.
488
511
 
@@ -608,7 +631,7 @@ curl -N http://localhost:18080/v1/chat/completions \
608
631
 
609
632
  ## Using with Claude Code
610
633
 
611
- The `/v1/messages` endpoint implements the Anthropic Messages gateway shape used by Claude Code. Compatibility was validated with Claude Code `2.1.209`, including streaming, real Codex OAuth chat, request-level GPT model routing, adaptive thinking, `--effort max`, and Fast Mode.
634
+ The `/v1/messages` endpoint implements the Anthropic Messages gateway shape used by Claude Code. The current request shape was reproduced with Claude Code `2.1.220`; streaming, real Codex OAuth chat, request-level GPT model routing, adaptive thinking, `--effort max`, and Fast Mode remain covered.
612
635
 
613
636
  Start the proxy first. `CODEX_AS_API_MODEL` is the fallback used when Claude Code sends a built-in Anthropic model name:
614
637
 
@@ -632,7 +655,14 @@ CLAUDE_CODE_ATTRIBUTION_HEADER=0 \
632
655
  claude
633
656
  ```
634
657
 
635
- Run `/model` and select **GPT-5.6 Sol**. The custom entry is appended to the built-in rows rather than replacing them. Claude Code `2.1.209` recognizes this GPT model ID without a custom capability declaration: `/effort low|medium|high|xhigh|max`, the picker effort control, and `claude --effort ...` are translated from Claude Code's `output_config.effort` to the Codex reasoning effort. Claude Code Fast Mode's `speed: "fast"` is translated to the Codex `priority` service tier.
658
+ Run `/model` and select **GPT-5.6 Sol**. The custom entry is appended to the built-in rows rather than replacing them. Claude Code `2.1.220` recognizes this GPT model ID without a custom capability declaration: `/effort low|medium|high|xhigh|max`, the picker effort control, and `claude --effort ...` are translated from Claude Code's `output_config.effort` to the Codex reasoning effort. Claude Code Fast Mode's `speed: "fast"` is translated to the Codex `priority` service tier.
659
+
660
+ Claude Code sends `x-claude-code-session-id` for gateway request grouping. This
661
+ proxy hashes the exact header value into a stable `prompt_cache_key`; it does
662
+ not turn the header into Codex `session_id` or `thread_id` metadata. A
663
+ top-level `prompt_cache_key` is supported as a proxy extension and takes
664
+ precedence. Normal Messages requests remain stateless, so callers must send
665
+ full message history.
636
666
 
637
667
  If managed settings define an `availableModels` allowlist, that list must include the exact `ANTHROPIC_CUSTOM_MODEL_OPTION` value, such as `gpt-5.6-sol`; otherwise Claude Code hides or rejects the custom row.
638
668
 
@@ -690,7 +720,7 @@ The provider handles:
690
720
  ## Release & package publishing
691
721
 
692
722
  - Bump versions in `pyproject.toml`, `src/codex_as_api/__init__.py`, `src/codex_as_api/server.py`, `ts/package.json`, `ts/package-lock.json`, `rust/Cargo.toml`, and `rust/Cargo.lock`.
693
- - Publish a GitHub Release such as `v0.6.3` from the matching commit.
723
+ - Publish a GitHub Release such as `v0.6.4` from the matching commit.
694
724
  - The manually-dispatched `Publish npm packages` workflow builds/tests the TypeScript package, runs `npm pack --dry-run`, publishes `codex-as-api` to npmjs when `NPM_TOKEN` is configured, and publishes `@eunho-j/codex-as-api` to GitHub Packages with `GITHUB_TOKEN`.
695
725
 
696
726
  Publishing to npmjs requires an authenticated npm session (`npm login` beforehand; `npm whoami` should succeed). From the repository root, the publish itself is one command:
@@ -733,6 +763,19 @@ npm test
733
763
 
734
764
  ## Release Notes
735
765
 
766
+ ### v0.6.4
767
+
768
+ - Remove process-global generated Codex session/thread identities and require
769
+ explicit session identity when Codex metadata mode is enabled.
770
+ - Resolve Chat cache affinity as explicit `prompt_cache_key`, then
771
+ `client_metadata.session_id`, then omission.
772
+ - Derive Claude Code cache affinity from `x-claude-code-session-id` without
773
+ fabricating Codex metadata; keep normal Anthropic Messages stateless.
774
+ - Validate and strip Anthropic `cache_control` compatibility hints without
775
+ claiming unavailable breakpoint or TTL semantics.
776
+ - Update current compatibility evidence to Codex `0.145.0`, Codex `main` at
777
+ `bd2de422aa287b97b06ca6425a10935bcf1b3731`, and Claude Code `2.1.220`.
778
+
736
779
  ### v0.6.3
737
780
 
738
781
  - Accept Claude Code auxiliary requests that combine ambient `output_config.effort` with call-level `thinking.disabled`.
@@ -1,5 +1,43 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.6.4
4
+
5
+ ### Codex identity and cache affinity
6
+
7
+ - Remove process-global generated `session_id` and `thread_id` values from
8
+ Python, TypeScript, and Rust. Codex metadata mode now requires an explicit
9
+ non-empty `client_metadata.session_id`, defaults a root `thread_id` to that
10
+ session, preserves an explicit child thread, and refreshes only turn-scoped
11
+ identity on each request.
12
+ - Resolve Chat `prompt_cache_key` as explicit value, then non-empty
13
+ `client_metadata.session_id`, then omission. This matches Codex 0.145.0 and
14
+ current `main`, where the official default is Responses metadata
15
+ `session_id`, not `thread_id`.
16
+ - Retain bounded process-local Chat `previous_response_id` history replay over
17
+ private HTTP. The ID remains local and is never forwarded upstream.
18
+
19
+ ### Claude Code session and cache compatibility
20
+
21
+ - Read `x-claude-code-session-id` on `/v1/messages` and derive a stable,
22
+ privacy-safe SHA-256 `prompt_cache_key`; an explicit proxy extension
23
+ `prompt_cache_key` takes precedence.
24
+ - Keep normal Anthropic Messages stateless: reject non-null
25
+ `previous_response_id`, disable ambient Codex metadata, avoid fabricated
26
+ Claude thread IDs, and remove Python's nonstandard Anthropic `response_id`.
27
+ - Validate Anthropic `cache_control` hints at request, system, message, content,
28
+ and tool locations. Accept only `type: "ephemeral"` with optional TTL `5m` or
29
+ `1h`, then strip the hint because the private Codex transport cannot preserve
30
+ Anthropic cache breakpoints or TTL behavior.
31
+
32
+ ### Validation
33
+
34
+ - Python: 515 tests, mypy, and changed-file Ruff checks.
35
+ - TypeScript: 261 tests and production build.
36
+ - Rust: 253 tests and rustfmt.
37
+ - Re-audited `@openai/codex` 0.145.0 plus `main` at
38
+ `bd2de422aa287b97b06ca6425a10935bcf1b3731`, and reproduced the Claude Code
39
+ 2.1.220 request shape.
40
+
3
41
  ## v0.6.3
4
42
 
5
43
  ### Claude Code WebSearch/WebFetch auxiliary calls
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-as-api"
7
- version = "0.6.3"
7
+ version = "0.6.4"
8
8
  description = "Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -1,3 +1,3 @@
1
1
  """codex-as-api: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server."""
2
2
 
3
- __version__ = "0.6.3"
3
+ __version__ = "0.6.4"
@@ -23,8 +23,6 @@ _PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[2]
23
23
  _CAPABILITIES_PATH = _PROJECT_ROOT / "config" / "model-capabilities.json"
24
24
  _PACKAGE_CAPABILITIES_PATH = pathlib.Path(__file__).resolve().with_name("model-capabilities.json")
25
25
  _INSTALLATION_NAMESPACE = uuid.UUID("d2c81270-8f15-5e8d-a5c4-4cdbf2c21fd0")
26
- _SESSION_ID = str(uuid.uuid4())
27
- _THREAD_ID = str(uuid.uuid4())
28
26
  _WINDOW_ID = str(uuid.uuid4())
29
27
 
30
28
  ResponsesLiteMode = Literal["off", "on", "auto"]
@@ -157,14 +155,23 @@ def build_codex_client_metadata(
157
155
  existing: Mapping[str, str] | None,
158
156
  ) -> dict[str, str]:
159
157
  metadata = dict(existing or {})
158
+ session_id = metadata.get(SESSION_ID_KEY)
159
+ if not isinstance(session_id, str) or not session_id.strip():
160
+ raise ValueError("codex_metadata requires a non-empty client_metadata.session_id")
161
+ thread_id = metadata.get(THREAD_ID_KEY)
162
+ if thread_id is None:
163
+ thread_id = session_id
164
+ elif not isinstance(thread_id, str) or not thread_id.strip():
165
+ raise ValueError("client_metadata.thread_id must be a non-empty string when provided")
166
+
160
167
  auth_path = pathlib.Path(auth_json_path or "~/.codex/auth.json").expanduser()
161
168
  absolute_path = os.path.abspath(os.fspath(auth_path))
162
169
  installation_id = str(uuid.uuid5(_INSTALLATION_NAMESPACE, f"codex-as-api:{absolute_path}"))
163
170
  turn_id = str(uuid.uuid4())
164
171
  turn_metadata = {
165
172
  "installation_id": installation_id,
166
- "session_id": _SESSION_ID,
167
- "thread_id": _THREAD_ID,
173
+ "session_id": session_id,
174
+ "thread_id": thread_id,
168
175
  "turn_id": turn_id,
169
176
  "window_id": _WINDOW_ID,
170
177
  "source": "codex-as-api",
@@ -172,8 +179,8 @@ def build_codex_client_metadata(
172
179
  metadata.update(
173
180
  {
174
181
  INSTALLATION_ID_KEY: installation_id,
175
- SESSION_ID_KEY: _SESSION_ID,
176
- THREAD_ID_KEY: _THREAD_ID,
182
+ SESSION_ID_KEY: session_id,
183
+ THREAD_ID_KEY: thread_id,
177
184
  TURN_ID_KEY: turn_id,
178
185
  WINDOW_ID_KEY: _WINDOW_ID,
179
186
  TURN_METADATA_KEY: json.dumps(turn_metadata, separators=(",", ":"), sort_keys=True),
@@ -27,6 +27,7 @@ from .model_capabilities import (
27
27
  LITE_HEADER_NAME,
28
28
  LITE_HEADER_VALUE,
29
29
  RESPONSES_LITE_ENV,
30
+ SESSION_ID_KEY,
30
31
  apply_model_capability_fields,
31
32
  build_codex_client_metadata,
32
33
  capability_for_model,
@@ -753,18 +754,27 @@ class ChatGPTOAuthProvider:
753
754
  }
754
755
  if any(tool.get("type") == "web_search" for tool in payload["tools"]):
755
756
  payload["include"] = ["web_search_call.action.sources"]
756
- if prompt_cache_key is not None:
757
+ metadata = dict(client_metadata) if client_metadata is not None else None
758
+ if resolve_codex_metadata_enabled(codex_metadata):
759
+ try:
760
+ metadata = build_codex_client_metadata(
761
+ auth_json_path=self.auth_json_path,
762
+ existing=metadata,
763
+ )
764
+ except ValueError as exc:
765
+ raise ChatGPTOAuthInvalidRequestError(str(exc)) from exc
766
+ effective_prompt_cache_key = prompt_cache_key
767
+ if effective_prompt_cache_key is None and metadata is not None:
768
+ session_id = metadata.get(SESSION_ID_KEY)
769
+ if isinstance(session_id, str) and session_id.strip():
770
+ effective_prompt_cache_key = session_id
771
+ if effective_prompt_cache_key is not None:
757
772
  payload["prompt_cache_key"] = _validate_non_empty_string(
758
- prompt_cache_key,
773
+ effective_prompt_cache_key,
759
774
  "prompt_cache_key",
760
775
  )
761
- if client_metadata is not None:
762
- payload["client_metadata"] = client_metadata
763
- if resolve_codex_metadata_enabled(codex_metadata):
764
- payload["client_metadata"] = build_codex_client_metadata(
765
- auth_json_path=self.auth_json_path,
766
- existing=payload.get("client_metadata") if isinstance(payload.get("client_metadata"), dict) else None,
767
- )
776
+ if metadata is not None:
777
+ payload["client_metadata"] = metadata
768
778
  _finalize_responses_payload(
769
779
  payload,
770
780
  model=request_model,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import os
5
6
  import time
@@ -39,6 +40,9 @@ CODEX_CONFIG = load_codex_config()
39
40
  MODEL = _env_str("CODEX_AS_API_MODEL", CODEX_CONFIG.model or "gpt-5.5")
40
41
  AUTH_PATH = os.getenv("CODEX_AS_API_AUTH_PATH")
41
42
  DEFAULT_CONTEXT_WINDOW = 200_000
43
+ CLAUDE_CODE_SESSION_HEADER = "x-claude-code-session-id"
44
+ _CLAUDE_CACHE_KEY_NAMESPACE = "codex-as-api:claude-code-session:"
45
+ _ANTHROPIC_CACHE_CONTROL_TTLS = frozenset({"5m", "1h"})
42
46
 
43
47
  _provider: ChatGPTOAuthProvider | None = None
44
48
 
@@ -109,6 +113,76 @@ def _anthropic_service_tier(body: dict[str, Any]) -> str | None:
109
113
  return speed_tier
110
114
 
111
115
 
116
+ def _validate_anthropic_cache_control(value: object, location: str) -> None:
117
+ if not isinstance(value, dict):
118
+ raise ChatGPTOAuthInvalidRequestError(f"{location}.cache_control must be an object")
119
+ unknown = sorted(set(value) - {"type", "ttl"})
120
+ if unknown:
121
+ raise ChatGPTOAuthInvalidRequestError(
122
+ f"{location}.cache_control contains unsupported fields: {', '.join(unknown)}"
123
+ )
124
+ if value.get("type") != "ephemeral":
125
+ raise ChatGPTOAuthInvalidRequestError(f"{location}.cache_control.type must be 'ephemeral'")
126
+ if "ttl" in value and (
127
+ not isinstance(value["ttl"], str) or value["ttl"] not in _ANTHROPIC_CACHE_CONTROL_TTLS
128
+ ):
129
+ raise ChatGPTOAuthInvalidRequestError(f"{location}.cache_control.ttl must be one of: 5m, 1h")
130
+
131
+
132
+ def _strip_anthropic_content_cache_controls(value: object, location: str) -> None:
133
+ if not isinstance(value, list):
134
+ return
135
+ for index, block in enumerate(value):
136
+ if not isinstance(block, dict):
137
+ continue
138
+ block_location = f"{location}[{index}]"
139
+ if "cache_control" in block:
140
+ _validate_anthropic_cache_control(block["cache_control"], block_location)
141
+ block.pop("cache_control")
142
+ _strip_anthropic_content_cache_controls(block.get("content"), f"{block_location}.content")
143
+
144
+
145
+ def _strip_anthropic_cache_controls(body: dict[str, Any]) -> None:
146
+ if "cache_control" in body:
147
+ _validate_anthropic_cache_control(body["cache_control"], "request")
148
+ body.pop("cache_control")
149
+
150
+ _strip_anthropic_content_cache_controls(body.get("system"), "system")
151
+ messages = body.get("messages")
152
+ if isinstance(messages, list):
153
+ for index, message in enumerate(messages):
154
+ if not isinstance(message, dict):
155
+ continue
156
+ location = f"messages[{index}]"
157
+ if "cache_control" in message:
158
+ _validate_anthropic_cache_control(message["cache_control"], location)
159
+ message.pop("cache_control")
160
+ _strip_anthropic_content_cache_controls(message.get("content"), f"{location}.content")
161
+
162
+ tools = body.get("tools")
163
+ if isinstance(tools, list):
164
+ for index, tool in enumerate(tools):
165
+ if not isinstance(tool, dict) or "cache_control" not in tool:
166
+ continue
167
+ location = f"tools[{index}]"
168
+ _validate_anthropic_cache_control(tool["cache_control"], location)
169
+ tool.pop("cache_control")
170
+
171
+
172
+ def _anthropic_prompt_cache_key(body: dict[str, Any], claude_session_id: str | None) -> str | None:
173
+ explicit = body.get("prompt_cache_key")
174
+ if explicit is not None:
175
+ if not isinstance(explicit, str) or not explicit.strip():
176
+ raise ChatGPTOAuthInvalidRequestError("prompt_cache_key must be a non-empty string")
177
+ return explicit
178
+ if claude_session_id is None:
179
+ return None
180
+ if not claude_session_id.strip():
181
+ raise ChatGPTOAuthInvalidRequestError(f"{CLAUDE_CODE_SESSION_HEADER} must be a non-empty string")
182
+ value = f"{_CLAUDE_CACHE_KEY_NAMESPACE}{claude_session_id}".encode()
183
+ return hashlib.sha256(value).hexdigest()
184
+
185
+
112
186
  def _merge_anthropic_text(
113
187
  converted: dict[str, Any] | None,
114
188
  direct: object,
@@ -140,7 +214,7 @@ try:
140
214
  app = FastAPI(
141
215
  title="codex-as-api",
142
216
  description="Local OpenAI-compatible API server backed by ChatGPT/Codex OAuth.",
143
- version="0.6.3",
217
+ version="0.6.4",
144
218
  )
145
219
 
146
220
  @app.exception_handler(ChatGPTOAuthError)
@@ -481,6 +555,7 @@ try:
481
555
  ) -> tuple[list[Message], list[ToolSchema] | None, str | None, dict[str, Any] | None]:
482
556
  messages_value = body.get("messages")
483
557
  if anthropic or any(key in body for key in ("system", "thinking", "tool_choice", "stop_sequences")):
558
+ _strip_anthropic_cache_controls(body)
484
559
  _validate_anthropic_context_management(body.get("context_management"))
485
560
  anthropic_messages = cast(list[dict[str, Any]], messages_value) if isinstance(messages_value, list) else []
486
561
  max_tokens_value = body.get("max_tokens")
@@ -952,6 +1027,7 @@ try:
952
1027
  ),
953
1028
  )
954
1029
  try:
1030
+ _strip_anthropic_cache_controls(body)
955
1031
  _validate_anthropic_context_management(body.get("context_management"))
956
1032
  messages, tools, _tool_choice, _stop, _reasoning_effort, _text = anthropic_request_to_internal(
957
1033
  model=body.get("model"),
@@ -1002,6 +1078,15 @@ try:
1002
1078
  )
1003
1079
 
1004
1080
  try:
1081
+ if body.get("previous_response_id") is not None:
1082
+ raise ChatGPTOAuthInvalidRequestError(
1083
+ "previous_response_id is not supported by the Anthropic Messages endpoint"
1084
+ )
1085
+ _strip_anthropic_cache_controls(body)
1086
+ prompt_cache_key = _anthropic_prompt_cache_key(
1087
+ body,
1088
+ http_request.headers.get(CLAUDE_CODE_SESSION_HEADER),
1089
+ )
1005
1090
  _validate_anthropic_context_management(body.get("context_management"))
1006
1091
  messages, tools, tool_choice, stop, reasoning_effort, text = anthropic_request_to_internal(
1007
1092
  model=body.get("model", MODEL),
@@ -1046,7 +1131,9 @@ try:
1046
1131
  reasoning_mode=reasoning_mode,
1047
1132
  reasoning_context=reasoning_context,
1048
1133
  stop=stop,
1134
+ prompt_cache_key=prompt_cache_key,
1049
1135
  text=text,
1136
+ codex_metadata=False,
1050
1137
  responses_lite=responses_lite,
1051
1138
  safety_identifier=body.get("safety_identifier"),
1052
1139
  prompt_cache_options=body.get("prompt_cache_options"),
@@ -1070,7 +1157,9 @@ try:
1070
1157
  reasoning_mode=reasoning_mode,
1071
1158
  reasoning_context=reasoning_context,
1072
1159
  stop=stop,
1160
+ prompt_cache_key=prompt_cache_key,
1073
1161
  text=text,
1162
+ codex_metadata=False,
1074
1163
  responses_lite=responses_lite,
1075
1164
  safety_identifier=body.get("safety_identifier"),
1076
1165
  prompt_cache_options=body.get("prompt_cache_options"),
@@ -1103,7 +1192,9 @@ try:
1103
1192
  reasoning_mode=reasoning_mode,
1104
1193
  reasoning_context=reasoning_context,
1105
1194
  stop=stop,
1195
+ prompt_cache_key=prompt_cache_key,
1106
1196
  text=text,
1197
+ codex_metadata=False,
1107
1198
  responses_lite=responses_lite,
1108
1199
  safety_identifier=body.get("safety_identifier"),
1109
1200
  prompt_cache_options=body.get("prompt_cache_options"),
@@ -1116,8 +1207,6 @@ try:
1116
1207
 
1117
1208
  request_id = f"msg_{uuid.uuid4().hex[:24]}"
1118
1209
  result = internal_response_to_anthropic(response, client_model, request_id)
1119
- if response.response_id is not None:
1120
- result["response_id"] = response.response_id
1121
1210
  return JSONResponse(content=result)
1122
1211
 
1123
1212
  # ------------------------------------------------------------------
@@ -481,15 +481,89 @@ def test_responses_payload_client_metadata_mode_overlays_reserved_keys(auth_json
481
481
  payload = provider._responses_payload( # noqa: SLF001
482
482
  _provider_messages(),
483
483
  model="gpt-5.5",
484
- client_metadata={"app": "kept", "turn_id": "user-value"},
484
+ client_metadata={"app": "kept", "session_id": "session-root", "turn_id": "user-value"},
485
485
  codex_metadata=True,
486
486
  )
487
487
 
488
488
  metadata = payload["client_metadata"]
489
489
  assert metadata["app"] == "kept"
490
+ assert metadata["session_id"] == "session-root"
491
+ assert metadata["thread_id"] == "session-root"
490
492
  assert metadata["turn_id"] != "user-value"
491
493
  assert metadata["x-codex-installation-id"]
492
- assert json.loads(metadata["x-codex-turn-metadata"])["source"] == "codex-as-api"
494
+ turn_metadata = json.loads(metadata["x-codex-turn-metadata"])
495
+ assert turn_metadata["source"] == "codex-as-api"
496
+ assert turn_metadata["session_id"] == "session-root"
497
+ assert turn_metadata["thread_id"] == "session-root"
498
+ assert payload["prompt_cache_key"] == "session-root"
499
+
500
+
501
+ def test_responses_payload_codex_metadata_preserves_thread_and_refreshes_only_turn_identity(auth_json_factory):
502
+ provider = ChatGPTOAuthProvider(auth_json_path=str(auth_json_factory()))
503
+ options = {
504
+ "model": "gpt-5.5",
505
+ "client_metadata": {"session_id": "session-root", "thread_id": "thread-child"},
506
+ "codex_metadata": True,
507
+ }
508
+
509
+ first = provider._responses_payload(_provider_messages(), **options) # noqa: SLF001
510
+ second = provider._responses_payload(_provider_messages(), **options) # noqa: SLF001
511
+ first_metadata = first["client_metadata"]
512
+ second_metadata = second["client_metadata"]
513
+
514
+ assert first_metadata["session_id"] == second_metadata["session_id"] == "session-root"
515
+ assert first_metadata["thread_id"] == second_metadata["thread_id"] == "thread-child"
516
+ assert first_metadata["x-codex-installation-id"] == second_metadata["x-codex-installation-id"]
517
+ assert first_metadata["x-codex-window-id"] == second_metadata["x-codex-window-id"]
518
+ assert first_metadata["turn_id"] != second_metadata["turn_id"]
519
+
520
+
521
+ def test_responses_payload_codex_metadata_requires_explicit_session_identity():
522
+ provider = ChatGPTOAuthProvider()
523
+
524
+ with pytest.raises(ChatGPTOAuthInvalidRequestError):
525
+ provider._responses_payload( # noqa: SLF001
526
+ _provider_messages(),
527
+ model="gpt-5.5",
528
+ codex_metadata=True,
529
+ )
530
+
531
+
532
+ def test_responses_payload_prompt_cache_key_precedence_and_session_fallback():
533
+ provider = ChatGPTOAuthProvider()
534
+
535
+ fallback = provider._responses_payload( # noqa: SLF001
536
+ _provider_messages(),
537
+ model="gpt-5.5",
538
+ client_metadata={"session_id": "session-cache"},
539
+ )
540
+ explicit = provider._responses_payload( # noqa: SLF001
541
+ _provider_messages(),
542
+ model="gpt-5.5",
543
+ prompt_cache_key="explicit-cache",
544
+ client_metadata={"session_id": "session-cache"},
545
+ )
546
+ absent = provider._responses_payload( # noqa: SLF001
547
+ _provider_messages(),
548
+ model="gpt-5.5",
549
+ )
550
+ blank_session = provider._responses_payload( # noqa: SLF001
551
+ _provider_messages(),
552
+ model="gpt-5.5",
553
+ client_metadata={"session_id": " "},
554
+ )
555
+ explicit_with_blank_session = provider._responses_payload( # noqa: SLF001
556
+ _provider_messages(),
557
+ model="gpt-5.5",
558
+ prompt_cache_key="explicit-cache",
559
+ client_metadata={"session_id": " "},
560
+ )
561
+
562
+ assert fallback["prompt_cache_key"] == "session-cache"
563
+ assert explicit["prompt_cache_key"] == "explicit-cache"
564
+ assert "prompt_cache_key" not in absent
565
+ assert "prompt_cache_key" not in blank_session
566
+ assert explicit_with_blank_session["prompt_cache_key"] == "explicit-cache"
493
567
 
494
568
 
495
569
  def test_chat_stream_adds_responses_lite_header(monkeypatch):
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import queue
5
6
  import threading
@@ -14,6 +15,14 @@ from codex_as_api.model_capabilities import LITE_HEADER_NAME, LITE_HEADER_VALUE,
14
15
  from codex_as_api.provider import REMOTE_COMPACTION_MARKER, ChatGPTOAuthProvider
15
16
 
16
17
 
18
+ def _has_nested_key(value: object, key: str) -> bool:
19
+ if isinstance(value, dict):
20
+ return key in value or any(_has_nested_key(child, key) for child in value.values())
21
+ if isinstance(value, list):
22
+ return any(_has_nested_key(child, key) for child in value)
23
+ return False
24
+
25
+
17
26
  class RecordingBackend(NamedTuple):
18
27
  base_url: str
19
28
  requests: queue.Queue[dict[str, Any]]
@@ -519,6 +528,27 @@ def test_chat_handler_omits_standard_reasoning_mode_and_preserves_supported_fiel
519
528
  ]
520
529
 
521
530
 
531
+ def test_chat_handler_codex_metadata_requires_client_session_before_upstream(
532
+ client,
533
+ recording_backend: RecordingBackend,
534
+ ):
535
+ response = client.post(
536
+ "/v1/chat/completions",
537
+ json={
538
+ "model": "gpt-5.6-sol",
539
+ "messages": [
540
+ {"role": "system", "content": "system"},
541
+ {"role": "user", "content": "hello"},
542
+ ],
543
+ "codex_metadata": True,
544
+ },
545
+ )
546
+
547
+ assert response.status_code == 400
548
+ assert response.json()["error"]["type"] == "chatgpt_oauth_error"
549
+ assert recording_backend.requests.empty()
550
+
551
+
522
552
  def test_chat_handler_rejects_pro_mode_before_upstream(
523
553
  client,
524
554
  recording_backend: RecordingBackend,
@@ -2076,6 +2106,213 @@ def test_anthropic_messages_uses_codex_model_for_provider_and_client_model_in_re
2076
2106
  assert resp.json()["model"] == "claude-sonnet-4-5"
2077
2107
 
2078
2108
 
2109
+ def test_anthropic_messages_uses_session_cache_affinity_without_codex_metadata(
2110
+ client,
2111
+ recording_backend: RecordingBackend,
2112
+ monkeypatch,
2113
+ ):
2114
+ monkeypatch.setenv("CODEX_AS_API_CODEX_METADATA", "on")
2115
+ payload = {
2116
+ "model": "gpt-5.6-sol",
2117
+ "max_tokens": 1024,
2118
+ "system": "system",
2119
+ "messages": [{"role": "user", "content": "hello"}],
2120
+ }
2121
+ session_id = "claude-session-123"
2122
+ other_session_id = "claude-session-456"
2123
+ expected = hashlib.sha256(f"codex-as-api:claude-code-session:{session_id}".encode()).hexdigest()
2124
+ other_expected = hashlib.sha256(f"codex-as-api:claude-code-session:{other_session_id}".encode()).hexdigest()
2125
+
2126
+ first = client.post(
2127
+ "/v1/messages",
2128
+ headers={"x-claude-code-session-id": session_id},
2129
+ json=payload,
2130
+ )
2131
+ second = client.post(
2132
+ "/v1/messages",
2133
+ headers={"x-claude-code-session-id": session_id},
2134
+ json=payload,
2135
+ )
2136
+ third = client.post(
2137
+ "/v1/messages",
2138
+ headers={"x-claude-code-session-id": other_session_id},
2139
+ json=payload,
2140
+ )
2141
+ explicit = client.post(
2142
+ "/v1/messages",
2143
+ headers={"x-claude-code-session-id": session_id},
2144
+ json={**payload, "prompt_cache_key": "explicit-cache-key"},
2145
+ )
2146
+
2147
+ assert first.status_code == second.status_code == third.status_code == explicit.status_code == 200
2148
+ first_outbound = recording_backend.requests.get(timeout=1)["body"]
2149
+ second_outbound = recording_backend.requests.get(timeout=1)["body"]
2150
+ third_outbound = recording_backend.requests.get(timeout=1)["body"]
2151
+ explicit_outbound = recording_backend.requests.get(timeout=1)["body"]
2152
+ assert first_outbound["prompt_cache_key"] == expected
2153
+ assert second_outbound["prompt_cache_key"] == expected
2154
+ assert third_outbound["prompt_cache_key"] == other_expected
2155
+ assert explicit_outbound["prompt_cache_key"] == "explicit-cache-key"
2156
+ assert "client_metadata" not in first_outbound
2157
+ assert "client_metadata" not in second_outbound
2158
+ assert "client_metadata" not in third_outbound
2159
+ assert "client_metadata" not in explicit_outbound
2160
+ assert "response_id" not in first.json()
2161
+ assert "response_id" not in second.json()
2162
+ assert "response_id" not in third.json()
2163
+ assert "response_id" not in explicit.json()
2164
+
2165
+
2166
+ def test_anthropic_messages_without_session_or_explicit_cache_key_omits_cache_key(
2167
+ client,
2168
+ recording_backend: RecordingBackend,
2169
+ ):
2170
+ response = client.post(
2171
+ "/v1/messages",
2172
+ json={
2173
+ "model": "gpt-5.6-sol",
2174
+ "max_tokens": 1024,
2175
+ "system": "system",
2176
+ "messages": [{"role": "user", "content": "hello"}],
2177
+ "previous_response_id": None,
2178
+ },
2179
+ )
2180
+
2181
+ assert response.status_code == 200
2182
+ assert "prompt_cache_key" not in recording_backend.requests.get(timeout=1)["body"]
2183
+
2184
+
2185
+ def test_anthropic_messages_accepts_and_strips_ephemeral_cache_control_hints(
2186
+ client,
2187
+ recording_backend: RecordingBackend,
2188
+ ):
2189
+ response = client.post(
2190
+ "/v1/messages",
2191
+ json={
2192
+ "model": "gpt-5.6-sol",
2193
+ "max_tokens": 1024,
2194
+ "responses_lite": False,
2195
+ "cache_control": {"type": "ephemeral"},
2196
+ "system": [
2197
+ {
2198
+ "type": "text",
2199
+ "text": "system",
2200
+ "cache_control": {"type": "ephemeral", "ttl": "1h"},
2201
+ }
2202
+ ],
2203
+ "messages": [
2204
+ {
2205
+ "role": "user",
2206
+ "cache_control": {"type": "ephemeral", "ttl": "5m"},
2207
+ "content": [
2208
+ {
2209
+ "type": "text",
2210
+ "text": "hello",
2211
+ "cache_control": {"type": "ephemeral"},
2212
+ }
2213
+ ],
2214
+ }
2215
+ ],
2216
+ "tools": [
2217
+ {
2218
+ "name": "lookup",
2219
+ "description": "Lookup",
2220
+ "input_schema": {"type": "object"},
2221
+ "cache_control": {"type": "ephemeral", "ttl": "1h"},
2222
+ }
2223
+ ],
2224
+ },
2225
+ )
2226
+
2227
+ assert response.status_code == 200, response.text
2228
+ outbound = recording_backend.requests.get(timeout=1)["body"]
2229
+ assert not _has_nested_key(outbound, "cache_control")
2230
+ assert outbound["instructions"] == "system"
2231
+ assert outbound["input"][0]["content"][0]["text"] == "hello"
2232
+ assert outbound["tools"][0]["name"] == "lookup"
2233
+
2234
+
2235
+ @pytest.mark.parametrize(
2236
+ "cache_control_payload",
2237
+ [
2238
+ {"cache_control": "ephemeral"},
2239
+ {"cache_control": {"type": "persistent"}},
2240
+ {"cache_control": {"type": "ephemeral", "ttl": None}},
2241
+ {
2242
+ "system": [
2243
+ {
2244
+ "type": "text",
2245
+ "text": "system",
2246
+ "cache_control": {"type": "ephemeral", "ttl": "24h"},
2247
+ }
2248
+ ]
2249
+ },
2250
+ {
2251
+ "messages": [
2252
+ {
2253
+ "role": "user",
2254
+ "content": [
2255
+ {
2256
+ "type": "text",
2257
+ "text": "hello",
2258
+ "cache_control": {"type": "ephemeral", "extra": True},
2259
+ }
2260
+ ],
2261
+ }
2262
+ ]
2263
+ },
2264
+ {
2265
+ "tools": [
2266
+ {
2267
+ "name": "lookup",
2268
+ "input_schema": {"type": "object"},
2269
+ "cache_control": {"type": "ephemeral", "ttl": []},
2270
+ }
2271
+ ]
2272
+ },
2273
+ ],
2274
+ )
2275
+ def test_anthropic_messages_rejects_malformed_cache_control_before_upstream(
2276
+ client,
2277
+ recording_backend: RecordingBackend,
2278
+ cache_control_payload,
2279
+ ):
2280
+ payload = {
2281
+ "model": "gpt-5.6-sol",
2282
+ "max_tokens": 1024,
2283
+ "system": "system",
2284
+ "messages": [{"role": "user", "content": "hello"}],
2285
+ **cache_control_payload,
2286
+ }
2287
+ response = client.post("/v1/messages", json=payload)
2288
+
2289
+ assert response.status_code == 400
2290
+ assert response.json()["type"] == "error"
2291
+ assert response.json()["error"]["type"] == "invalid_request_error"
2292
+ assert recording_backend.requests.empty()
2293
+
2294
+
2295
+ def test_anthropic_messages_rejects_non_null_previous_response_id_before_upstream(
2296
+ client,
2297
+ recording_backend: RecordingBackend,
2298
+ ):
2299
+ response = client.post(
2300
+ "/v1/messages",
2301
+ json={
2302
+ "model": "gpt-5.6-sol",
2303
+ "max_tokens": 1024,
2304
+ "system": "system",
2305
+ "messages": [{"role": "user", "content": "hello"}],
2306
+ "previous_response_id": "resp-prior",
2307
+ },
2308
+ )
2309
+
2310
+ assert response.status_code == 400
2311
+ assert response.json()["type"] == "error"
2312
+ assert response.json()["error"]["type"] == "invalid_request_error"
2313
+ assert recording_backend.requests.empty()
2314
+
2315
+
2079
2316
  def test_anthropic_latest_claude_code_shape_routes_known_gpt_effort_and_fast_mode(
2080
2317
  client,
2081
2318
  recording_backend: RecordingBackend,
File without changes
File without changes