codex-as-api 0.8.0__tar.gz → 0.8.2__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 (47) hide show
  1. codex_as_api-0.8.0/README.md → codex_as_api-0.8.2/PKG-INFO +51 -0
  2. codex_as_api-0.8.0/PKG-INFO → codex_as_api-0.8.2/README.md +17 -33
  3. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/RELEASES.md +34 -0
  4. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/pyproject.toml +2 -2
  5. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/__init__.py +1 -1
  6. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/anthropic_adapter.py +12 -2
  7. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/codex_config.py +5 -4
  8. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/server.py +5 -5
  9. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_anthropic_adapter.py +34 -1
  10. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_model_capabilities.py +18 -0
  11. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_provider.py +2 -2
  12. codex_as_api-0.8.2/tests/test_ultrafast.py +159 -0
  13. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/.gitignore +0 -0
  14. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/LICENSE +0 -0
  15. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/THIRD_PARTY_NOTICES.md +0 -0
  16. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/config/codex-upstream-contract.json +0 -0
  17. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/config/o200k_base.tiktoken +0 -0
  18. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/requirements.txt +0 -0
  19. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/rust/THIRD_PARTY_NOTICES.md +0 -0
  20. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/check_no_static_model_catalog.py +0 -0
  21. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/check_package_versions.py +0 -0
  22. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/check_registry_release.py +0 -0
  23. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/check_release_artifacts.py +0 -0
  24. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/generate_rust_third_party_notices.py +0 -0
  25. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/scripts/package_rust_release.py +0 -0
  26. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/auth.py +0 -0
  27. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/messages.py +0 -0
  28. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/model_capabilities.py +0 -0
  29. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/o200k_tokenizer.py +0 -0
  30. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/protocol.py +0 -0
  31. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/provider.py +0 -0
  32. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/src/codex_as_api/strict_json.py +0 -0
  33. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/__init__.py +0 -0
  34. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/conftest.py +0 -0
  35. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/fixtures/o200k_base_encode_ordinary.json +0 -0
  36. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_auth.py +0 -0
  37. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_codex_config.py +0 -0
  38. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_messages.py +0 -0
  39. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_no_static_model_catalog.py +0 -0
  40. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_o200k_tokenizer.py +0 -0
  41. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_package_versions.py +0 -0
  42. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_protocol.py +0 -0
  43. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_registry_release.py +0 -0
  44. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_release_artifacts.py +0 -0
  45. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_rust_third_party_notices.py +0 -0
  46. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_server.py +0 -0
  47. {codex_as_api-0.8.0 → codex_as_api-0.8.2}/tests/test_strict_json.py +0 -0
@@ -1,3 +1,37 @@
1
+ Metadata-Version: 2.5
2
+ Name: codex-as-api
3
+ Version: 0.8.2
4
+ Summary: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server.
5
+ Project-URL: Homepage, https://github.com/Eunho-J/codex-as-api
6
+ Project-URL: Repository, https://github.com/Eunho-J/codex-as-api
7
+ Author: Eunho-J
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: api,chatgpt,codex,llm,oauth,openai
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fastapi>=0.100
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: regex
24
+ Requires-Dist: tomli>=2; python_version < '3.11'
25
+ Requires-Dist: uvicorn>=0.23
26
+ Provides-Extra: dev
27
+ Requires-Dist: httpx; extra == 'dev'
28
+ Requires-Dist: mypy; extra == 'dev'
29
+ Requires-Dist: pytest; extra == 'dev'
30
+ Requires-Dist: ruff; extra == 'dev'
31
+ Requires-Dist: tomli>=2.0.1; extra == 'dev'
32
+ Requires-Dist: types-regex; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
1
35
  # codex-as-api
2
36
 
