codex-proxy 3.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_proxy/plugins.py ADDED
@@ -0,0 +1,110 @@
1
+ """Plugin system — hook-based extensibility for codex-proxy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger("codex-proxy")
11
+
12
+
13
+ @dataclass
14
+ class PluginContext:
15
+ """Safe context passed to plugin hooks."""
16
+
17
+ request_id: str
18
+ method: str # "http" or "ws"
19
+ model: str
20
+ provider: str
21
+ api_key_masked: str
22
+ stream: bool
23
+ status_code: int | None = None
24
+ error: str | None = None
25
+ duration_ms: float | None = None
26
+ metadata: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ class Plugin:
30
+ """Base class for plugins. Override only the hooks you need."""
31
+
32
+ name: str = "unnamed"
33
+
34
+ async def on_startup(self, config: Any) -> None:
35
+ pass
36
+
37
+ async def on_shutdown(self) -> None:
38
+ pass
39
+
40
+ async def on_request(self, ctx: PluginContext) -> PluginContext:
41
+ return ctx
42
+
43
+ async def on_response(self, ctx: PluginContext) -> None:
44
+ pass
45
+
46
+ async def on_error(self, ctx: PluginContext) -> None:
47
+ pass
48
+
49
+
50
+ class PluginRegistry:
51
+ """Loads and manages plugins from dotted module paths."""
52
+
53
+ def __init__(self) -> None:
54
+ self._plugins: list[Plugin] = []
55
+
56
+ def load(self, plugin_paths: list[str]) -> None:
57
+ """Load plugins from dotted module.class paths."""
58
+ for path in plugin_paths:
59
+ try:
60
+ module_path, class_name = path.rsplit(".", 1)
61
+ module = importlib.import_module(module_path)
62
+ cls = getattr(module, class_name)
63
+ instance: Plugin = cls()
64
+ instance.name = getattr(instance, "name", class_name)
65
+ self._plugins.append(instance)
66
+ logger.info("Plugin loaded: %s", instance.name)
67
+ except Exception as e:
68
+ logger.error("Failed to load plugin %s: %s", path, e)
69
+
70
+ async def on_startup(self, config: Any) -> None:
71
+ for p in self._plugins:
72
+ try:
73
+ await p.on_startup(config)
74
+ except Exception as e:
75
+ logger.error("Plugin %s on_startup error: %s", p.name, e)
76
+
77
+ async def on_shutdown(self) -> None:
78
+ for p in self._plugins:
79
+ try:
80
+ await p.on_shutdown()
81
+ except Exception as e:
82
+ logger.error("Plugin %s on_shutdown error: %s", p.name, e)
83
+
84
+ async def on_request(self, ctx: PluginContext) -> PluginContext:
85
+ for p in self._plugins:
86
+ try:
87
+ ctx = await p.on_request(ctx)
88
+ except Exception as e:
89
+ logger.error("Plugin %s on_request error: %s", p.name, e)
90
+ return ctx
91
+
92
+ async def on_response(self, ctx: PluginContext) -> None:
93
+ for p in self._plugins:
94
+ try:
95
+ await p.on_response(ctx)
96
+ except Exception as e:
97
+ logger.error("Plugin %s on_response error: %s", p.name, e)
98
+
99
+ async def on_error(self, ctx: PluginContext) -> None:
100
+ for p in self._plugins:
101
+ try:
102
+ await p.on_error(ctx)
103
+ except Exception as e:
104
+ logger.error("Plugin %s on_error error: %s", p.name, e)
105
+
106
+ def list_plugins(self) -> list[str]:
107
+ return [p.name for p in self._plugins]
108
+
109
+ def reset(self) -> None:
110
+ self._plugins.clear()
@@ -0,0 +1,34 @@
1
+ """Built-in plugins for codex-proxy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from .plugins import Plugin, PluginContext
8
+
9
+ logger = logging.getLogger("codex-proxy")
10
+
11
+
12
+ class LoggingPlugin(Plugin):
13
+ """Structured request/response logging plugin."""
14
+
15
+ name = "logging"
16
+
17
+ async def on_request(self, ctx: PluginContext) -> PluginContext:
18
+ logger.info(
19
+ "[plugin:logging] request id=%s method=%s model=%s provider=%s stream=%s",
20
+ ctx.request_id, ctx.method, ctx.model, ctx.provider, ctx.stream,
21
+ )
22
+ return ctx
23
+
24
+ async def on_response(self, ctx: PluginContext) -> None:
25
+ logger.info(
26
+ "[plugin:logging] response id=%s status=%d duration=%.1fms",
27
+ ctx.request_id, ctx.status_code or 0, ctx.duration_ms or 0,
28
+ )
29
+
30
+ async def on_error(self, ctx: PluginContext) -> None:
31
+ logger.error(
32
+ "[plugin:logging] error id=%s status=%d error=%s",
33
+ ctx.request_id, ctx.status_code or 0, ctx.error,
34
+ )
@@ -0,0 +1,130 @@
1
+ """Provider-specific adapters for backend quirks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class ProviderAdapter:
10
+ """Base adapter — no modifications."""
11
+
12
+ name: str = "default"
13
+
14
+ def adjust_request(self, cc_body: dict) -> dict:
15
+ """Modify the Chat Completions request before sending."""
16
+ return cc_body
17
+
18
+ def adjust_headers(self, headers: dict) -> dict:
19
+ """Modify HTTP headers before sending."""
20
+ return headers
21
+
22
+
23
+ def _strip_stream_options(cc_body: dict) -> dict:
24
+ cc_body.pop("stream_options", None)
25
+ return cc_body
26
+
27
+
28
+ class OllamaAdapter(ProviderAdapter):
29
+ """Ollama doesn't support stream_options and doesn't need a real API key."""
30
+
31
+ name: str = "ollama"
32
+
33
+ def adjust_request(self, cc_body: dict) -> dict:
34
+ return _strip_stream_options(cc_body)
35
+
36
+
37
+ class OpenRouterAdapter(ProviderAdapter):
38
+ """OpenRouter requires HTTP-Referer header for rankings."""
39
+
40
+ name: str = "openrouter"
41
+
42
+ def adjust_headers(self, headers: dict) -> dict:
43
+ headers.setdefault("HTTP-Referer", "https://github.com/ZiryaNoov/codex-proxy")
44
+ headers.setdefault("X-Title", "codex-proxy")
45
+ return headers
46
+
47
+
48
+ class GroqAdapter(ProviderAdapter):
49
+ """Groq has strict rate limits — no special request modifications needed yet."""
50
+
51
+ name: str = "groq"
52
+
53
+
54
+ class AnthropicAdapter(ProviderAdapter):
55
+ """Anthropic uses x-api-key header and anthropic-version."""
56
+
57
+ name: str = "anthropic"
58
+
59
+ def adjust_request(self, cc_body: dict) -> dict:
60
+ return _strip_stream_options(cc_body)
61
+
62
+ def adjust_headers(self, headers: dict) -> dict:
63
+ api_key = headers.pop("Authorization", "").removeprefix("Bearer ").strip()
64
+ headers["x-api-key"] = api_key
65
+ headers["anthropic-version"] = "2023-06-01"
66
+ return headers
67
+
68
+
69
+ class GeminiAdapter(ProviderAdapter):
70
+ """Google Gemini OpenAI-compatible endpoint."""
71
+
72
+ name: str = "gemini"
73
+
74
+ def adjust_request(self, cc_body: dict) -> dict:
75
+ return _strip_stream_options(cc_body)
76
+
77
+
78
+ class DeepSeekAdapter(ProviderAdapter):
79
+ """DeepSeek is OpenAI-compatible but doesn't support stream_options."""
80
+
81
+ name: str = "deepseek"
82
+
83
+ def adjust_request(self, cc_body: dict) -> dict:
84
+ return _strip_stream_options(cc_body)
85
+
86
+
87
+ class MistralAdapter(ProviderAdapter):
88
+ """Mistral is OpenAI-compatible but doesn't support stream_options."""
89
+
90
+ name: str = "mistral"
91
+
92
+ def adjust_request(self, cc_body: dict) -> dict:
93
+ return _strip_stream_options(cc_body)
94
+
95
+
96
+ class CohereAdapter(ProviderAdapter):
97
+ """Cohere OpenAI-compatible endpoint."""
98
+
99
+ name: str = "cohere"
100
+
101
+ def adjust_request(self, cc_body: dict) -> dict:
102
+ return _strip_stream_options(cc_body)
103
+
104
+
105
+ class NvidiaAdapter(ProviderAdapter):
106
+ """NVIDIA NIM OpenAI-compatible endpoint."""
107
+
108
+ name: str = "nvidia"
109
+
110
+ def adjust_request(self, cc_body: dict) -> dict:
111
+ return _strip_stream_options(cc_body)
112
+
113
+
114
+ _ADAPTERS: dict[str, type[ProviderAdapter]] = {
115
+ "ollama": OllamaAdapter,
116
+ "openrouter": OpenRouterAdapter,
117
+ "groq": GroqAdapter,
118
+ "anthropic": AnthropicAdapter,
119
+ "gemini": GeminiAdapter,
120
+ "deepseek": DeepSeekAdapter,
121
+ "mistral": MistralAdapter,
122
+ "cohere": CohereAdapter,
123
+ "nvidia": NvidiaAdapter,
124
+ }
125
+
126
+
127
+ def get_adapter(provider_name: str) -> ProviderAdapter:
128
+ """Get the adapter for a provider by name. Returns base adapter if unknown."""
129
+ cls = _ADAPTERS.get(provider_name, ProviderAdapter)
130
+ return cls(name=provider_name)