siwc-proxy 0.2.2__tar.gz → 0.2.3__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 (27) hide show
  1. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/PKG-INFO +3 -4
  2. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/docs/proxy.md +37 -15
  3. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/pyproject.toml +5 -6
  4. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/auth.py +8 -8
  5. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/cli.py +7 -7
  6. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/responses.py +16 -11
  7. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/server.py +21 -24
  8. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/sse.py +3 -2
  9. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/support.py +11 -11
  10. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_cli.py +6 -6
  11. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_compat_sdk.py +8 -8
  12. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_login.py +3 -3
  13. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_proxy.py +34 -33
  14. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_tokens.py +16 -16
  15. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/.gitignore +0 -0
  16. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/LICENSE +0 -0
  17. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/README.md +0 -0
  18. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/__init__.py +0 -0
  19. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/__main__.py +0 -0
  20. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/chat.py +0 -0
  21. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/config.py +0 -0
  22. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/constants.py +0 -0
  23. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/errors.py +0 -0
  24. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/store.py +0 -0
  25. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/conftest.py +0 -0
  26. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/smoke.py +0 -0
  27. {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_chat.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: siwc-proxy
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Local OpenAI-compatible API proxy for Sign in with ChatGPT token sharing
5
5
  Project-URL: Homepage, https://github.com/d-nyamkhuu/siwc-proxy
6
6
  Project-URL: Documentation, https://github.com/d-nyamkhuu/siwc-proxy/blob/main/docs/proxy.md
@@ -24,12 +24,11 @@ Classifier: Topic :: Internet :: Proxy Servers
24
24
  Classifier: Topic :: Software Development :: Libraries
25
25
  Requires-Python: >=3.12
26
26
  Requires-Dist: anyio>=4.4
27
- Requires-Dist: authlib<1.8,>=1.7.2
27
+ Requires-Dist: authlib<1.9,>=1.8
28
28
  Requires-Dist: click>=8.1
29
29
  Requires-Dist: fastapi>=0.115
30
30
  Requires-Dist: filelock>=3.16
31
- Requires-Dist: httpx-sse>=0.4.3
32
- Requires-Dist: httpx<0.29,>=0.28
31
+ Requires-Dist: httpx2<3,>=2.13
33
32
  Requires-Dist: itsdangerous>=2.2
34
33
  Requires-Dist: joserfc>=1.6
35
34
  Requires-Dist: platformdirs>=4.3
@@ -224,34 +224,56 @@ These are always rejected: `n > 1`, `logprobs`, audio, and legacy
224
224
 
225
225
  Neither API keeps conversation state here: every Chat or Responses call is a new
226
226
  upstream request that carries the whole history. To keep long chats fast, turns
227
- need to reuse the prompt cache. ChatGPT sign-in keys that cache on a `session_id`
228
- request header and replaces any `prompt_cache_key` with a random value per
229
- request, so without a session no turn ever hits the cache.
227
+ need to reuse the prompt cache. ChatGPT sign-in derives cache affinity from the
228
+ `session-id` request header (as Codex sends it) and sets the prompt cache key
229
+ from it, replacing any `prompt_cache_key` in the body. Without a session no turn
230
+ ever hits the cache.
230
231
 
231
- The proxy sends a `session_id` on every inference request:
232
+ The proxy sends a `session-id` on every inference request, and, like Codex, the
233
+ same value as the body's `prompt_cache_key` (the API's official field, in case
234
+ upstream starts honoring it):
232
235
 
233
- - The caller's own `session_id` header, if there is one, is forwarded as is.
236
+ - The caller's own `session-id` header, if there is one, is used as is.
234
237
  - Otherwise, a `prompt_cache_key` from the body (Chat or Responses) names the
235
- session; it is removed from the body, since upstream ignores it there.
238
+ session.
236
239
  - Otherwise, the session is derived from the model, `instructions` and the
237
240
  first input item (usually the system prompt). These stay the same as a
238
241
  conversation grows, so every turn lands in one session, and editing a later
239
242
  message keeps the cached prefix up to the edit. Conversations that start the
240
243
  same way share a session, which also shares the cache for that opening.
241
244
 
242
- Session values are HMACs under a key made at startup, so conversation text is
243
- never sent as an ID; a proxy restart starts new sessions. Usage reports hits in
244
- `prompt_tokens_details.cached_tokens` (Chat) or
245
- `input_tokens_details.cached_tokens` (Responses). Caching is best effort: in
246
- live tests, cached tokens grew with the history to about 80% of the prompt,
247
- but an occasional turn missed.
245
+ Derived session values are HMACs under a key made at startup, so conversation
246
+ text is never sent as an ID; a proxy restart starts new sessions. Usage reports
247
+ hits in `prompt_tokens_details.cached_tokens` (Chat) or
248
+ `input_tokens_details.cached_tokens` (Responses).
249
+
250
+ **Caching is best effort, and cached tokens can go down.** The cache lives on
251
+ several upstream machines, each holding only the prompts it served, and a
252
+ session steers requests towards them without pinning a conversation to one. So
253
+ each turn gets whatever the machine it lands on has:
254
+
255
+ - the previous turn's prompt, rounded down to a multiple of 128 tokens, when it
256
+ lands where the last turn ran;
257
+ - an earlier, shorter prefix when it lands on a machine that served an older turn,
258
+ so `cached` drops below the turn before;
259
+ - nothing (`cached=0`) on a machine that has not seen the conversation, which is
260
+ common for the second and third turns.
261
+
262
+ Prompts under 1024 tokens are never cached, so short side requests such as title
263
+ generation always show `cached=0`. In live tests with 12-turn conversations of 4–8k
264
+ tokens, 56–92% of input tokens after the first turn were cached, with 0–4 turns
265
+ missing entirely. Sending Codex's other headers (`thread-id`, `x-client-request-id`)
266
+ or replaying the `x-codex-turn-state` response header did not change this, and
267
+ explicit `prompt_cache_breakpoint`s are rejected. What a client can control is the
268
+ prefix: keep the system prompt (no timestamps), tools and their order,
269
+ `reasoning_effort` and `response_format` identical between turns, and only append
270
+ new messages.
248
271
 
249
272
  The access log shows each inference request's token counts and cache hit rate,
250
- the cached share of its input tokens, plus `cache_overall`, the rate across all
251
- requests since the proxy started:
273
+ the cached share of its input tokens:
252
274
 
253
275
  ```text
254
- POST /v1/chat/completions 200 model=gpt-6.1-sol request_id=req_… 4834ms input=3611 cached=3456 (96%) output=11 cache_overall=48%
276
+ POST /v1/chat/completions 200 model=gpt-6.1-sol request_id=req_… 4834ms input=3611 cached=3456 (96%) output=11
255
277
  ```
256
278
 
257
279
  ## Errors
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "siwc-proxy"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Local OpenAI-compatible API proxy for Sign in with ChatGPT token sharing"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,12 +24,11 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "anyio>=4.4",
27
- "authlib>=1.7.2,<1.8",
27
+ "authlib>=1.8,<1.9",
28
28
  "click>=8.1",
29
29
  "fastapi>=0.115",
30
30
  "filelock>=3.16",
31
- "httpx>=0.28,<0.29",
32
- "httpx-sse>=0.4.3",
31
+ "httpx2>=2.13,<3",
33
32
  "itsdangerous>=2.2",
34
33
  "joserfc>=1.6",
35
34
  "platformdirs>=4.3",
@@ -48,7 +47,7 @@ siwc-proxy = "siwc_proxy.cli:main"
48
47
 
49
48
  [dependency-groups]
50
49
  dev = [
51
- "openai>=2.54,<3",
50
+ "openai>=3,<4",
52
51
  "pytest>=8",
53
52
  ]
54
53
 