3
37
  [![GitHub Release](https://img.shields.io/github/v/release/Eunho-J/codex-as-api)](https://github.com/Eunho-J/codex-as-api/releases)
@@ -537,6 +571,23 @@ The mapping is intentionally transport-aware:
537
571
  | `previous_response_id` | Resolve locally and replay complete Response history over HTTP |
538
572
  | `service_tier: "fast"` | Send `service_tier: "priority"` |
539
573
  | `service_tier: "default"` | Omit the field |
574
+ | `service_tier: "ultrafast"` | Send `service_tier: "ultrafast"` only when the live model advertises it |
575
+ | Messages/compact `speed: "ultrafast"` (proxy extension) | Resolve to `service_tier: "ultrafast"`, with the same catalog check |
576
+
577
+ Ultra Fast is distinct from Fast (`priority`), not a reasoning effort. For OpenAI
578
+ Chat requests use `"service_tier": "ultrafast"`; for `/v1/messages` and
579
+ `/v1/messages/compact` either that field or the proxy extension
580
+ `"speed": "ultrafast"` is accepted. If both fields are supplied they must agree.
581
+ Unsupported tiers return HTTP 400 before an inference request, including streaming
582
+ requests; the proxy never silently downgrades Ultra Fast to priority. Check the
583
+ account-specific `service_tiers` returned by `GET /v1/models`. An official feature
584
+ flag does not grant account access or change pricing. `speed: "ultrafast"` is not
585
+ claimed as an official Anthropic field, and existing `speed: "fast"` behavior is
586
+ unchanged. The proxy does not inherit speed defaults from Codex config.
587
+
588
+ Official behavior reference: Codex [0.164.0 service-tier resolution](https://github.com/openai/codex/blob/rust-v0.164.0/codex-rs/tui/src/service_tier_resolution.rs)
589
+ and [stable, enabled-by-default ultrafast feature](https://github.com/openai/codex/blob/rust-v0.164.0/codex-rs/features/src/lib.rs).
590
+ The rest of the private HTTP contract remains pinned to 0.153.3.
540
591
 
541
592
  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 Codex 0.153.3 [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/b1a547b1f73ce86205d9222ac19cff334b3b7a2e/codex-rs/codex-api/src/common.rs#L275-L350).
542
593
 
@@ -1,36 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: codex-as-api
3
- Version: 0.8.0
4
- Summary: Use ChatGPT/Codex OAuth as a local OpenAI-compatible API server.
5
- Project-URL: Homepage, https://github.com/Eunho-J/codex-as-api
6
- Project-URL: Repository, https://github.com/Eunho-J/codex-as-api
7
- Author: Eunho-J
8
- License: Apache-2.0
9
- License-File: LICENSE
10
- Keywords: api,chatgpt,codex,llm,oauth,openai
11
- Classifier: Development Status :: 3 - Alpha
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: Apache Software License
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
- Requires-Python: >=3.10
21
- Requires-Dist: fastapi>=0.100
22
- Requires-Dist: pydantic>=2.0
23
- Requires-Dist: regex
24
- Requires-Dist: tomli>=2; python_version < '3.11'
25
- Requires-Dist: uvicorn>=0.23
26
- Provides-Extra: dev
27
- Requires-Dist: httpx; extra == 'dev'
28
- Requires-Dist: mypy; extra == 'dev'
29
- Requires-Dist: pytest; extra == 'dev'
30
- Requires-Dist: ruff; extra == 'dev'
31
- Requires-Dist: types-regex; extra == 'dev'
32
- Description-Content-Type: text/markdown
33
-
34
1
  # codex-as-api
35
2
 
36
3
  [![GitHub Release](https://img.shields.io/github/v/release/Eunho-J/codex-as-api)](https://github.com/Eunho-J/codex-as-api/releases)
@@ -570,6 +537,23 @@ The mapping is intentionally transport-aware:
570
537
  | `previous_response_id` | Resolve locally and replay complete Response history over HTTP |
571
538
  | `service_tier: "fast"` | Send `service_tier: "priority"` |
572
539
  | `service_tier: "default"` | Omit the field |
540
+ | `service_tier: "ultrafast"` | Send `service_tier: "ultrafast"` only when the live model advertises it |
541
+ | Messages/compact `speed: "ultrafast"` (proxy extension) | Resolve to `service_tier: "ultrafast"`, with the same catalog check |
542
+
543
+ Ultra Fast is distinct from Fast (`priority`), not a reasoning effort. For OpenAI
544
+ Chat requests use `"service_tier": "ultrafast"`; for `/v1/messages` and
545
+ `/v1/messages/compact` either that field or the proxy extension
546
+ `"speed": "ultrafast"` is accepted. If both fields are supplied they must agree.
547
+ Unsupported tiers return HTTP 400 before an inference request, including streaming
548
+ requests; the proxy never silently downgrades Ultra Fast to priority. Check the
549
+ account-specific `service_tiers` returned by `GET /v1/models`. An official feature
550
+ flag does not grant account access or change pricing. `speed: "ultrafast"` is not
551
+ claimed as an official Anthropic field, and existing `speed: "fast"` behavior is
552
+ unchanged. The proxy does not inherit speed defaults from Codex config.
553
+
554
+ Official behavior reference: Codex [0.164.0 service-tier resolution](https://github.com/openai/codex/blob/rust-v0.164.0/codex-rs/tui/src/service_tier_resolution.rs)
555
+ and [stable, enabled-by-default ultrafast feature](https://github.com/openai/codex/blob/rust-v0.164.0/codex-rs/features/src/lib.rs).
556
+ The rest of the private HTTP contract remains pinned to 0.153.3.
573
557
 
574
558
  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 Codex 0.153.3 [HTTP/WebSocket request structures](https://github.com/openai/codex/blob/b1a547b1f73ce86205d9222ac19cff334b3b7a2e/codex-rs/codex-api/src/common.rs#L275-L350).
575
559
 
@@ -1,5 +1,39 @@
1
1
  # Release Notes
2
2
 
3
+ ## v0.8.2
4
+
5
+ ### Hotfix: disjoint Anthropic cache usage (all runtimes)
6
+
7
+ - Correct Anthropic Messages usage in streaming and non-streaming responses:
8
+ `input_tokens` now excludes both cache reads and reported cache creation,
9
+ which remain in their separate `cache_read_input_tokens` and
10
+ `cache_creation_input_tokens` counters. Previously, consumers adding those
11
+ counters counted cached input twice.
12
+ - Keep OpenAI/Responses totals unchanged. Preserve unknown cache counters as
13
+ null; subtract only reported cache buckets. Reject impossible cache sums
14
+ exceeding the upstream input total instead of emitting negative/clamped usage.
15
+ - Add no-cache, partial/full-cache, cache-write, invalid-total and HTTP pipeline
16
+ regressions across TypeScript, Python and Rust. Example: 100 total input,
17
+ 50 cache read and 25 cache write becomes 25 uncached + 50 read + 25 write.
18
+
19
+ ## v0.8.1
20
+
21
+ ### Explicit Ultra Fast selection (all runtimes)
22
+
23
+ - Accept `speed: "ultrafast"` as a proxy extension on Anthropic Messages and
24
+ compact routes and forward the literal Codex `service_tier: "ultrafast"`.
25
+ Direct `service_tier` selection remains supported on Chat and Messages.
26
+ - Preserve live account/model tier validation: unavailable Ultra Fast is HTTP
27
+ 400 before inference/streaming headers, never a silent downgrade to priority.
28
+ Reject conflicting speed/tier inputs; keep Fast and standard behavior unchanged.
29
+ - Add cross-runtime mapping and capability regression tests, including HTTP
30
+ recording-backend tests. Malformed Python speed objects/arrays now return
31
+ invalid-request errors instead of causing an unhashable-type failure.
32
+ - Keep TOML config imports type-checkable on current Python while preserving
33
+ Python 3.10 support; include `tomli` in dev dependencies for the 3.10 analysis target.
34
+ - Document Ultra Fast separately from priority and reasoning effort. No model
35
+ support, account entitlement, pricing or config speed default is invented.
36
+
3
37
  ## v0.8.0
4
38
 
5
39
  ### ChatGPT Live realtime voice (TypeScript)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codex-as-api"
7
- version = "0.8.0"
7
+ version = "0.8.2"
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" }
@@ -33,7 +33,7 @@ dependencies = [
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
36
- dev = ["pytest", "ruff", "mypy", "httpx", "types-regex"]
36
+ dev = ["pytest", "ruff", "mypy", "httpx", "types-regex", "tomli>=2.0.1"]
37
37
 
38
38
  [project.scripts]
39
39
  codex-as-api = "codex_as_api.server:main"
@@ -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.8.0"
3
+ __version__ = "0.8.2"
@@ -718,13 +718,23 @@ def _reject_unrepresentable_response_events(raw: dict | None) -> None:
718
718
  )
719
719
 
720
720
 
721
+ def _uncached_input_tokens(total: int, cache_read: int | None, cache_write: int | None) -> int:
722
+ # OpenAI totals include cache buckets; Anthropic exposes disjoint counters.
723
+ cached = (cache_read or 0) + (cache_write or 0)
724
+ if cached > total:
725
+ raise ChatGPTOAuthProtocolError("usage cache tokens must not exceed input_tokens")
726
+ return total - cached
727
+
728
+
721
729
  def _anthropic_usage_from_internal(usage: Usage) -> dict[str, Any]:
722
730
  return {
723
731
  "cache_creation": None,
724
732
  "cache_creation_input_tokens": usage.cache_write_tokens,
725
733
  "cache_read_input_tokens": usage.cached_tokens,
726
734
  "inference_geo": None,
727
- "input_tokens": usage.prompt_tokens,
735
+ "input_tokens": _uncached_input_tokens(
736
+ usage.prompt_tokens, usage.cached_tokens, usage.cache_write_tokens
737
+ ),
728
738
  "iterations": None,
729
739
  "output_tokens": usage.completion_tokens,
730
740
  "server_tool_use": None,
@@ -910,7 +920,7 @@ def _anthropic_usage_from_provider(usage: Any) -> dict[str, Any]:
910
920
  out: dict[str, Any] = {
911
921
  "cache_creation_input_tokens": cache_write,
912
922
  "cache_read_input_tokens": cache_read,
913
- "input_tokens": input_tokens,
923
+ "input_tokens": _uncached_input_tokens(input_tokens, cache_read, cache_write),
914
924
  "iterations": None,
915
925
  "output_tokens": output_tokens,
916
926
  "server_tool_use": server_tool_use,
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import sys
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
8
- try:
9
- import tomllib # type: ignore[import-not-found]
10
- except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
11
- import tomli as tomllib # type: ignore[import-not-found]
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else: # pragma: no cover - Python 3.10 compatibility
12
+ import tomli as tomllib
12
13
 
13
14
 
14
15
  _JS_SAFE_INTEGER = 9_007_199_254_740_991
@@ -271,10 +271,10 @@ def _anthropic_service_tier(body: dict[str, Any]) -> str | None:
271
271
  speed = body.get("speed")
272
272
  if speed is None:
273
273
  return cast(str | None, service_tier)
274
- if speed not in {"fast", "standard"}:
275
- raise ChatGPTOAuthInvalidRequestError("speed must be one of: fast, standard")
276
- speed_tier = "fast" if speed == "fast" else "default"
277
- equivalent_tiers = {"fast", "priority"} if speed == "fast" else {"default"}
274
+ if not isinstance(speed, str) or speed not in {"fast", "standard", "ultrafast"}:
275
+ raise ChatGPTOAuthInvalidRequestError("speed must be one of: fast, standard, ultrafast")
276
+ speed_tier = "default" if speed == "standard" else speed
277
+ equivalent_tiers = {"fast", "priority"} if speed == "fast" else {speed_tier}
278
278
  if service_tier is not None and service_tier not in equivalent_tiers:
279
279
  raise ChatGPTOAuthInvalidRequestError("speed conflicts with service_tier")
280
280
  return speed_tier
@@ -497,7 +497,7 @@ def _health_error_message(exc: BaseException) -> str:
497
497
  app = FastAPI(
498
498
  title="codex-as-api",
499
499
  description="Local OpenAI-compatible API server backed by ChatGPT/Codex OAuth.",
500
- version="0.8.0",
500
+ version="0.8.2",
501
501
  )
502
502
 
503
503
 
@@ -1206,6 +1206,23 @@ class TestInternalResponseToAnthropic:
1206
1206
  result = internal_response_to_anthropic(resp, "m", "msg_1")
1207
1207
  assert result["usage"]["cache_read_input_tokens"] == 50
1208
1208
  assert result["usage"]["cache_creation_input_tokens"] == 25
1209
+ assert result["usage"]["input_tokens"] == 25
1210
+
1211
+ @pytest.mark.parametrize("read,write", [(0, 0), (40, 0), (100, 0), (0, 100), (40, 60), (101, 0), (60, 41)])
1212
+ def test_disjoint_cache_buckets(self, read, write):
1213
+ source = Usage(prompt_tokens=100, completion_tokens=7, total_tokens=107,
1214
+ cached_tokens=read, cache_write_tokens=write)
1215
+ response = AssistantResponse(content="hi", tool_calls=(), finish_reason="stop",
1216
+ usage=source, reasoning_content=None, raw=None)
1217
+ if read + write > 100:
1218
+ with pytest.raises(ChatGPTOAuthProtocolError, match="cache tokens must not exceed"):
1219
+ internal_response_to_anthropic(response, "m", "msg")
1220
+ else:
1221
+ usage = internal_response_to_anthropic(response, "m", "msg")["usage"]
1222
+ assert usage["input_tokens"] == 100 - read - write
1223
+ buckets = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
1224
+ assert sum(usage[k] for k in buckets) == 100
1225
+ assert source.prompt_tokens == 100
1209
1226
 
1210
1227
  def test_web_search_provider_output_is_rejected(self):
1211
1228
  resp = AssistantResponse(
@@ -1460,7 +1477,7 @@ class TestAnthropicStreamAdapter:
1460
1477
  assert msg_delta["usage"] == {
1461
1478
  "cache_creation_input_tokens": 11,
1462
1479
  "cache_read_input_tokens": 13,
1463
- "input_tokens": 123,
1480
+ "input_tokens": 99,
1464
1481
  "iterations": None,
1465
1482
  "output_tokens": 7,
1466
1483
  "server_tool_use": {"web_search_requests": 2, "web_fetch_requests": 1},
@@ -1516,6 +1533,22 @@ class TestAnthropicStreamAdapter:
1516
1533
  with pytest.raises(ChatGPTOAuthProtocolError, match="provider (finish )?usage"):
1517
1534
  self._collect_events(events)
1518
1535
 
1536
+ @pytest.mark.parametrize("read,write", [(0, 0), (40, 0), (100, 0), (0, 100), (40, 60), (101, 0), (60, 41)])
1537
+ def test_stream_disjoint_cache_buckets(self, read, write):
1538
+ source = {"input_tokens": 100, "output_tokens": 7, "total_tokens": 107,
1539
+ "input_tokens_details": {"cached_tokens": read, "cache_write_tokens": write}}
1540
+ events = [{"type": "finish", "finish_reason": "stop", "usage": source}]
1541
+ if read + write > 100:
1542
+ with pytest.raises(ChatGPTOAuthProtocolError, match="cache tokens must not exceed"):
1543
+ self._collect_events(events)
1544
+ else:
1545
+ result = self._collect_events(events)
1546
+ usage = next(e["usage"] for e in result if e["type"] == "message_delta")
1547
+ assert usage["input_tokens"] == 100 - read - write
1548
+ buckets = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
1549
+ assert sum(usage[k] for k in buckets) == 100
1550
+ assert source["input_tokens"] == 100
1551
+
1519
1552
  def test_maps_responses_cache_write_details_into_message_delta(self):
1520
1553
  result = self._collect_events(
1521
1554
  [
@@ -89,6 +89,24 @@ def test_catalog_parser_diagnostics_do_not_reflect_slug_or_modality_values(
89
89
  assert parsed.models[0].input_modalities == ("audio", "text")
90
90
 
91
91
 
92
+ def test_ultrafast_preserves_live_tier_and_rejects_unavailable(model_catalog_document) -> None:
93
+ document = deepcopy(model_catalog_document)
94
+ document["models"][0]["service_tiers"] = [
95
+ {"id": "ultrafast", "name": "Ultra Fast", "description": "Ultra Fast"},
96
+ ]
97
+ capability = _parse(document).models[0]
98
+ payload = {}
99
+ apply_model_capability_fields(
100
+ payload, capability=capability, text=None, service_tier="ultrafast",
101
+ )
102
+ assert payload["service_tier"] == "ultrafast"
103
+ unavailable = _parse(model_catalog_document).models[0]
104
+ with pytest.raises(ValueError, match="service_tier"):
105
+ apply_model_capability_fields(
106
+ {}, capability=unavailable, text=None, service_tier="ultrafast",
107
+ )
108
+
109
+
92
110
  def test_invalid_model_control_diagnostics_do_not_reflect_values(
93
111
  model_catalog_document,
94
112
  ) -> None:
@@ -2158,7 +2158,7 @@ def test_codex_cli_headers_include_official_originator_and_versioned_user_agent(
2158
2158
 
2159
2159
  assert headers["originator"] == "codex_cli_rs"
2160
2160
  assert headers["User-Agent"].startswith("codex_cli_rs/0.153.3 (")
2161
- assert headers["User-Agent"].endswith(") codex-as-api/0.8.0")
2161
+ assert headers["User-Agent"].endswith(") codex-as-api/0.8.2")
2162
2162
 
2163
2163
 
2164
2164
  def test_codex_cli_headers_reject_unpinned_version():
@@ -2881,7 +2881,7 @@ def test_provider_headers_include_codex_cli_headers(auth_json_factory):
2881
2881
 
2882
2882
  assert headers["originator"] == "codex_cli_rs"
2883
2883
  assert headers["User-Agent"].startswith("codex_cli_rs/0.153.3 (")
2884
- assert headers["User-Agent"].endswith(") codex-as-api/0.8.0")
2884
+ assert headers["User-Agent"].endswith(") codex-as-api/0.8.2")
2885
2885
  assert headers["Authorization"].startswith("Bearer ")
2886
2886
 
2887
2887
 
@@ -0,0 +1,159 @@
1
+ """The proxy speed extension must not alias Ultra Fast to ordinary priority."""
2
+
3
+ import pytest
4
+
5
+ from codex_as_api.auth import ChatGPTOAuthInvalidRequestError
6
+ from codex_as_api.server import _anthropic_service_tier
7
+
8
+ # Reuse the real provider + loopback HTTP backend, rather than mocking the
9
+ # provider methods (which would bypass live-catalog tier validation).
10
+ from tests.test_server import client, recording_backend # noqa: F401
11
+
12
+ # Imported pytest fixtures are intentionally injected by argument name.
13
+ # ruff: noqa: F811
14
+
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def _isolate_ultrafast_http_environment(monkeypatch):
18
+ monkeypatch.setenv("CODEX_AS_API_RESPONSES_LITE", "auto")
19
+
20
+
21
+ @pytest.fixture()
22
+ def ultrafast_catalog(model_catalog_document):
23
+ model = next(
24
+ model for model in model_catalog_document["models"]
25
+ if model["slug"] == "gpt-5.6-sol"
26
+ )
27
+ model["service_tiers"].append({
28
+ "id": "ultrafast",
29
+ "name": "Ultra Fast",
30
+ "description": "Test-only advertised Ultra Fast tier",
31
+ })
32
+ return model_catalog_document
33
+
34
+
35
+ @pytest.mark.parametrize("fields", [
36
+ {"speed": "ultrafast"},
37
+ {"speed": "ultrafast", "service_tier": "ultrafast"},
38
+ {"service_tier": "ultrafast"},
39
+ ])
40
+ def test_ultrafast_speed_resolves_without_downgrade(fields):
41
+ assert _anthropic_service_tier(fields) == "ultrafast"
42
+
43
+
44
+ @pytest.mark.parametrize("fields", [
45
+ {"speed": "ultrafast", "service_tier": "priority"},
46
+ {"speed": "fast", "service_tier": "ultrafast"},
47
+ {"speed": "standard", "service_tier": "ultrafast"},
48
+ {"speed": {}},
49
+ {"speed": []},
50
+ ])
51
+ def test_ultrafast_conflicts_and_malformed_speed_are_invalid_requests(fields):
52
+ with pytest.raises(ChatGPTOAuthInvalidRequestError):
53
+ _anthropic_service_tier(fields)
54
+
55
+
56
+ @pytest.mark.parametrize("stream", [False, True], ids=["nonstream", "stream"])
57
+ @pytest.mark.parametrize("fields", [
58
+ {"speed": "ultrafast"},
59
+ {"service_tier": "ultrafast"},
60
+ {"speed": "ultrafast", "service_tier": "ultrafast"},
61
+ ], ids=["speed-alias", "direct-tier", "matching-controls"])
62
+ def test_messages_ultrafast_reaches_recording_upstream(
63
+ ultrafast_catalog, client, recording_backend, stream, fields,
64
+ ):
65
+ response = client.post("/v1/messages", json={
66
+ "model": "gpt-5.6-sol",
67
+ "max_tokens": 64,
68
+ "messages": [{"role": "user", "content": "hello"}],
69
+ "stream": stream,
70
+ **fields,
71
+ })
72
+
73
+ assert response.status_code == 200, response.text
74
+ if stream:
75
+ assert response.headers["content-type"].startswith("text/event-stream")
76
+ assert "event: message_stop" in response.text
77
+ assert "backend-ok" in response.text
78
+ assert "event: error" not in response.text
79
+ else:
80
+ assert response.json()["content"][0]["type"] == "text"
81
+ assert response.json()["content"][0]["text"] == "backend-ok"
82
+ recorded = recording_backend.requests.get(timeout=1)
83
+ assert recorded["path"] == "/responses"
84
+ assert recorded["body"]["service_tier"] == "ultrafast"
85
+ assert "speed" not in recorded["body"]
86
+ assert recording_backend.requests.empty()
87
+
88
+
89
+ @pytest.mark.parametrize("route, fields", [
90
+ ("/v1/messages/compact", {"speed": "ultrafast"}),
91
+ ("/v1/messages/compact", {"service_tier": "ultrafast"}),
92
+ ("/v1/messages/compact", {"speed": "ultrafast", "service_tier": "ultrafast"}),
93
+ ("/v1/compact", {"service_tier": "ultrafast"}),
94
+ ], ids=["speed-alias", "direct-tier", "matching-controls", "openai-compact"])
95
+ def test_compact_ultrafast_reaches_recording_upstream(
96
+ ultrafast_catalog, client, recording_backend, route, fields,
97
+ ):
98
+ body = {
99
+ "model": "gpt-5.6-sol",
100
+ "messages": [{"role": "user", "content": "hello"}],
101
+ **fields,
102
+ }
103
+ if route == "/v1/messages/compact":
104
+ body["max_tokens"] = 64
105
+ response = client.post(route, json=body)
106
+
107
+ assert response.status_code == 200, response.text
108
+ recorded = recording_backend.requests.get(timeout=1)
109
+ assert recorded["path"] == "/responses/compact"
110
+ assert recorded["body"]["service_tier"] == "ultrafast"
111
+ assert "speed" not in recorded["body"]
112
+ assert recording_backend.requests.empty()
113
+
114
+
115
+ @pytest.mark.parametrize("route, stream", [
116
+ ("/v1/messages", False),
117
+ ("/v1/messages", True),
118
+ ("/v1/messages/compact", False),
119
+ ])
120
+ @pytest.mark.parametrize("fields", [
121
+ {"speed": "ultrafast"},
122
+ {"service_tier": "ultrafast"},
123
+ ], ids=["speed-alias", "direct-tier"])
124
+ def test_unadvertised_ultrafast_rejected_before_recording_upstream(
125
+ client, recording_backend, route, stream, fields,
126
+ ):
127
+ # Default catalog advertises priority only: do not globally grant Ultra Fast
128
+ # in the common fixture, or this rejection guard would become untestable.
129
+ body = {
130
+ "model": "gpt-5.6-sol",
131
+ "max_tokens": 64,
132
+ "messages": [{"role": "user", "content": "hello"}],
133
+ **fields,
134
+ }
135
+ if route == "/v1/messages":
136
+ body["stream"] = stream
137
+ response = client.post(route, json=body)
138
+
139
+ assert response.status_code == 400, response.text
140
+ assert response.json()["error"]["type"] == "invalid_request_error"
141
+ assert "service_tier" in response.json()["error"]["message"]
142
+ assert recording_backend.requests.empty()
143
+ # Catalog discovery is permitted; no generation/compact POST may occur.
144
+ assert not recording_backend.catalog_requests.empty()
145
+
146
+
147
+ def test_openai_compact_unadvertised_ultrafast_rejected_before_upstream(
148
+ client, recording_backend,
149
+ ):
150
+ response = client.post("/v1/compact", json={
151
+ "model": "gpt-5.6-sol",
152
+ "messages": [{"role": "user", "content": "hello"}],
153
+ "service_tier": "ultrafast",
154
+ })
155
+
156
+ assert response.status_code == 400, response.text
157
+ assert "service_tier" in response.json()["error"]["message"]
158
+ assert recording_backend.requests.empty()
159
+ assert not recording_backend.catalog_requests.empty()
File without changes
File without changes