codex-relay-server 0.1.0__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.
@@ -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,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,168 @@
1
+ # codex-relay-server
2
+
3
+ A LAN relay server for OpenAI-compatible HTTP APIs.
4
+
5
+ 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.
6
+
7
+ ## Features
8
+
9
+ - Bind to any local IP and port, such as `0.0.0.0:8000` or a specific LAN adapter IP.
10
+ - Use any upstream API root URL. The upstream does not need to use `/v1`.
11
+ - Configure the local client-facing base path, such as `/`, `/v1`, `/openai`, or `/api/openai`.
12
+ - Forward all endpoints with the same path rules, including `/chat/completions`, `/responses`, `/models`, and future API paths.
13
+ - Pass through normal JSON responses and streaming SSE responses.
14
+ - Support two API key modes:
15
+ - `direct`: forward the incoming `Authorization` header unchanged.
16
+ - `transform`: map client-visible keys to upstream keys, with an optional default upstream key.
17
+
18
+ ## Installation
19
+
20
+ ```powershell
21
+ .\venv\Scripts\python.exe -m pip install -e ".[dev]"
22
+ ```
23
+
24
+ ## Configuration
25
+
26
+ Copy the example config:
27
+
28
+ ```powershell
29
+ Copy-Item config.example.json config.json
30
+ ```
31
+
32
+ Edit `config.json`:
33
+
34
+ ```json
35
+ {
36
+ "server": {
37
+ "host": "0.0.0.0",
38
+ "port": 8000
39
+ },
40
+ "relay": {
41
+ "upstream_url": "https://your-upstream.example/custom-api",
42
+ "local_base_path": "/",
43
+ "strip_local_base_path": true
44
+ },
45
+ "auth": {
46
+ "mode": "direct"
47
+ }
48
+ }
49
+ ```
50
+
51
+ With that config, an incoming LAN request to `/responses` is forwarded to:
52
+
53
+ ```text
54
+ https://your-upstream.example/custom-api/responses
55
+ ```
56
+
57
+ 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`.
58
+
59
+ Set `strip_local_base_path` to `false` if you want the full local path appended to the upstream URL.
60
+
61
+ You can expose any local base path:
62
+
63
+ ```json
64
+ {
65
+ "relay": {
66
+ "upstream_url": "https://your-upstream.example/not-v1",
67
+ "local_base_path": "/openai",
68
+ "strip_local_base_path": true
69
+ }
70
+ }
71
+ ```
72
+
73
+ LAN clients can then use this base URL:
74
+
75
+ ```text
76
+ http://<relay-lan-ip>:8000/openai
77
+ ```
78
+
79
+ ### Transform Mode
80
+
81
+ ```json
82
+ {
83
+ "auth": {
84
+ "mode": "transform",
85
+ "key_map": {
86
+ "client-visible-key-a": "real-upstream-key-a",
87
+ "client-visible-key-b": "real-upstream-key-b"
88
+ },
89
+ "default_key": "real-default-upstream-key"
90
+ }
91
+ }
92
+ ```
93
+
94
+ In transform mode, if the client sends:
95
+
96
+ ```text
97
+ Authorization: Bearer client-visible-key-a
98
+ ```
99
+
100
+ The upstream receives:
101
+
102
+ ```text
103
+ Authorization: Bearer real-upstream-key-a
104
+ ```
105
+
106
+ 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`.
107
+
108
+ 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`.
109
+
110
+ ### Python Configuration
111
+
112
+ You can also provide configuration from Python code instead of a JSON file:
113
+
114
+ ```python
115
+ from codex_relay_server import create_app, load_settings
116
+
117
+ settings = load_settings(
118
+ {
119
+ "server": {"host": "0.0.0.0", "port": 8000},
120
+ "relay": {
121
+ "upstream_url": "https://your-upstream.example/custom-api",
122
+ "local_base_path": "/openai",
123
+ "strip_local_base_path": True,
124
+ },
125
+ "auth": {"mode": "direct"},
126
+ }
127
+ )
128
+
129
+ app = create_app(settings)
130
+ ```
131
+
132
+ ## Run
133
+
134
+ ```powershell
135
+ .\venv\Scripts\python.exe -m codex_relay_server --config config.json
136
+ ```
137
+
138
+ You can temporarily override the bind address, port, upstream URL, and local base path:
139
+
140
+ ```powershell
141
+ .\venv\Scripts\python.exe -m codex_relay_server `
142
+ --config config.json `
143
+ --host 0.0.0.0 `
144
+ --port 8000 `
145
+ --upstream-url https://your-upstream.example/custom-api `
146
+ --local-base-path /openai
147
+ ```
148
+
149
+ LAN client settings:
150
+
151
+ ```text
152
+ base_url = http://<relay-lan-ip>:8000/<your-local-base-path>
153
+ api_key = <client-key>
154
+ ```
155
+
156
+ ## Tests
157
+
158
+ ```powershell
159
+ .\venv\Scripts\python.exe -m pytest
160
+ ```
161
+
162
+ Tests use a local mock upstream and do not need a real API key.
163
+
164
+ ## Security Notes
165
+
166
+ - Do not expose this service to the public internet, especially in `transform` mode with `default_key` enabled.
167
+ - If you bind to `0.0.0.0`, make sure your firewall only allows trusted LAN clients.
168
+ - 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,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "codex-relay-server"
7
+ version = "0.1.0"
8
+ description = "A LAN OpenAI-compatible relay server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "fastapi>=0.110",
14
+ "httpx>=0.27",
15
+ "pydantic>=2.7",
16
+ "uvicorn[standard]>=0.29",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8",
22
+ "pytest-asyncio>=0.23",
23
+ ]
24
+
25
+ [project.scripts]
26
+ codex-relay-server = "codex_relay_server.cli:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ asyncio_mode = "auto"
34
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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)