@@ -64,4 +63,4 @@ include = ["siwc_proxy", "tests", "docs", "README.md", "LICENSE"]
64
63
 
65
64
  [tool.pytest.ini_options]
66
65
  testpaths = ["tests"]
67
- filterwarnings = ["ignore:Using `httpx` with `starlette.testclient`:UserWarning"]
66
+ filterwarnings = ["error::authlib.deprecate.AuthlibDeprecationWarning"]
@@ -11,7 +11,7 @@ import secrets
11
11
  import time
12
12
 
13
13
  import anyio
14
- import httpx
14
+ import httpx2
15
15
  from authlib.integrations.base_client import OAuthError
16
16
  from authlib.integrations.httpx_client import AsyncOAuth2Client
17
17
  from authlib.integrations.starlette_client import OAuth
@@ -182,7 +182,7 @@ class ChatGPTAuth:
182
182
  except (JoseError, ValueError, KeyError, TypeError) as error:
183
183
  log.warning("Sign-in failed: invalid ID token (%s)", type(error).__name__)
184
184
  raise SignInError("OpenAI returned an identity token that could not be verified.") from None
185
- except httpx.HTTPError as error:
185
+ except httpx2.HTTPError as error:
186
186
  log.warning("Sign-in failed: %s", type(error).__name__)
187
187
  raise SignInError("Could not reach OpenAI to finish sign-in. Start again.") from None
188
188
  claims = token.pop("userinfo", None)
@@ -262,9 +262,9 @@ class ChatGPTAuth:
262
262
  account = record.account
263
263
  try:
264
264
  metadata = await self.server_metadata()
265
- except (httpx.HTTPError, ValueError) as error:
265
+ except (httpx2.HTTPError, ValueError) as error:
266
266
  log.warning("Refresh failed: discovery %s", type(error).__name__)
267
- raise refresh_failed(isinstance(error, httpx.TimeoutException)) from None
267
+ raise refresh_failed(isinstance(error, httpx2.TimeoutException)) from None
268
268
  previous_scope, issued = account.scope, {}
269
269
 
270
270
  async def persist(token, refresh_token=None, access_token=None):
@@ -293,9 +293,9 @@ class ChatGPTAuth:
293
293
  "in again.", status=502, code="chatgpt_client_rejected") from None
294
294
  log.warning("Refresh failed: %s", error.error)
295
295
  raise refresh_failed() from None
296
- except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
296
+ except (httpx2.HTTPError, ValueError, KeyError, TypeError) as error:
297
297
  log.warning("Refresh failed: %s", type(error).__name__)
298
- raise refresh_failed(isinstance(error, httpx.TimeoutException)) from None
298
+ raise refresh_failed(isinstance(error, httpx2.TimeoutException)) from None
299
299
  log.info("Refreshed the ChatGPT access token.")
300
300
  if issued.get("id_token"):
301
301
  await self._check_identity(record, issued)
@@ -305,7 +305,7 @@ class ChatGPTAuth:
305
305
  try:
306
306
  claims = await self.remote(account.client_id).parse_id_token(
307
307
  token, nonce=None, claims_options=self.claims_options(account.client_id), leeway=LEEWAY)
308
- except (JoseError, ValueError, KeyError, TypeError, httpx.HTTPError) as error:
308
+ except (JoseError, ValueError, KeyError, TypeError, httpx2.HTTPError) as error:
309
309
  log.warning("Could not verify the refreshed ID token (%s); keeping tokens.", type(error).__name__)
310
310
  return
311
311
  if account.subject and claims["sub"] != account.subject:
@@ -351,7 +351,7 @@ class ChatGPTAuth:
351
351
  if response.status_code < 500:
352
352
  log.warning("OpenAI rejected token revocation (HTTP %s).", response.status_code)
353
353
  return False
354
- except httpx.TransportError as error:
354
+ except httpx2.TransportError as error:
355
355
  log.warning("Revocation attempt %s failed: %s", attempt, type(error).__name__)
356
356
  if attempt < attempts:
357
357
  await anyio.sleep(backoff(attempt))
@@ -13,7 +13,7 @@ from urllib.parse import parse_qs, urlsplit
13
13
 
14
14
  import anyio
15
15
  import click
16
- import httpx
16
+ import httpx2
17
17
  import uvicorn
18
18
 
19
19
  from .auth import ChatGPTAuth, SignInError
@@ -48,22 +48,22 @@ def make_settings(ctx, **overrides):
48
48
 
49
49
  def configure_logging(level):
