codex-relay-server 0.1.0__py3-none-any.whl
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.
- codex_relay_server/__init__.py +17 -0
- codex_relay_server/__main__.py +5 -0
- codex_relay_server/auth.py +39 -0
- codex_relay_server/cli.py +78 -0
- codex_relay_server/config.py +107 -0
- codex_relay_server/relay.py +180 -0
- codex_relay_server-0.1.0.dist-info/METADATA +185 -0
- codex_relay_server-0.1.0.dist-info/RECORD +12 -0
- codex_relay_server-0.1.0.dist-info/WHEEL +5 -0
- codex_relay_server-0.1.0.dist-info/entry_points.txt +2 -0
- codex_relay_server-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_relay_server-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""OpenAI-compatible LAN relay server."""
|
|
2
|
+
|
|
3
|
+
from .config import AuthConfig, AuthMode, RelayConfig, ServerConfig, Settings, load_settings
|
|
4
|
+
from .relay import create_app
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthConfig",
|
|
10
|
+
"AuthMode",
|
|
11
|
+
"RelayConfig",
|
|
12
|
+
"ServerConfig",
|
|
13
|
+
"Settings",
|
|
14
|
+
"__version__",
|
|
15
|
+
"create_app",
|
|
16
|
+
"load_settings",
|
|
17
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException, status
|
|
4
|
+
|
|
5
|
+
from .config import AuthConfig, AuthMode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_authorization_header(incoming_authorization: str | None, config: AuthConfig) -> str | None:
|
|
9
|
+
if config.mode == AuthMode.DIRECT:
|
|
10
|
+
return incoming_authorization
|
|
11
|
+
|
|
12
|
+
client_key = extract_client_key(incoming_authorization)
|
|
13
|
+
upstream_secret = config.key_map.get(client_key or "") if client_key else None
|
|
14
|
+
if upstream_secret is None:
|
|
15
|
+
upstream_secret = config.default_key
|
|
16
|
+
|
|
17
|
+
if upstream_secret is None:
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
20
|
+
detail="No upstream API key is available for this client key.",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return f"Bearer {upstream_secret.get_secret_value()}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_client_key(authorization_header: str | None) -> str | None:
|
|
27
|
+
if authorization_header is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
authorization_header = authorization_header.strip()
|
|
31
|
+
if not authorization_header:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
scheme, separator, value = authorization_header.partition(" ")
|
|
35
|
+
if separator and scheme.lower() == "bearer":
|
|
36
|
+
value = value.strip()
|
|
37
|
+
return value or None
|
|
38
|
+
|
|
39
|
+
return authorization_header
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from .config import load_settings
|
|
10
|
+
from .relay import create_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Run a LAN OpenAI-compatible relay server.")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"-c",
|
|
17
|
+
"--config",
|
|
18
|
+
type=Path,
|
|
19
|
+
default=None,
|
|
20
|
+
help="Path to a JSON config file.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--host", help="Override server.host, for example 0.0.0.0 or a LAN IP.")
|
|
23
|
+
parser.add_argument("--port", type=int, help="Override server.port.")
|
|
24
|
+
parser.add_argument("--upstream-url", help="Override relay.upstream_url.")
|
|
25
|
+
parser.add_argument("--local-base-path", help="Override relay.local_base_path.")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--strip-local-base-path",
|
|
28
|
+
type=parse_bool,
|
|
29
|
+
help="Override relay.strip_local_base_path with true or false.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--auth-mode", choices=("direct", "transform"), help="Override auth.mode.")
|
|
32
|
+
parser.add_argument("--log-level", help="Override server.log_level.")
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
parser = build_parser()
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
settings = load_settings(args.config)
|
|
41
|
+
updates = {}
|
|
42
|
+
if args.host is not None:
|
|
43
|
+
updates.setdefault("server", {})["host"] = args.host
|
|
44
|
+
if args.port is not None:
|
|
45
|
+
updates.setdefault("server", {})["port"] = args.port
|
|
46
|
+
if args.log_level is not None:
|
|
47
|
+
updates.setdefault("server", {})["log_level"] = args.log_level
|
|
48
|
+
if args.upstream_url is not None:
|
|
49
|
+
updates.setdefault("relay", {})["upstream_url"] = args.upstream_url
|
|
50
|
+
if args.local_base_path is not None:
|
|
51
|
+
updates.setdefault("relay", {})["local_base_path"] = args.local_base_path
|
|
52
|
+
if args.strip_local_base_path is not None:
|
|
53
|
+
updates.setdefault("relay", {})["strip_local_base_path"] = args.strip_local_base_path
|
|
54
|
+
if args.auth_mode is not None:
|
|
55
|
+
updates.setdefault("auth", {})["mode"] = args.auth_mode
|
|
56
|
+
if updates:
|
|
57
|
+
settings = type(settings).model_validate(_deep_update(settings.model_dump(mode="python"), updates))
|
|
58
|
+
|
|
59
|
+
app = create_app(settings)
|
|
60
|
+
uvicorn.run(app, host=settings.server.host, port=settings.server.port, log_level=settings.server.log_level)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_bool(value: str) -> bool:
|
|
64
|
+
normalized = value.strip().lower()
|
|
65
|
+
if normalized in {"1", "true", "yes", "y", "on"}:
|
|
66
|
+
return True
|
|
67
|
+
if normalized in {"0", "false", "no", "n", "off"}:
|
|
68
|
+
return False
|
|
69
|
+
raise argparse.ArgumentTypeError(f"invalid boolean value: {value!r}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deep_update(original: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
for key, value in updates.items():
|
|
74
|
+
if isinstance(value, dict) and isinstance(original.get(key), dict):
|
|
75
|
+
original[key] = _deep_update(original[key], value)
|
|
76
|
+
else:
|
|
77
|
+
original[key] = value
|
|
78
|
+
return original
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthMode(StrEnum):
|
|
14
|
+
DIRECT = "direct"
|
|
15
|
+
TRANSFORM = "transform"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerConfig(BaseModel):
|
|
19
|
+
host: str = "127.0.0.1"
|
|
20
|
+
port: int = 8000
|
|
21
|
+
log_level: str = "info"
|
|
22
|
+
|
|
23
|
+
@field_validator("port")
|
|
24
|
+
@classmethod
|
|
25
|
+
def validate_port(cls, value: int) -> int:
|
|
26
|
+
if not 1 <= value <= 65535:
|
|
27
|
+
raise ValueError("port must be between 1 and 65535")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RelayConfig(BaseModel):
|
|
32
|
+
upstream_url: str = "https://api.openai.com/v1"
|
|
33
|
+
local_base_path: str = "/"
|
|
34
|
+
strip_local_base_path: bool = True
|
|
35
|
+
timeout_seconds: float = 300
|
|
36
|
+
verify_ssl: bool = True
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="before")
|
|
39
|
+
@classmethod
|
|
40
|
+
def migrate_old_base_path_names(cls, data: Any) -> Any:
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
if "local_base_path" not in data and "public_base_path" in data:
|
|
43
|
+
data["local_base_path"] = data["public_base_path"]
|
|
44
|
+
if "strip_local_base_path" not in data and "strip_public_base_path" in data:
|
|
45
|
+
data["strip_local_base_path"] = data["strip_public_base_path"]
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
@field_validator("upstream_url")
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_upstream_url(cls, value: str) -> str:
|
|
51
|
+
value = value.strip()
|
|
52
|
+
if not value:
|
|
53
|
+
raise ValueError("upstream_url is required")
|
|
54
|
+
if not value.startswith(("http://", "https://")):
|
|
55
|
+
raise ValueError("upstream_url must start with http:// or https://")
|
|
56
|
+
return value.rstrip("/")
|
|
57
|
+
|
|
58
|
+
@field_validator("local_base_path")
|
|
59
|
+
@classmethod
|
|
60
|
+
def validate_local_base_path(cls, value: str) -> str:
|
|
61
|
+
value = value.strip()
|
|
62
|
+
if value in {"", "/"}:
|
|
63
|
+
return "/"
|
|
64
|
+
if not value.startswith("/"):
|
|
65
|
+
value = f"/{value}"
|
|
66
|
+
return value.rstrip("/")
|
|
67
|
+
|
|
68
|
+
@field_validator("timeout_seconds")
|
|
69
|
+
@classmethod
|
|
70
|
+
def validate_timeout(cls, value: float) -> float:
|
|
71
|
+
if value <= 0:
|
|
72
|
+
raise ValueError("timeout_seconds must be greater than 0")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AuthConfig(BaseModel):
|
|
77
|
+
mode: AuthMode = AuthMode.DIRECT
|
|
78
|
+
key_map: dict[str, SecretStr] = Field(default_factory=dict)
|
|
79
|
+
default_key: SecretStr | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Settings(BaseModel):
|
|
83
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
84
|
+
relay: RelayConfig = Field(default_factory=RelayConfig)
|
|
85
|
+
auth: AuthConfig = Field(default_factory=AuthConfig)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
ConfigInput = Settings | Mapping[str, Any] | str | Path | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_settings(config: ConfigInput = None) -> Settings:
|
|
92
|
+
raw: dict[str, Any] = {}
|
|
93
|
+
if isinstance(config, Settings):
|
|
94
|
+
raw = config.model_dump(mode="python")
|
|
95
|
+
elif isinstance(config, Mapping):
|
|
96
|
+
raw = deepcopy(dict(config))
|
|
97
|
+
elif config:
|
|
98
|
+
path = Path(config)
|
|
99
|
+
if not path.exists():
|
|
100
|
+
raise FileNotFoundError(f"config file not found: {path}")
|
|
101
|
+
with path.open("r", encoding="utf-8") as file:
|
|
102
|
+
loaded = json.load(file)
|
|
103
|
+
if not isinstance(loaded, dict):
|
|
104
|
+
raise ValueError("config file must contain a JSON object")
|
|
105
|
+
raw = loaded
|
|
106
|
+
|
|
107
|
+
return Settings.model_validate(raw)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
8
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
9
|
+
from starlette.background import BackgroundTask
|
|
10
|
+
|
|
11
|
+
from .auth import resolve_authorization_header
|
|
12
|
+
from .config import Settings
|
|
13
|
+
|
|
14
|
+
HOP_BY_HOP_HEADERS = {
|
|
15
|
+
"connection",
|
|
16
|
+
"keep-alive",
|
|
17
|
+
"proxy-authenticate",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"te",
|
|
20
|
+
"trailer",
|
|
21
|
+
"transfer-encoding",
|
|
22
|
+
"upgrade",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
REQUEST_HEADERS_TO_DROP = HOP_BY_HOP_HEADERS | {
|
|
26
|
+
"host",
|
|
27
|
+
"content-length",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
RESPONSE_HEADERS_TO_DROP = HOP_BY_HOP_HEADERS
|
|
31
|
+
|
|
32
|
+
ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_app(settings: Settings, transport: httpx.AsyncBaseTransport | None = None) -> FastAPI:
|
|
36
|
+
client = httpx.AsyncClient(
|
|
37
|
+
timeout=httpx.Timeout(settings.relay.timeout_seconds),
|
|
38
|
+
verify=settings.relay.verify_ssl,
|
|
39
|
+
transport=transport,
|
|
40
|
+
follow_redirects=False,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
45
|
+
yield
|
|
46
|
+
await client.aclose()
|
|
47
|
+
|
|
48
|
+
app = FastAPI(
|
|
49
|
+
title="Codex Relay Server",
|
|
50
|
+
version="0.1.0",
|
|
51
|
+
docs_url=None,
|
|
52
|
+
redoc_url=None,
|
|
53
|
+
openapi_url=None,
|
|
54
|
+
lifespan=lifespan,
|
|
55
|
+
)
|
|
56
|
+
app.state.settings = settings
|
|
57
|
+
app.state.http_client = client
|
|
58
|
+
|
|
59
|
+
@app.get("/healthz")
|
|
60
|
+
async def healthz() -> dict[str, str]:
|
|
61
|
+
return {"status": "ok"}
|
|
62
|
+
|
|
63
|
+
@app.api_route("/{path:path}", methods=ALL_METHODS, response_model=None)
|
|
64
|
+
async def relay_request(path: str, request: Request) -> Response:
|
|
65
|
+
try:
|
|
66
|
+
return await forward_request(request, path, settings, client)
|
|
67
|
+
except HTTPException:
|
|
68
|
+
raise
|
|
69
|
+
except httpx.TimeoutException:
|
|
70
|
+
return JSONResponse({"detail": "Upstream request timed out."}, status_code=504)
|
|
71
|
+
except httpx.TransportError as exc:
|
|
72
|
+
return JSONResponse({"detail": f"Upstream request failed: {exc.__class__.__name__}"}, status_code=502)
|
|
73
|
+
|
|
74
|
+
return app
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def forward_request(
|
|
78
|
+
request: Request,
|
|
79
|
+
incoming_path: str,
|
|
80
|
+
settings: Settings,
|
|
81
|
+
client: httpx.AsyncClient,
|
|
82
|
+
) -> Response:
|
|
83
|
+
target_url = build_upstream_url(
|
|
84
|
+
settings.relay.upstream_url,
|
|
85
|
+
incoming_path,
|
|
86
|
+
request.url.query,
|
|
87
|
+
settings.relay.local_base_path,
|
|
88
|
+
settings.relay.strip_local_base_path,
|
|
89
|
+
)
|
|
90
|
+
headers = build_upstream_headers(request.headers.items(), settings)
|
|
91
|
+
body = await request.body()
|
|
92
|
+
|
|
93
|
+
upstream_request = client.build_request(
|
|
94
|
+
request.method,
|
|
95
|
+
target_url,
|
|
96
|
+
headers=headers,
|
|
97
|
+
content=body,
|
|
98
|
+
)
|
|
99
|
+
upstream_response = await client.send(upstream_request, stream=True)
|
|
100
|
+
response_headers = filter_response_headers(upstream_response.headers.items())
|
|
101
|
+
|
|
102
|
+
if upstream_response.is_stream_consumed:
|
|
103
|
+
return Response(
|
|
104
|
+
content=upstream_response.content,
|
|
105
|
+
status_code=upstream_response.status_code,
|
|
106
|
+
headers=response_headers,
|
|
107
|
+
media_type=upstream_response.headers.get("content-type"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return StreamingResponse(
|
|
111
|
+
upstream_response.aiter_raw(),
|
|
112
|
+
status_code=upstream_response.status_code,
|
|
113
|
+
headers=response_headers,
|
|
114
|
+
background=BackgroundTask(upstream_response.aclose),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_upstream_url(
|
|
119
|
+
upstream_url: str,
|
|
120
|
+
incoming_path: str,
|
|
121
|
+
query_string: str | bytes,
|
|
122
|
+
local_base_path: str,
|
|
123
|
+
strip_local_base_path: bool,
|
|
124
|
+
) -> str:
|
|
125
|
+
suffix = incoming_path.strip("/")
|
|
126
|
+
|
|
127
|
+
validate_local_path(incoming_path, local_base_path)
|
|
128
|
+
|
|
129
|
+
if strip_local_base_path and local_base_path:
|
|
130
|
+
normalized_path = f"/{suffix}" if suffix else "/"
|
|
131
|
+
prefix = local_base_path.rstrip("/")
|
|
132
|
+
if normalized_path == prefix:
|
|
133
|
+
suffix = ""
|
|
134
|
+
elif normalized_path.startswith(f"{prefix}/"):
|
|
135
|
+
suffix = normalized_path[len(prefix) :].lstrip("/")
|
|
136
|
+
|
|
137
|
+
target = upstream_url.rstrip("/")
|
|
138
|
+
if suffix:
|
|
139
|
+
target = f"{target}/{suffix}"
|
|
140
|
+
|
|
141
|
+
if query_string:
|
|
142
|
+
query = query_string.decode("utf-8") if isinstance(query_string, bytes) else query_string
|
|
143
|
+
target = f"{target}?{query}"
|
|
144
|
+
|
|
145
|
+
return target
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def validate_local_path(incoming_path: str, local_base_path: str) -> None:
|
|
149
|
+
if not local_base_path:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
normalized_path = f"/{incoming_path.strip('/')}" if incoming_path.strip("/") else "/"
|
|
153
|
+
prefix = local_base_path.rstrip("/")
|
|
154
|
+
if normalized_path == prefix or normalized_path.startswith(f"{prefix}/"):
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
raise HTTPException(status_code=404, detail="This path is outside the configured local base path.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_upstream_headers(incoming_headers: Iterable[tuple[str, str]], settings: Settings) -> dict[str, str]:
|
|
161
|
+
headers = {
|
|
162
|
+
key: value
|
|
163
|
+
for key, value in incoming_headers
|
|
164
|
+
if key.lower() not in REQUEST_HEADERS_TO_DROP
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
authorization = resolve_authorization_header(headers.get("authorization"), settings.auth)
|
|
168
|
+
headers.pop("authorization", None)
|
|
169
|
+
if authorization:
|
|
170
|
+
headers["authorization"] = authorization
|
|
171
|
+
|
|
172
|
+
return headers
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def filter_response_headers(incoming_headers: Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
176
|
+
return {
|
|
177
|
+
key: value
|
|
178
|
+
for key, value in incoming_headers
|
|
179
|
+
if key.lower() not in RESPONSE_HEADERS_TO_DROP
|
|
180
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-relay-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A LAN OpenAI-compatible relay server.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: pydantic>=2.7
|
|
12
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# codex-relay-server
|
|
19
|
+
|
|
20
|
+
A LAN relay server for OpenAI-compatible HTTP APIs.
|
|
21
|
+
|
|
22
|
+
Use this when one computer can reach the upstream model provider, but other computers on the LAN can only reach that computer. LAN clients point their OpenAI SDKs at this relay; the relay forwards requests to your configured upstream API root and streams the upstream response back.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- Bind to any local IP and port, such as `0.0.0.0:8000` or a specific LAN adapter IP.
|
|
27
|
+
- Use any upstream API root URL. The upstream does not need to use `/v1`.
|
|
28
|
+
- Configure the local client-facing base path, such as `/`, `/v1`, `/openai`, or `/api/openai`.
|
|
29
|
+
- Forward all endpoints with the same path rules, including `/chat/completions`, `/responses`, `/models`, and future API paths.
|
|
30
|
+
- Pass through normal JSON responses and streaming SSE responses.
|
|
31
|
+
- Support two API key modes:
|
|
32
|
+
- `direct`: forward the incoming `Authorization` header unchanged.
|
|
33
|
+
- `transform`: map client-visible keys to upstream keys, with an optional default upstream key.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
.\venv\Scripts\python.exe -m pip install -e ".[dev]"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Copy the example config:
|
|
44
|
+
|
|
45
|
+
```powershell
|
|
46
|
+
Copy-Item config.example.json config.json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Edit `config.json`:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"server": {
|
|
54
|
+
"host": "0.0.0.0",
|
|
55
|
+
"port": 8000
|
|
56
|
+
},
|
|
57
|
+
"relay": {
|
|
58
|
+
"upstream_url": "https://your-upstream.example/custom-api",
|
|
59
|
+
"local_base_path": "/",
|
|
60
|
+
"strip_local_base_path": true
|
|
61
|
+
},
|
|
62
|
+
"auth": {
|
|
63
|
+
"mode": "direct"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
With that config, an incoming LAN request to `/responses` is forwarded to:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
https://your-upstream.example/custom-api/responses
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The default `local_base_path` is `/`, so the relay accepts requests from the root path. Set `local_base_path` explicitly if you want clients to use a specific base URL such as `/v1` or `/openai`.
|
|
75
|
+
|
|
76
|
+
Set `strip_local_base_path` to `false` if you want the full local path appended to the upstream URL.
|
|
77
|
+
|
|
78
|
+
You can expose any local base path:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"relay": {
|
|
83
|
+
"upstream_url": "https://your-upstream.example/not-v1",
|
|
84
|
+
"local_base_path": "/openai",
|
|
85
|
+
"strip_local_base_path": true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
LAN clients can then use this base URL:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
http://<relay-lan-ip>:8000/openai
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Transform Mode
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"auth": {
|
|
101
|
+
"mode": "transform",
|
|
102
|
+
"key_map": {
|
|
103
|
+
"client-visible-key-a": "real-upstream-key-a",
|
|
104
|
+
"client-visible-key-b": "real-upstream-key-b"
|
|
105
|
+
},
|
|
106
|
+
"default_key": "real-default-upstream-key"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
In transform mode, if the client sends:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
Authorization: Bearer client-visible-key-a
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The upstream receives:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Authorization: Bearer real-upstream-key-a
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
If the client key is not in `key_map` and `default_key` is configured, the relay uses `default_key`. If no default key is available, the relay returns `401`.
|
|
124
|
+
|
|
125
|
+
Keep real upstream keys in local `config.json` or Python-provided configuration. Do not commit them. This repository ignores `config.json`, `*.local.json`, and `*.secrets.json`.
|
|
126
|
+
|
|
127
|
+
### Python Configuration
|
|
128
|
+
|
|
129
|
+
You can also provide configuration from Python code instead of a JSON file:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from codex_relay_server import create_app, load_settings
|
|
133
|
+
|
|
134
|
+
settings = load_settings(
|
|
135
|
+
{
|
|
136
|
+
"server": {"host": "0.0.0.0", "port": 8000},
|
|
137
|
+
"relay": {
|
|
138
|
+
"upstream_url": "https://your-upstream.example/custom-api",
|
|
139
|
+
"local_base_path": "/openai",
|
|
140
|
+
"strip_local_base_path": True,
|
|
141
|
+
},
|
|
142
|
+
"auth": {"mode": "direct"},
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
app = create_app(settings)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Run
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
.\venv\Scripts\python.exe -m codex_relay_server --config config.json
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can temporarily override the bind address, port, upstream URL, and local base path:
|
|
156
|
+
|
|
157
|
+
```powershell
|
|
158
|
+
.\venv\Scripts\python.exe -m codex_relay_server `
|
|
159
|
+
--config config.json `
|
|
160
|
+
--host 0.0.0.0 `
|
|
161
|
+
--port 8000 `
|
|
162
|
+
--upstream-url https://your-upstream.example/custom-api `
|
|
163
|
+
--local-base-path /openai
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
LAN client settings:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
base_url = http://<relay-lan-ip>:8000/<your-local-base-path>
|
|
170
|
+
api_key = <client-key>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
```powershell
|
|
176
|
+
.\venv\Scripts\python.exe -m pytest
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Tests use a local mock upstream and do not need a real API key.
|
|
180
|
+
|
|
181
|
+
## Security Notes
|
|
182
|
+
|
|
183
|
+
- Do not expose this service to the public internet, especially in `transform` mode with `default_key` enabled.
|
|
184
|
+
- If you bind to `0.0.0.0`, make sure your firewall only allows trusted LAN clients.
|
|
185
|
+
- The project does not store private keys in tracked files. Put real keys in ignored local config files or Python-provided configuration.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
codex_relay_server/__init__.py,sha256=HHHfABAFGmkGWuqMxz60IO706rx-PLoz9TiVutY9epc,351
|
|
2
|
+
codex_relay_server/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
3
|
+
codex_relay_server/auth.py,sha256=5jQ59n8eP9S2ahi3JmzL2j7oCKtYODROTo4YkSw4cg0,1235
|
|
4
|
+
codex_relay_server/cli.py,sha256=ztU_Dsfghqg_E0jRpEGaGImCco6R41TVKJn1XOo7Sho,2975
|
|
5
|
+
codex_relay_server/config.py,sha256=wmaWvBCeyRyIMMUcE_d6wC4bDFBdAAaPUC3xB_cgEHY,3394
|
|
6
|
+
codex_relay_server/relay.py,sha256=t2yLPX0c2XAaq7jgawXAoQY-rM57hYg3ISts1jyN0pQ,5467
|
|
7
|
+
codex_relay_server-0.1.0.dist-info/licenses/LICENSE,sha256=gmkEFqkF3KJjRnhXGoLSfX4xXtfPHT1ghq3YgCF7B3w,1086
|
|
8
|
+
codex_relay_server-0.1.0.dist-info/METADATA,sha256=R_3HKoK8H4r5N0BvBmNBnZQPfaHSo3ynzMPC1asHkPE,5164
|
|
9
|
+
codex_relay_server-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
codex_relay_server-0.1.0.dist-info/entry_points.txt,sha256=gztvLIknzFq2EZVDcmSzHpyxxWa2A-4UJFBftvZQ2A4,67
|
|
11
|
+
codex_relay_server-0.1.0.dist-info/top_level.txt,sha256=6kE5Ua6UGVeJNGCHu7Kwi9vjeSTK3wNsd4tGnXelk0U,19
|
|
12
|
+
codex_relay_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGN_2015
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codex_relay_server
|