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.
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/PKG-INFO +3 -4
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/docs/proxy.md +37 -15
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/pyproject.toml +5 -6
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/auth.py +8 -8
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/cli.py +7 -7
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/responses.py +16 -11
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/server.py +21 -24
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/sse.py +3 -2
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/support.py +11 -11
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_cli.py +6 -6
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_compat_sdk.py +8 -8
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_login.py +3 -3
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_proxy.py +34 -33
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/test_tokens.py +16 -16
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/.gitignore +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/LICENSE +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/README.md +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/__init__.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/__main__.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/chat.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/config.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/constants.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/errors.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/siwc_proxy/store.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/conftest.py +0 -0
- {siwc_proxy-0.2.2 → siwc_proxy-0.2.3}/tests/smoke.py +0 -0
- {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.
|
|
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.
|
|
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:
|
|
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
|
|
228
|
-
request header
|
|
229
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
|
|
243
|
-
never sent as an ID; a proxy restart starts new sessions. Usage reports
|
|
244
|
-
`prompt_tokens_details.cached_tokens` (Chat) or
|
|
245
|
-
`input_tokens_details.cached_tokens` (Responses).
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
27
|
+
"authlib>=1.8,<1.9",
|
|
28
28
|
"click>=8.1",
|
|
29
29
|
"fastapi>=0.115",
|
|
30
30
|
"filelock>=3.16",
|
|
31
|
-
"
|
|
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>=
|
|
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 = ["
|
|
66
|
+
filterwarnings = ["error::authlib.deprecate.AuthlibDeprecationWarning"]
|
|
@@ -11,7 +11,7 @@ import secrets
|
|
|
11
11
|
import time
|
|
12
12
|
|
|
13
13
|
import anyio
|
|
14
|
-
import
|
|
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
|
|
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 (
|
|
265
|
+
except (httpx2.HTTPError, ValueError) as error:
|
|
266
266
|
log.warning("Refresh failed: discovery %s", type(error).__name__)
|
|
267
|
-
raise refresh_failed(isinstance(error,
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
#
|
|
52
|
-
logging.getLogger("
|
|
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
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
-
"""
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
|
208
|
-
async with
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
78
|
-
"""A streamed body (unlike content=, which
|
|
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:
|
|
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) ->
|
|
129
|
-
self.revoke = lambda form:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
126
|
-
return
|
|
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:
|
|
130
|
-
base_url=settings.origin, transport=
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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=
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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"
|
|
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,
|
|
197
|
+
def session(path, body, headers=None):
|
|
198
198
|
client.post(path, json=body, headers=headers)
|
|
199
|
-
|
|
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 "
|
|
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 "
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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([
|
|
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:
|
|
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", [(
|
|
411
|
-
(
|
|
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
|
|
428
|
+
raise httpx2.ReadTimeout("private provider detail")
|
|
428
429
|
|
|
429
430
|
broken = Broken(b"")
|
|
430
|
-
fake.api = lambda r:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
590
|
-
assert lines[1].endswith(" input=10 cached=8 (80%) output=4
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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:
|
|
124
|
-
lambda form:
|
|
125
|
-
lambda form:
|
|
126
|
-
lambda form:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|