50
50
  logging.basicConfig(level=level.upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s")
51
- # httpx logs every outbound request at INFO; keep that for --log-level debug.
52
- logging.getLogger("httpx").setLevel(logging.DEBUG if level == "debug" else logging.WARNING)
51
+ # httpx2 logs every outbound request at INFO; keep that for --log-level debug.
52
+ logging.getLogger("httpx2").setLevel(logging.DEBUG if level == "debug" else logging.WARNING)
53
53
  # Their debug output includes request headers and token responses.
54
54
  for name in ("authlib", "httpcore"):
55
55
  logging.getLogger(name).setLevel(logging.WARNING)
56
56
 
57
57
 
58
58
  def running_proxy(settings, api_key):
59
- """An httpx client for a proxy already serving this data directory, or None.
59
+ """An httpx2 client for a proxy already serving this data directory, or None.
60
60
 
61
61
  The key is sent only after the server proves it knows it, so another program on the port never sees it.
62
62
  """
63
63
  from .server import healthz_proof
64
64
 
65
65
  challenge = secrets.token_hex(16)
66
- client = httpx.Client(base_url=settings.origin, trust_env=False, timeout=10)
66
+ client = httpx2.Client(base_url=settings.origin, trust_env=False, timeout=10)
67
67
  try:
68
68
  response = client.get("/healthz", params={"challenge": challenge})
69
69
  proof = response.json().get("proof") if response.status_code == 200 else None
@@ -72,7 +72,7 @@ def running_proxy(settings, api_key):
72
72
  response = client.get("/auth/status")
73
73
  if response.status_code == 200:
74
74
  return client
75
- except (httpx.HTTPError, ValueError, AttributeError):
75
+ except (httpx2.HTTPError, ValueError, AttributeError):
76
76
  pass
77
77
  client.close()
78
78
  return None
@@ -226,7 +226,7 @@ def remote_login(settings, consent, port):
226
226
  url = anyio.run(lambda: auth.pasted_sign_in_url(session, consent))
227
227
  except CredentialFileError as error:
228
228
  fail(error)
229
- except (httpx.HTTPError, ValueError) as error:
229
+ except (httpx2.HTTPError, ValueError) as error:
230
230
  raise click.ClickException(f"Could not reach OpenAI to start sign-in ({type(error).__name__}).") from None
231
231
  click.echo(f"Open this link in a browser on any computer and sign in:\n\n{click.style(url, fg='cyan', bold=True)}\n\n"
232
232
  f"The browser then goes to {settings.redirect_uri}?code=... and fails to load it. That is expected:\n"
@@ -27,19 +27,24 @@ def system_to_developer(item):
27
27
  return item
28
28
 
29
29
 
30
- def cache_session(body, secret):
31
- """Remove `prompt_cache_key` from an upstream body and return the `session_id` header to send instead.
30
+ def cache_session(body, secret, supplied=None):
31
+ """Return the `session-id` header for an upstream body and set its `prompt_cache_key` to the same value.
32
32
 
33
- ChatGPT sign-in keys its prompt cache on the `session_id` header and replaces `prompt_cache_key` with a
34
- random value, so without a session every turn of a conversation misses the cache. A caller's own key is
35
- used when given; otherwise the session comes from the model, instructions and first input item, which stay
36
- the same as a conversation grows. Values are keyed hashes so conversation text never leaves as an ID.
33
+ ChatGPT sign-in derives prompt cache affinity from the `session-id` header and sets the prompt cache key
34
+ from it, so without a session every turn of a conversation misses the cache. Like Codex, the body carries
35
+ the same value as `prompt_cache_key`, the API's official field, in case upstream starts honoring it.
36
+ A caller's own `session-id` is used as is, and a caller's own key names the session; otherwise the session
37
+ comes from the model, instructions and first input item, which stay the same as a conversation grows.
38
+ Derived values are keyed hashes so conversation text never leaves as an ID.
37
39
  """
38
- key = body.pop("prompt_cache_key", None)
39
- if not isinstance(key, str) or not key:
40
- first = body["input"][0] if isinstance(body.get("input"), list) and body["input"] else None
41
- key = json.dumps([body.get("model"), body.get("instructions"), first], sort_keys=True)
42
- return hmac.new(secret, key.encode(), hashlib.sha256).hexdigest()[:32]
40
+ if not supplied:
41
+ key = body.get("prompt_cache_key")
42
+ if not isinstance(key, str) or not key:
43
+ first = body["input"][0] if isinstance(body.get("input"), list) and body["input"] else None
44
+ key = json.dumps([body.get("model"), body.get("instructions"), first], sort_keys=True)
45
+ supplied = hmac.new(secret, key.encode(), hashlib.sha256).hexdigest()[:32]
46
+ body["prompt_cache_key"] = supplied
47
+ return supplied
43
48
 
44
49
 
45
50
  def normalize(body, *, strict=False):
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from html import escape
11
11
 
12
12
  import anyio
13
- import httpx
13
+ import httpx2
14
14
  from fastapi import FastAPI, Request
15
15
  from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response, StreamingResponse
16
16
  from starlette.datastructures import Headers
@@ -54,12 +54,20 @@ def printable(text, limit=200):
54
54
  return "".join(c if c.isprintable() else "?" for c in text[:limit])
55
55
 
56
56
 
57
+ def usage(counts):
58
+ """Token counts and the prompt cache hit rate (cached share of input tokens) for the access log."""
59
+ if not counts:
60
+ return ""
61
+ input_tokens, cached, output = counts
62
+ rate = f" ({cached / input_tokens:.0%})" if input_tokens else ""
63
+ return f" input={input_tokens} cached={cached}{rate} output={output}"
64
+
65
+
57
66
  class Boundary:
58
67
  """Host and bearer-key checks before any body is read, no-store responses, and access logs."""
59
68
 
60
69
  def __init__(self, app, settings, api_key):
61
70
  self.app, self.settings, self.key = app, settings, api_key.encode()
62
- self.input_tokens = self.cached_tokens = 0 # Since startup, for the overall prompt cache hit rate.
63
71
 
64
72
  async def __call__(self, scope, receive, send):
65
73
  if scope["type"] != "http":
@@ -96,18 +104,7 @@ class Boundary:
96
104
  log.info("%s %s %s%s%s %.0fms%s", scope["method"], path, seen.get("status", "-"),
97
105
  f" model={printable(model)}" if isinstance(model, str) else "",
98
106
  f" request_id={printable(seen['request_id'])}" if seen.get("request_id") else "",
99
- (time.perf_counter() - started) * 1000, self.usage(scope.get("state", {}).get("usage")))
100
-
101
- def usage(self, counts):
102
- """Token counts and prompt cache hit rates (cached share of input tokens) for the access log."""
103
- if not counts:
104
- return ""
105
- input_tokens, cached, output = counts
106
- self.input_tokens += input_tokens
107
- self.cached_tokens += cached
108
- rate = f" ({cached / input_tokens:.0%})" if input_tokens else ""
109
- overall = f" cache_overall={self.cached_tokens / self.input_tokens:.0%}" if self.input_tokens else ""
110
- return f" input={input_tokens} cached={cached}{rate} output={output}{overall}"
107
+ (time.perf_counter() - started) * 1000, usage(scope.get("state", {}).get("usage")))
111
108
 
112
109
 
113
110
  class Relayed(Exception):
@@ -160,7 +157,7 @@ def model_entry(model):
160
157
 
161
158
 
162
159
  def network_error(error):
163
- if isinstance(error, httpx.TimeoutException):
160
+ if isinstance(error, httpx2.TimeoutException):
164
161
  return OpenAIError("OpenAI request timed out.", status=504, code="upstream_timeout")
165
162
  return OpenAIError("Could not reach OpenAI.", status=502, code="upstream_connection_error")
166
163
 
@@ -202,10 +199,10 @@ def create_app(settings=None, *, transport=None, api_key=None):
202
199
  async def lifespan(app):
203
200
  app.state.active = set()
204
201
  auth.serving, auth.signed_in = True, anyio.Event()
205
- timeout = httpx.Timeout(30, read=settings.read_timeout)
202
+ timeout = httpx2.Timeout(30, read=settings.read_timeout)
206
203
  # The transport retries connection failures, before any request is sent.
207
- upstream_transport = transport or httpx.AsyncHTTPTransport(retries=MAX_RETRIES)
208
- async with httpx.AsyncClient(transport=upstream_transport, trust_env=False, follow_redirects=False,
204
+ upstream_transport = transport or httpx2.AsyncHTTPTransport(retries=MAX_RETRIES)
205
+ async with httpx2.AsyncClient(transport=upstream_transport, trust_env=False, follow_redirects=False,
209
206
  timeout=timeout) as client:
210
207
  app.state.upstream = client
211
208
  try:
@@ -238,7 +235,7 @@ def create_app(settings=None, *, transport=None, api_key=None):
238
235
  async def relayed(request, error):
239
236
  return error.response
240
237
 
241
- @app.exception_handler(httpx.HTTPError)
238
+ @app.exception_handler(httpx2.HTTPError)
242
239
  async def http_error(request, error):
243
240
  return network_error(error).response()
244
241
 
@@ -268,9 +265,9 @@ def create_app(settings=None, *, transport=None, api_key=None):
268
265
  headers["Accept-Encoding"] = "identity"
269
266
  content = None
270
267
  if body is not None:
271
- session = responses_api.cache_session(body, session_secret)
268
+ session = responses_api.cache_session(body, session_secret, request.headers.get("session-id"))
272
269
  headers.update({"Content-Type": "application/json", "Accept": "text/event-stream",
273
- "session_id": request.headers.get("session_id") or session})
270
+ "session-id": session})
274
271
  content = json.dumps(body).encode()
275
272
  token, refreshed, attempt = await auth.access_token(), False, 0
276
273
  while True:
@@ -425,7 +422,7 @@ def create_app(settings=None, *, transport=None, api_key=None):
425
422
  request.state.usage = token_usage(result)
426
423
  except OpenAIError as error:
427
424
  return error.response(headers)
428
- except httpx.HTTPError as error:
425
+ except httpx2.HTTPError as error:
429
426
  return network_error(error).response(headers)
430
427
  finally:
431
428
  await close(response)
@@ -468,7 +465,7 @@ def create_app(settings=None, *, transport=None, api_key=None):
468
465
  yield b"data: [DONE]\n\n"
469
466
  except OpenAIError as error:
470
467
  yield encode_sse(error.payload)
471
- except httpx.HTTPError as error:
468
+ except httpx2.HTTPError as error:
472
469
  yield encode_sse(network_error(error).payload)
473
470
  return UpstreamStreamingResponse(sse(), headers=headers, media_type="text/event-stream",
474
471
  upstream=response, active=app.state.active)
@@ -478,7 +475,7 @@ def create_app(settings=None, *, transport=None, api_key=None):
478
475
  return JSONResponse(state.completion(), headers=headers)
479
476
  except OpenAIError as error:
480
477
  return error.response(headers)
481
- except httpx.HTTPError as error:
478
+ except httpx2.HTTPError as error:
482
479
  return network_error(error).response(headers)
483
480
 
484
481
  return app
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  import re
5
5
 
6
- from httpx_sse import EventSource, SSEError
6
+ from httpx2 import EventSource, SSEError
7
7
 
8
8
  from .errors import protocol_error
9
9
 
@@ -11,7 +11,8 @@ from .errors import protocol_error
11
11
  async def response_events(response):
12
12
  """Responses events from an upstream SSE body; events without data (keep-alives) are skipped."""
13
13
  try:
14
- async for sse in EventSource(response).aiter_sse():
14
+ # No event size cap: terminal events repeat the whole answer, which may exceed httpx2's 1 MiB default.
15
+ async for sse in EventSource(response, max_event_size=None):
15
16
  if not sse.data:
16
17
  continue
17
18
  try:
@@ -7,7 +7,7 @@ import time
7
7
  from contextlib import contextmanager
8
8
  from urllib.parse import parse_qs
9
9
 
10
- import httpx
10
+ import httpx2
11
11
  import uvicorn
12
12
  from fastapi.testclient import TestClient
13
13
  from joserfc import jwt
@@ -74,8 +74,8 @@ def parse_chunks(response):
74
74
  if line.startswith("data: ") and line != "data: [DONE]"]
75
75
 
76
76
 
77
- class Chunks(httpx.AsyncByteStream):
78
- """A streamed body (unlike content=, which httpx pre-reads) that records being closed."""
77
+ class Chunks(httpx2.AsyncByteStream):
78
+ """A streamed body (unlike content=, which httpx2 pre-reads) that records being closed."""
79
79
 
80
80
  def __init__(self, data, size=7):
81
81
  self.data, self.size, self.closed = data, size, False
@@ -122,30 +122,30 @@ class FakeOpenAI:
122
122
 
123
123
  def __init__(self):
124
124
  self.calls, self.token_calls, self.revocations = [], [], []
125
- self.api = lambda request: httpx.Response(
125
+ self.api = lambda request: httpx2.Response(
126
126
  200, headers={"content-type": "text/event-stream", "x-request-id": "req-test"},
127
127
  stream=Chunks(sse(text_events())))
128
- self.token = None # optional override: callable(form) -> httpx.Response
129
- self.revoke = lambda form: httpx.Response(200)
128
+ self.token = None # optional override: callable(form) -> httpx2.Response
129
+ self.revoke = lambda form: httpx2.Response(200)
130
130
  self.codes = {} # code -> (client_id, nonce, scope, sub)
131
131
  self.rotation = 0
132
132
  self.token_delay = 0
133
133
 
134
134
  @property
135
135
  def transport(self):
136
- return httpx.MockTransport(self.handle)
136
+ return httpx2.MockTransport(self.handle)
137
137
 
138
138
  async def handle(self, request):
139
139
  self.calls.append(request)
140
140
  url = str(request.url.copy_with(query=None))
141
141
  if url == ISSUER + "/.well-known/openid-configuration":
142
- return httpx.Response(200, json={
142
+ return httpx2.Response(200, json={
143
143
  "issuer": ISSUER, "authorization_endpoint": ISSUER + "/oauth/authorize",
144
144
  "token_endpoint": ISSUER + "/oauth/token", "jwks_uri": ISSUER + "/jwks",
145
145
  "revocation_endpoint": ISSUER + "/oauth/revoke",
146
146
  "id_token_signing_alg_values_supported": ["RS256"]})
147
147
  if url == ISSUER + "/jwks":
148
- return httpx.Response(200, json=KeySet([SIGNING_KEY]).as_dict(private=False))
148
+ return httpx2.Response(200, json=KeySet([SIGNING_KEY]).as_dict(private=False))
149
149
  form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
150
150
  if url == ISSUER + "/oauth/token":
151
151
  self.token_calls.append(form)
@@ -167,11 +167,11 @@ class FakeOpenAI:
167
167
  if form["grant_type"] == "authorization_code":
168
168
  client_id, nonce, scope, sub = self.codes[form["code"]]
169
169
  assert form["client_id"] == client_id
170
- return httpx.Response(200, json={
170
+ return httpx2.Response(200, json={
171
171
  "access_token": "new-access", "refresh_token": "new-refresh", "token_type": "Bearer",
172
172
  "expires_in": 3600, "scope": scope, "id_token": id_token(client_id, sub, nonce)})
173
173
  self.rotation += 1
174
- return httpx.Response(200, json={
174
+ return httpx2.Response(200, json={
175
175
  "access_token": f"refreshed-{self.rotation}", "refresh_token": f"rotated-{self.rotation}",
176
176
  "token_type": "Bearer", "expires_in": 3600})
177
177
 
@@ -8,7 +8,7 @@ import time
8
8
  from urllib.parse import parse_qs, urlsplit
9
9
 
10
10
  import click
11
- import httpx
11
+ import httpx2
12
12
  import pytest
13
13
  from click.testing import CliRunner
14
14
  from fastapi.responses import JSONResponse
@@ -69,7 +69,7 @@ def test_standalone_login_serves_its_own_callback(tmp_path, monkeypatch):
69
69
 
70
70
  def browser(url, open_browser):
71
71
  def act():
72
- with httpx.Client(trust_env=False, timeout=5) as client:
72
+ with httpx2.Client(trust_env=False, timeout=5) as client:
73
73
  response = client.get(url, follow_redirects=False)
74
74
  params = {k: v[0] for k, v in parse_qs(urlsplit(response.headers["location"]).query).items()}
75
75
  fake.codes["c"] = ("oaiapp_cli", params["nonce"], "openid " + "chatgpt.tokens.use.direct", "sub-1")
@@ -122,12 +122,12 @@ def test_key_is_not_sent_to_an_impostor_on_the_port(tmp_path):
122
122
  def test_login_refuses_an_unexpected_link_from_the_proxy(tmp_path, monkeypatch):
123
123
  def proxy(request):
124
124
  if request.url.path == "/auth/status":
125
- return httpx.Response(200, json={"state": "signed_out"})
126
- return httpx.Response(200, json={"url": "file:///etc/passwd"})
125
+ return httpx2.Response(200, json={"state": "signed_out"})
126
+ return httpx2.Response(200, json={"url": "file:///etc/passwd"})
127
127
 
128
128
  opened = []
129
- monkeypatch.setattr(cli, "running_proxy", lambda settings, key: httpx.Client(
130
- base_url=settings.origin, transport=httpx.MockTransport(proxy)))
129
+ monkeypatch.setattr(cli, "running_proxy", lambda settings, key: httpx2.Client(
130
+ base_url=settings.origin, transport=httpx2.MockTransport(proxy)))
131
131
  monkeypatch.setattr(cli, "open_link", lambda url, browser: opened.append(url))
132
132
  result = CliRunner().invoke(cli.main, ["--data-dir", str(tmp_path), "login", "--no-browser"])
133
133
  assert result.exit_code == 1 and "unexpected sign-in link" in result.output and not opened
@@ -1,6 +1,6 @@
1
1
  """The stock openai SDK, used the way frameworks use it, against the proxy."""
2
2
 
3
- import httpx
3
+ import httpx2
4
4
  import openai
5
5
  import pytest
6
6
  from openai import AsyncOpenAI, OpenAI
@@ -40,7 +40,7 @@ def realistic_events():
40
40
 
41
41
  def test_responses_streaming_and_helper(sdk):
42
42
  sdk, fake = sdk
43
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=Chunks(sse(realistic_events())))
43
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=Chunks(sse(realistic_events())))
44
44
  events = list(sdk.responses.create(model="test-model", input="hi", stream=True))
45
45
  assert events[-1].type == "response.completed"
46
46
  with sdk.responses.stream(model="test-model", input="hi") as stream:
@@ -61,7 +61,7 @@ def test_chat_with_framework_defaults(sdk):
61
61
 
62
62
  def test_chat_stream_helper_accumulates_tool_arguments(sdk):
63
63
  sdk, fake = sdk
64
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=Chunks(sse(tool_events())))
64
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=Chunks(sse(tool_events())))
65
65
  with sdk.chat.completions.stream(**BODY) as stream:
66
66
  list(stream)
67
67
  message = stream.get_final_completion().choices[0].message
@@ -72,7 +72,7 @@ def test_chat_stream_helper_accumulates_tool_arguments(sdk):
72
72
  @pytest.mark.parametrize("endpoint", ["chat", "responses"])
73
73
  def test_plan_limit_is_a_rate_limit_error(sdk, endpoint):
74
74
  sdk, fake = sdk
75
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=Chunks(sse([{"type": "response.failed", "response": terminal(
75
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=Chunks(sse([{"type": "response.failed", "response": terminal(
76
76
  status="failed", error={"message": "Limit", "code": "subscription_sharing_usage_limit_exceeded"})}])))
77
77
  with pytest.raises(openai.RateLimitError) as caught:
78
78
  if endpoint == "chat":
@@ -84,7 +84,7 @@ def test_plan_limit_is_a_rate_limit_error(sdk, endpoint):
84
84
 
85
85
  def test_admission_detail_error(sdk):
86
86
  sdk, fake = sdk
87
- fake.api = lambda r: httpx.Response(403, json={"detail": {"message": "Not eligible",
87
+ fake.api = lambda r: httpx2.Response(403, json={"detail": {"message": "Not eligible",
88
88
  "code": "subscription_sharing_user_not_eligible"}})
89
89
  with pytest.raises(openai.PermissionDeniedError) as caught:
90
90
  sdk.chat.completions.create(**BODY)
@@ -96,7 +96,7 @@ def test_unknown_route_and_models(sdk):
96
96
  sdk, fake = sdk
97
97
  with pytest.raises(openai.NotFoundError):
98
98
  sdk.embeddings.create(model="x", input="hi")
99
- fake.api = lambda r: httpx.Response(200, json={"models": [{"slug": "test-model", "visibility": "list"}]})
99
+ fake.api = lambda r: httpx2.Response(200, json={"models": [{"slug": "test-model", "visibility": "list"}]})
100
100
  assert [m.id for m in sdk.models.list()] == ["test-model"]
101
101
  assert sdk.models.retrieve("test-model").id == "test-model"
102
102
  with pytest.raises(openai.NotFoundError):
@@ -118,10 +118,10 @@ def test_async_sdk(directory, fake):
118
118
  app = create_app(Settings(data_dir=directory, api_key=KEY), transport=fake.transport)
119
119
  async with app.router.lifespan_context(app):
120
120
  async with AsyncOpenAI(api_key=KEY, base_url=BASE + "/v1", max_retries=0,
121
- http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app))) as sdk:
121
+ http_client=httpx2.AsyncClient(transport=httpx2.ASGITransport(app))) as sdk:
122
122
  response = await sdk.responses.create(model="test-model", input="hi")
123
123
  assert response.output_text == "Hi 世界"
124
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=Chunks(sse(text_events())))
124
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=Chunks(sse(text_events())))
125
125
  stream = await sdk.chat.completions.create(**BODY, stream=True)
126
126
  text = "".join([c.choices[0].delta.content or "" async for c in stream if c.choices])
127
127
  assert text == "Hi 世界"
@@ -4,7 +4,7 @@ import time
4
4
  from urllib.parse import parse_qs, urlsplit
5
5
 
6
6
  import anyio
7
- import httpx
7
+ import httpx2
8
8
  import pytest
9
9
 
10
10
  from siwc_proxy.auth import ChatGPTAuth
@@ -126,7 +126,7 @@ def test_callback_validation(tmp_path, fake, tamper):
126
126
  value = id_token("oaiapp_issued", nonce=params["nonce"], **claims)
127
127
  if tamper == "signature":
128
128
  value = value[:-4] + ("AAAA" if not value.endswith("AAAA") else "BBBB")
129
- return httpx.Response(200, json={"access_token": "a", "refresh_token": "r", "expires_in": 60,
129
+ return httpx2.Response(200, json={"access_token": "a", "refresh_token": "r", "expires_in": 60,
130
130
  "id_token": value})
131
131
  fake.token = token
132
132
  page = client.get("/auth/callback", params=query)
@@ -181,7 +181,7 @@ def test_logout_revokes_and_keeps_registration(directory, fake):
181
181
 
182
182
  def test_logout_retries_server_errors_then_gives_up_locally(directory, fake, monkeypatch):
183
183
  monkeypatch.setattr("siwc_proxy.auth.backoff", lambda attempt: 0)
184
- fake.revoke = lambda form: httpx.Response(503)
184
+ fake.revoke = lambda form: httpx2.Response(503)
185
185
  auth = ChatGPTAuth(Settings(data_dir=directory, api_key=KEY), Store(directory), transport=fake.transport)
186
186
  assert anyio.run(auth.logout) is False
187
187
  assert len(fake.revocations) == 4
@@ -7,7 +7,7 @@ import socket
7
7
  import threading
8
8
 
9
9
  import anyio
10
- import httpx
10
+ import httpx2
11
11
  import pytest
12
12
  from fastapi.testclient import TestClient
13
13
 
@@ -20,7 +20,7 @@ from support import (BASE, BODY, HEADERS, KEY, SSE_TYPE, TEXT_ITEM, Chunks, asse
20
20
 
21
21
 
22
22
  def streamed(events, **headers):
23
- return lambda request: httpx.Response(200, headers={**SSE_TYPE, **headers},
23
+ return lambda request: httpx2.Response(200, headers={**SSE_TYPE, **headers},
24
24
  stream=Chunks(sse(events)))
25
25
 
26
26
 
@@ -73,7 +73,7 @@ def test_lenient_chat_reports_ignored_params(directory, fake):
73
73
  @pytest.mark.parametrize("streaming", [False, True])
74
74
  def test_interleaved_tools_have_dense_indexes_and_no_duplicates(directory, fake, streaming):
75
75
  stream = Chunks(sse(tool_events()), size=1)
76
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=stream)
76
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=stream)
77
77
  with running(directory, fake) as (client, *_):
78
78
  response = client.post("/v1/chat/completions", json={**BODY, "stream": streaming})
79
79
  if streaming:
@@ -127,7 +127,7 @@ def test_incomplete_and_refusal(directory, fake, reason, finish, streaming):
127
127
  @pytest.mark.parametrize("streaming", [False, True])
128
128
  def test_stream_failures_never_report_success(directory, fake, bad, streaming):
129
129
  stream = Chunks(bad)
130
- fake.api = lambda r: httpx.Response(200, headers={**SSE_TYPE, "x-request-id": "req-failed"}, stream=stream)
130
+ fake.api = lambda r: httpx2.Response(200, headers={**SSE_TYPE, "x-request-id": "req-failed"}, stream=stream)
131
131
  with running(directory, fake) as (client, fake, app):
132
132
  response = client.post("/v1/chat/completions", json={**BODY, "stream": streaming})
133
133
  assert response.headers["x-request-id"] == "req-failed"
@@ -161,7 +161,7 @@ def test_failed_responses_keep_their_meaning(directory, fake, code, status, endp
161
161
 
162
162
 
163
163
  def test_admission_usage_limit_is_not_retried_by_sdks(directory, fake):
164
- fake.api = lambda r: httpx.Response(429, json={"error": {
164
+ fake.api = lambda r: httpx2.Response(429, json={"error": {
165
165
  "message": "Limit", "type": "rate_limit_error", "code": "subscription_sharing_usage_limit_exceeded"}})
166
166
  with running(directory, fake) as (client, *_):
167
167
  response = client.post("/v1/responses", json={"model": "m", "input": "Hi"})
@@ -180,7 +180,7 @@ def test_responses_drop_rejected_params_and_system_items(directory, fake):
180
180
  assert sent["input"][0] == {**system, "role": "developer"}
181
181
  assert sent["input"][1] == {"role": "system", "content": "x"} # accepted as is
182
182
  assert "max_output_tokens" in sent and "temperature" not in sent
183
- assert "prompt_cache_key" not in sent # Replaced upstream; the session_id header carries it instead.
183
+ assert sent["prompt_cache_key"] == fake.calls[-1].headers["session-id"] != "k" # Hashed into the session.
184
184
  with running(directory, fake, strict=True) as (client, *_):
185
185
  response = client.post("/v1/responses", json=body)
186
186
  assert response.status_code == 400 and response.json()["error"]["param"] == "temperature"
@@ -194,9 +194,11 @@ def test_turns_of_a_conversation_share_a_cache_session(directory, fake):
194
194
  edited = [system, {"role": "user", "content": "Edited"}] + turn2[2:]
195
195
  other = [{"role": "system", "content": "Different prompt"}, {"role": "user", "content": "Hi"}]
196
196
  with running(directory, fake) as (client, fake, _):
197
- def session(path, body, **headers):
197
+ def session(path, body, headers=None):
198
198
  client.post(path, json=body, headers=headers)
199
- return fake.calls[-1].headers["session_id"]
199
+ sent = fake.calls[-1]
200
+ assert json.loads(sent.content)["prompt_cache_key"] == sent.headers["session-id"]
201
+ return sent.headers["session-id"]
200
202
 
201
203
  chat = [session("/v1/chat/completions", {"model": "m", "messages": m}) for m in (turn1, turn2, edited, other)]
202
204
  assert chat[0] == chat[1] == chat[2] != chat[3] # Edits after the first item keep the session.
@@ -208,10 +210,9 @@ def test_turns_of_a_conversation_share_a_cache_session(directory, fake):
208
210
  keyed = [session("/v1/chat/completions", {"model": "m", "messages": m, "prompt_cache_key": "conv-1"})
209
211
  for m in (turn1, other)]
210
212
  assert keyed[0] == keyed[1] != chat[0]
211
- assert "prompt_cache_key" not in json.loads(fake.calls[-1].content)
212
- assert session("/v1/responses", {"model": "m", "input": turn1}, session_id="mine") == "mine"
213
+ assert session("/v1/responses", {"model": "m", "input": turn1}, {"session-id": "mine"}) == "mine"
213
214
  client.get("/v1/models")
214
- assert "session_id" not in fake.calls[-1].headers
215
+ assert "session-id" not in fake.calls[-1].headers
215
216
 
216
217
 
217
218
  def test_multiline_sse_comments_crlf_and_bom(directory, fake):
@@ -219,7 +220,7 @@ def test_multiline_sse_comments_crlf_and_bom(directory, fake):
219
220
  split = event.index('"response":')
220
221
  raw = (": ping\r\nevent: response.completed\r\ndata: " + event[:split] + "\r\ndata: " + event[split:]
221
222
  + "\r\n\r\n").encode()
222
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=Chunks(raw, size=1))
223
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=Chunks(raw, size=1))
223
224
  with running(directory, fake) as (client, *_):
224
225
  response = client.post("/v1/chat/completions", json=BODY)
225
226
  assert response.status_code == 200
@@ -238,7 +239,7 @@ def test_responses_normalized_and_aggregated(directory, fake):
238
239
  assert response.headers["x-siwc-ignored-params"] == "store"
239
240
  sent = json.loads(fake.calls[-1].content)
240
241
  assert sent == {"model": "test-model", "input": [{"role": "user", "content": "Hi"}],
241
- "store": False, "stream": True}
242
+ "store": False, "stream": True, "prompt_cache_key": fake.calls[-1].headers["session-id"]}
242
243
  assert fake.calls[-1].url.query == b"" # only the models catalog takes query parameters
243
244
  assert fake.calls[-1].headers["openai-beta"] == "test=v1"
244
245
 
@@ -265,7 +266,7 @@ def empty_terminal_events():
265
266
  def test_relayed_stream_terminal_gets_output(directory, fake):
266
267
  events, item = empty_terminal_events()
267
268
  raw = sse(events).replace(b"\n", b"\r\n")
268
- fake.api = lambda r: httpx.Response(200, stream=Chunks(gzip.compress(raw), size=5), headers={
269
+ fake.api = lambda r: httpx2.Response(200, stream=Chunks(gzip.compress(raw), size=5), headers={
269
270
  "content-encoding": "gzip", "content-length": "999", "content-type": "text/event-stream"})
270
271
  with running(directory, fake) as (client, *_):
271
272
  response = client.post("/v1/responses", json={"model": "m", "input": "Hi", "stream": True})
@@ -323,7 +324,7 @@ def test_responses_stream_is_relayed_unchanged(directory, fake):
323
324
  raw = sse(text_events()) + b'event: new.event\ndata: {"unknown":true}\n\n'
324
325
  compressed = gzip.compress(raw)
325
326
  stream = Chunks(compressed)
326
- fake.api = lambda r: httpx.Response(200, stream=stream, headers={
327
+ fake.api = lambda r: httpx2.Response(200, stream=stream, headers={
327
328
  "content-encoding": "gzip", "content-type": "text/event-stream", "connection": "keep-alive, x-hop",
328
329
  "x-hop": "remove", "set-cookie": "a=1"})
329
330
  with running(directory, fake) as (client, *_):
@@ -338,7 +339,7 @@ def test_responses_stream_is_relayed_unchanged(directory, fake):
338
339
  ("/v1/responses", {"model": "m", "input": "Hi"}),
339
340
  ("/v1/responses", {"model": "m", "input": "Hi", "stream": True})])
340
341
  def test_stream_without_content_type(directory, fake, path, body):
341
- fake.api = lambda r: httpx.Response(200, stream=Chunks(sse(text_events())))
342
+ fake.api = lambda r: httpx2.Response(200, stream=Chunks(sse(text_events())))
342
343
  with running(directory, fake) as (client, *_):
343
344
  response = client.post(path, json=body)
344
345
  assert response.status_code == 200
@@ -366,7 +367,7 @@ def test_responses_unsupported_features(directory, fake, body, param):
366
367
  @pytest.mark.parametrize("endpoint", ["chat/completions", "responses"])
367
368
  def test_upstream_errors_preserved(directory, fake, status, endpoint):
368
369
  body = b'{"error":{"message":"Denied","code":"permission_denied"}}'
369
- fake.api = lambda r: httpx.Response(status, content=body, headers={
370
+ fake.api = lambda r: httpx2.Response(status, content=body, headers={
370
371
  "x-request-id": "req-denied", "retry-after": "2", "x-ratelimit-remaining-tokens": "100"})
371
372
  with running(directory, fake) as (client, fake, app):
372
373
  response = client.post("/v1/" + endpoint, json=BODY if endpoint.startswith("chat") else {"model": "m", "input": "x"})
@@ -383,7 +384,7 @@ def test_upstream_errors_preserved(directory, fake, status, endpoint):
383
384
  (b"<html>gateway</html>", "OpenAI returned HTTP 429.", None),
384
385
  ])
385
386
  def test_detail_bodies_become_openai_errors(directory, fake, body, message, code):
386
- fake.api = lambda r: httpx.Response(429, content=body)
387
+ fake.api = lambda r: httpx2.Response(429, content=body)
387
388
  with running(directory, fake) as (client, *_):
388
389
  response = client.post("/v1/chat/completions", json=BODY)
389
390
  assert response.status_code == 429
@@ -392,7 +393,7 @@ def test_detail_bodies_become_openai_errors(directory, fake, body, message, code
392
393
 
393
394
  def test_retryable_admission_503_is_retried(directory, fake, monkeypatch):
394
395
  monkeypatch.setattr("siwc_proxy.server.backoff", lambda attempt: 0)
395
- answers = iter([httpx.Response(503, json={"detail": {"code": "subscription_sharing_usage_unavailable"}},
396
+ answers = iter([httpx2.Response(503, json={"detail": {"code": "subscription_sharing_usage_unavailable"}},
396
397
  headers={"retry-after": "0"})])
397
398
  fake.api = lambda r: next(answers, None) or streamed(text_events())(r)
398
399
  with running(directory, fake) as (client, fake, _):
@@ -401,14 +402,14 @@ def test_retryable_admission_503_is_retried(directory, fake, monkeypatch):
401
402
 
402
403
 
403
404
  def test_other_503_is_not_retried(directory, fake):
404
- fake.api = lambda r: httpx.Response(503, json={"error": {"message": "Down", "code": "overloaded"}})
405
+ fake.api = lambda r: httpx2.Response(503, json={"error": {"message": "Down", "code": "overloaded"}})
405
406
  with running(directory, fake) as (client, fake, _):
406
407
  assert client.post("/v1/chat/completions", json=BODY).status_code == 503
407
408
  assert len(fake.calls) == 1
408
409
 
409
410
 
410
- @pytest.mark.parametrize("error,status", [(httpx.ConnectError("secret network detail"), 502),
411
- (httpx.ReadTimeout("secret timeout detail"), 504)])
411
+ @pytest.mark.parametrize("error,status", [(httpx2.ConnectError("secret network detail"), 502),
412
+ (httpx2.ReadTimeout("secret timeout detail"), 504)])
412
413
  def test_network_errors_are_sanitized(directory, fake, error, status):
413
414
  def fail(request):
414
415
  raise error
@@ -424,10 +425,10 @@ def test_network_error_after_stream_start(directory, fake, streaming):
424
425
  class Broken(Chunks):
425
426
  async def __aiter__(self):
426
427
  yield sse(text_events()[:4])
427
- raise httpx.ReadTimeout("private provider detail")
428
+ raise httpx2.ReadTimeout("private provider detail")
428
429
 
429
430
  broken = Broken(b"")
430
- fake.api = lambda r: httpx.Response(200, headers=SSE_TYPE, stream=broken)
431
+ fake.api = lambda r: httpx2.Response(200, headers=SSE_TYPE, stream=broken)
431
432
  with running(directory, fake) as (client, *_):
432
433
  response = client.post("/v1/chat/completions", json={**BODY, "stream": streaming})
433
434
  if streaming:
@@ -482,7 +483,7 @@ def test_unknown_routes_are_openai_shaped(directory, fake):
482
483
  # Models
483
484
 
484
485
  def test_models_catalog_and_retrieve(directory, fake):
485
- fake.api = lambda r: httpx.Response(200, json={"models": [
486
+ fake.api = lambda r: httpx2.Response(200, json={"models": [
486
487
  {"slug": "test-model", "display_name": "Test", "visibility": "list"},
487
488
  {"slug": "hidden", "visibility": "hidden"}]})
488
489
  with running(directory, fake) as (client, fake, _):
@@ -496,7 +497,7 @@ def test_models_catalog_and_retrieve(directory, fake):
496
497
 
497
498
 
498
499
  def test_models_query_is_forwarded(directory, fake):
499
- fake.api = lambda r: httpx.Response(200, json={"models": [{"slug": "m", "visibility": "list"}]})
500
+ fake.api = lambda r: httpx2.Response(200, json={"models": [{"slug": "m", "visibility": "list"}]})
500
501
  with running(directory, fake) as (client, fake, _):
501
502
  assert client.get("/v1/models?client_version=1&x=a%20b").status_code == 200
502
503
  assert fake.calls[-1].url.query == b"client_version=1&x=a%20b"
@@ -504,7 +505,7 @@ def test_models_query_is_forwarded(directory, fake):
504
505
 
505
506
 
506
507
  def test_models_are_slimmed(directory, fake):
507
- fake.api = lambda r: httpx.Response(200, json={"models": [{
508
+ fake.api = lambda r: httpx2.Response(200, json={"models": [{
508
509
  "slug": "big", "visibility": "list", "display_name": "Big", "context_window": 1000,
509
510
  "supported_reasoning_levels": [{"effort": "low", "description": "fast"}, {"effort": "high"}],
510
511
  "base_instructions": "x" * 50_000, "shell_type": "default"}]})
@@ -514,7 +515,7 @@ def test_models_are_slimmed(directory, fake):
514
515
  "context_window": 1000, "supported_reasoning_levels": ["low", "high"]}]
515
516
 
516
517
  def test_invalid_catalog(directory, fake):
517
- fake.api = lambda r: httpx.Response(200, json={"models": "nope"})
518
+ fake.api = lambda r: httpx2.Response(200, json={"models": "nope"})
518
519
  with running(directory, fake) as (client, *_):
519
520
  assert client.get("/v1/models").status_code == 502
520
521
 
@@ -586,8 +587,8 @@ def test_access_log_reports_prompt_cache_hits(directory, fake, caplog, path, bod
586
587
  fake.api = streamed(text_events()[:-1]) # no terminal response, so no usage
587
588
  client.post(path, json=body)
588
589
  lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("POST /v1/")]
589
- assert lines[0].endswith(" input=10 cached=2 (20%) output=4 cache_overall=20%")
590
- assert lines[1].endswith(" input=10 cached=8 (80%) output=4 cache_overall=50%")
590
+ assert lines[0].endswith(" input=10 cached=2 (20%) output=4")
591
+ assert lines[1].endswith(" input=10 cached=8 (80%) output=4")
591
592
  assert "cached=" not in lines[2]
592
593
 
593
594
 
@@ -624,7 +625,7 @@ def test_corrupt_credentials(directory, fake):
624
625
  def test_real_http_streaming_and_disconnect_cleanup(directory, fake, endpoint, disconnect):
625
626
  gate, closed = threading.Event(), threading.Event()
626
627
 
627
- class Delayed(httpx.AsyncByteStream):
628
+ class Delayed(httpx2.AsyncByteStream):
628
629
  async def __aiter__(self):
629
630
  yield sse(text_events()[:4])
630
631
  while not gate.is_set():
@@ -637,12 +638,12 @@ def test_real_http_streaming_and_disconnect_cleanup(directory, fake, endpoint, d
637
638
  sock = socket.socket()
638
639
  sock.bind(("127.0.0.1", 0))
639
640
  port = sock.getsockname()[1]
640
- fake.api = lambda r: httpx.Response(200, stream=Delayed(), headers={"content-type": "text/event-stream"})
641
+ fake.api = lambda r: httpx2.Response(200, stream=Delayed(), headers={"content-type": "text/event-stream"})
641
642
  app = create_app(Settings(data_dir=directory, api_key=KEY, port=port), transport=fake.transport)
642
643
  body = {**BODY, "stream": True} if endpoint.startswith("chat") else {"model": "m", "input": "Hi", "stream": True}
643
644
  try:
644
645
  with live_server(app, sock):
645
- with httpx.Client(timeout=2, trust_env=False) as client:
646
+ with httpx2.Client(timeout=2, trust_env=False) as client:
646
647
  with client.stream("POST", f"http://127.0.0.1:{port}/v1/{endpoint}", headers=HEADERS,
647
648
  json=body) as response:
648
649
  assert response.status_code == 200
@@ -7,7 +7,7 @@ import time
7
7
  from pathlib import Path
8
8
 
9
9
  import anyio
10
- import httpx
10
+ import httpx2
11
11
  import pytest
12
12
 
13
13
  from siwc_proxy.auth import ChatGPTAuth
@@ -50,12 +50,12 @@ def test_valid_token_is_not_refreshed(directory, fake):
50
50
 
51
51
  def test_concurrent_requests_refresh_once(expired, fake):
52
52
  fake.token_delay = 0.1
53
- fake.api = lambda r: httpx.Response(200, json={"object": "list", "data": []})
53
+ fake.api = lambda r: httpx2.Response(200, json={"object": "list", "data": []})
54
54
 
55
55
  async def exercise():
56
56
  app = create_app(Settings(data_dir=expired, api_key=KEY), transport=fake.transport)
57
57
  async with app.router.lifespan_context(app):
58
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url=BASE, headers=HEADERS) as client:
58
+ async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app), base_url=BASE, headers=HEADERS) as client:
59
59
  results = await asyncio_gather([client.get("/v1/models?n=" + str(i)) for i in range(8)])
60
60
  assert all(r.status_code == 200 for r in results)
61
61
  assert {c.headers["authorization"] for c in fake.calls if c.url.host == "api.openai.com"} == {
@@ -91,7 +91,7 @@ def test_separate_stores_share_the_file_lock(expired, fake):
91
91
  def test_processes_refresh_once(expired, tmp_path):
92
92
  log = tmp_path / "refreshes.log"
93
93
  script = textwrap.dedent(f"""
94
- import sys, time, anyio, httpx
94
+ import sys, time, anyio, httpx2
95
95
  sys.path.insert(0, {str(Path(__file__).parent)!r})
96
96
  from support import FakeOpenAI
97
97
  from siwc_proxy.auth import ChatGPTAuth
@@ -104,7 +104,7 @@ def test_processes_refresh_once(expired, tmp_path):
104
104
  f.write(form["refresh_token"] + "\\n")
105
105
  time.sleep(0.3)
106
106
  stamp = str(time.monotonic_ns())
107
- return httpx.Response(200, json={{"access_token": "a-" + stamp, "refresh_token": "r-" + stamp,
107
+ return httpx2.Response(200, json={{"access_token": "a-" + stamp, "refresh_token": "r-" + stamp,
108
108
  "expires_in": 3600}})
109
109
  fake.token = token
110
110
  directory = {str(expired)!r}
@@ -120,10 +120,10 @@ def test_processes_refresh_once(expired, tmp_path):
120
120
 
121
121
 
122
122
  @pytest.mark.parametrize("answer", [
123
- lambda form: httpx.Response(500, text="<html>oops</html>"),
124
- lambda form: httpx.Response(503, json={"error": "temporarily_unavailable"}),
125
- lambda form: httpx.Response(400, json={"error": "invalid_request"}),
126
- lambda form: httpx.Response(200, json={"token_type": "Bearer"}),
123
+ lambda form: httpx2.Response(500, text="<html>oops</html>"),
124
+ lambda form: httpx2.Response(503, json={"error": "temporarily_unavailable"}),
125
+ lambda form: httpx2.Response(400, json={"error": "invalid_request"}),
126
+ lambda form: httpx2.Response(200, json={"token_type": "Bearer"}),
127
127
  ])
128
128
  def test_transient_failures_keep_credentials(expired, fake, answer):
129
129
  before = (expired / "credentials.json").read_bytes()
@@ -138,7 +138,7 @@ def test_network_failure_keeps_credentials(expired, fake):
138
138
  before = (expired / "credentials.json").read_bytes()
139
139
 
140
140
  def fail(form):
141
- raise httpx.ReadTimeout("secret")
141
+ raise httpx2.ReadTimeout("secret")
142
142
  fake.token = fail
143
143
  with pytest.raises(OpenAIError) as caught:
144
144
  anyio.run(lambda: auth_for(expired, fake).access_token())
@@ -148,7 +148,7 @@ def test_network_failure_keeps_credentials(expired, fake):
148
148
 
149
149
  def test_invalid_client_keeps_credentials(expired, fake):
150
150
  before = (expired / "credentials.json").read_bytes()
151
- fake.token = lambda form: httpx.Response(401, json={"error": "invalid_client"})
151
+ fake.token = lambda form: httpx2.Response(401, json={"error": "invalid_client"})
152
152
  with pytest.raises(OpenAIError) as caught:
153
153
  anyio.run(lambda: auth_for(expired, fake).access_token())
154
154
  assert caught.value.status == 502 and caught.value.code == "chatgpt_client_rejected"
@@ -158,7 +158,7 @@ def test_invalid_client_keeps_credentials(expired, fake):
158
158
  @pytest.mark.parametrize("code", ["invalid_grant", "refresh_token_reused", "refresh_token_expired",
159
159
  "refresh_token_invalidated", "invalid_refresh_token", "token_expired"])
160
160
  def test_terminal_refresh_errors_clear_tokens_but_keep_registration(expired, fake, code):
161
- fake.token = lambda form: httpx.Response(400, json={"error": code})
161
+ fake.token = lambda form: httpx2.Response(400, json={"error": code})
162
162
  with pytest.raises(OpenAIError) as caught:
163
163
  anyio.run(lambda: auth_for(expired, fake).access_token())
164
164
  assert caught.value.status == 401 and caught.value.code == "chatgpt_login_required"
@@ -171,14 +171,14 @@ def test_terminal_refresh_errors_clear_tokens_but_keep_registration(expired, fak
171
171
 
172
172
 
173
173
  def test_unverifiable_id_token_after_rotation_keeps_new_tokens(expired, fake):
174
- fake.token = lambda form: httpx.Response(200, json={
174
+ fake.token = lambda form: httpx2.Response(200, json={
175
175
  "access_token": "new-a", "refresh_token": "new-r", "expires_in": 3600, "id_token": "not.a.jwt"})
176
176
  assert anyio.run(lambda: auth_for(expired, fake).access_token()) == "new-a"
177
177
  assert saved(expired)["account"]["refresh_token"] == "new-r"
178
178
 
179
179
 
180
180
  def test_identity_change_on_refresh_clears_tokens(expired, fake):
181
- fake.token = lambda form: httpx.Response(200, json={
181
+ fake.token = lambda form: httpx2.Response(200, json={
182
182
  "access_token": "new-a", "refresh_token": "new-r", "expires_in": 3600,
183
183
  "id_token": id_token("oaiapp_registered", sub="someone-else")})
184
184
  with pytest.raises(OpenAIError) as caught:
@@ -200,7 +200,7 @@ def test_earliest_refresh_at_is_respected(tmp_path, fake):
200
200
 
201
201
  def test_upstream_401_refreshes_and_retries_once(directory, fake):
202
202
  statuses = iter([401, 200])
203
- fake.api = lambda r: httpx.Response(next(statuses), json={"object": "list", "data": []})
203
+ fake.api = lambda r: httpx2.Response(next(statuses), json={"object": "list", "data": []})
204
204
  with running(directory, fake) as (client, fake, _):
205
205
  assert client.get("/v1/models").status_code == 200
206
206
  api = [c for c in fake.calls if c.url.host == "api.openai.com"]
@@ -209,7 +209,7 @@ def test_upstream_401_refreshes_and_retries_once(directory, fake):
209
209
 
210
210
 
211
211
  def test_second_upstream_401_is_relayed(directory, fake):
212
- fake.api = lambda r: httpx.Response(401, json={"error": {"message": "Bad token", "code": "invalid_token"}})
212
+ fake.api = lambda r: httpx2.Response(401, json={"error": {"message": "Bad token", "code": "invalid_token"}})
213
213
  with running(directory, fake) as (client, fake, _):
214
214
  response = client.post("/v1/chat/completions", json=BODY)
215
215
  assert response.status_code == 401 and response.json()["error"]["code"] == "invalid_token"
File without changes
File without changes
File without changes
File without changes
File without changes