codex-image-generation 0.3.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.
@@ -0,0 +1,10 @@
1
+ """Codex image-generation MCP server."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+
6
+ def main() -> None:
7
+ """Entry point for the installed console script."""
8
+ from .cli import main as run
9
+
10
+ run()
@@ -0,0 +1,6 @@
1
+ """Run with ``python -m codex_image_generation``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,73 @@
1
+ """Run the MCP service, inspect settings, or print installation configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import math
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+
15
+
16
+ def _configure_utf8_output() -> None:
17
+ """Keep JSON, TOML, and diagnostics readable in redirected CLI output."""
18
+ for stream, errors in ((sys.stdout, "strict"), (sys.stderr, "backslashreplace")):
19
+ reconfigure = getattr(stream, "reconfigure", None)
20
+ if callable(reconfigure):
21
+ reconfigure(encoding="utf-8", errors=errors)
22
+
23
+
24
+ def main() -> None:
25
+ _configure_utf8_output()
26
+ parser = argparse.ArgumentParser(description="Generate images using Codex's existing relay configuration.")
27
+ parser.add_argument("--version", action="version", version=__version__)
28
+ mode = parser.add_mutually_exclusive_group()
29
+ mode.add_argument("--check", action="store_true", help="Inspect local configuration without an API request")
30
+ mode.add_argument("--print-config", action="store_true", help="Print a Codex MCP installation snippet")
31
+ parser.add_argument("--codex-home", help="Locate existing Codex configuration")
32
+ parser.add_argument("--profile", help="Select an existing Codex configuration profile")
33
+ options = parser.parse_args()
34
+ if options.codex_home is not None:
35
+ os.environ["CODEX_HOME"] = str(Path(options.codex_home).expanduser().resolve())
36
+ if options.profile is not None:
37
+ os.environ["CODEX_IMAGE_PROFILE"] = options.profile
38
+
39
+ if options.print_config:
40
+ from .config import get_launch_settings
41
+ from .errors import ImageToolError
42
+
43
+ try:
44
+ settings = get_launch_settings()
45
+ except ImageToolError as exc:
46
+ print(str(exc), file=sys.stderr)
47
+ raise SystemExit(1) from None
48
+ arguments = ["-m", "codex_image_generation", "--codex-home", settings["codex_home"]]
49
+ names = set(settings["required_env_vars"])
50
+ names.discard("CODEX_HOME")
51
+ if options.profile is not None:
52
+ arguments.extend(["--profile", options.profile])
53
+ names.discard("CODEX_IMAGE_PROFILE")
54
+ timeout = max(360, math.ceil(settings["request_timeout"]) + 60)
55
+ print("[mcp_servers.image_generation]")
56
+ print("command = " + json.dumps(sys.executable, ensure_ascii=False))
57
+ print("args = " + json.dumps(arguments, ensure_ascii=False))
58
+ print("startup_timeout_sec = 60")
59
+ print("tool_timeout_sec = " + str(timeout))
60
+ print("env_vars = " + json.dumps(sorted(names), ensure_ascii=False))
61
+ return
62
+
63
+ for name in ("openai", "httpx", "httpx2", "httpcore"):
64
+ logging.getLogger(name).setLevel(logging.WARNING)
65
+ from .server import configuration_status, mcp
66
+
67
+ if options.check:
68
+ status = configuration_status()
69
+ print(json.dumps(status, ensure_ascii=False, indent=2))
70
+ if not status["configured"]:
71
+ raise SystemExit(1)
72
+ return
73
+ mcp.run(transport="stdio", show_banner=False)
@@ -0,0 +1,395 @@
1
+ """Read the selected Codex provider without inventing models, endpoints or credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ import tomllib
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import parse_qsl, urlsplit, urlunsplit
15
+
16
+ from .errors import ImageToolError
17
+
18
+ _PROFILE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
19
+ _ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
20
+ _HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z")
21
+ _AUTH_HEADERS = frozenset({
22
+ "authorization", "api-key", "x-api-key", "x-api-token",
23
+ "x-auth-token", "x-access-token", "x-goog-api-key", "ocp-apim-subscription-key",
24
+ })
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ResolvedConfig:
29
+ base_url: str = field(repr=False)
30
+ api_key: str = field(repr=False)
31
+ headers: dict[str, str] = field(repr=False)
32
+ query_params: dict[str, str] = field(repr=False)
33
+ model: str | None
34
+ provider: str
35
+ model_source: str
36
+ credential_source: str
37
+ config_path: Path
38
+ request_timeout: float
39
+ required_env_vars: tuple[str, ...] = field(default=(), repr=False)
40
+
41
+ def public_status(self) -> dict[str, Any]:
42
+ """Expose configuration metadata, not secrets or proof of a session model."""
43
+ parsed = urlsplit(self.base_url)
44
+ netloc = parsed.netloc.rsplit("@", 1)[-1]
45
+ return {
46
+ "provider": self.provider,
47
+ "base_url": urlunsplit((parsed.scheme, netloc, parsed.path, "", "")),
48
+ "model": self.model,
49
+ "model_source": self.model_source,
50
+ "credential_source": self.credential_source,
51
+ "config_path": str(self.config_path),
52
+ "request_timeout": self.request_timeout,
53
+ "required_env_vars": list(self.required_env_vars),
54
+ "session_model_auto_detection": False,
55
+ "session_model_verified": False,
56
+ }
57
+
58
+
59
+ def _text(value: Any, label: str) -> str:
60
+ if (not isinstance(value, str) or not value.strip()
61
+ or any(ord(c) < 32 or ord(c) == 127 for c in value)):
62
+ raise ImageToolError(f"{label} must be a non-empty, single-line string.")
63
+ return value.strip()
64
+
65
+
66
+ def _env_text(env: Mapping[str, str], name: str) -> str | None:
67
+ value = env.get(name)
68
+ if value is None or (isinstance(value, str) and not value.strip()):
69
+ return None
70
+ return _text(value, name)
71
+
72
+
73
+ def _table(value: Any, label: str) -> dict[str, Any]:
74
+ if not isinstance(value, dict):
75
+ raise ImageToolError(f"{label} must be a TOML table.")
76
+ return value
77
+
78
+
79
+ def _variable(value: Any, label: str) -> str:
80
+ value = _text(value, label)
81
+ if not _ENV_NAME.fullmatch(value):
82
+ raise ImageToolError(f"{label} must be a valid environment variable name.")
83
+ return value
84
+
85
+
86
+ def _home(env: Mapping[str, str], codex_home: Path | None) -> Path:
87
+ value = codex_home if codex_home is not None else _env_text(env, "CODEX_HOME")
88
+ try:
89
+ return (Path(value).expanduser() if value is not None else Path.home() / ".codex").resolve()
90
+ except (OSError, ValueError, TypeError):
91
+ raise ImageToolError("Cannot resolve the Codex configuration directory.") from None
92
+
93
+
94
+ def _read_toml(path: Path, *, required: bool) -> dict[str, Any]:
95
+ try:
96
+ with path.open("rb") as handle:
97
+ return tomllib.load(handle)
98
+ except FileNotFoundError:
99
+ if not required:
100
+ return {}
101
+ raise ImageToolError("The selected Codex profile file does not exist.") from None
102
+ except (OSError, ValueError):
103
+ raise ImageToolError("Cannot read or parse the Codex TOML configuration; check the file locally.") from None
104
+
105
+
106
+ def _merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
107
+ result = dict(base)
108
+ for key, value in overlay.items():
109
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
110
+ result[key] = _merge(result[key], value)
111
+ else:
112
+ result[key] = value
113
+ return result
114
+
115
+
116
+ def _load_config(home: Path, env: Mapping[str, str], profile: str | None
117
+ ) -> tuple[dict[str, Any], Path, str | None]:
118
+ path = home / "config.toml"
119
+ config = _read_toml(path, required=False)
120
+ selected = profile if profile is not None else _env_text(env, "CODEX_IMAGE_PROFILE")
121
+ if selected is None and config.get("profile") is not None:
122
+ selected = _text(config["profile"], "Codex profile")
123
+ if selected is None:
124
+ return config, path, None
125
+ if not isinstance(selected, str) or not _PROFILE_NAME.fullmatch(selected):
126
+ raise ImageToolError("Profile names may contain only letters, digits, dots, underscores, and hyphens, and must begin with a letter or digit.")
127
+ profile_path = home / f"{selected}.config.toml"
128
+ try:
129
+ has_file = profile_path.exists()
130
+ except OSError:
131
+ raise ImageToolError("Cannot access the selected Codex profile configuration.") from None
132
+ if has_file:
133
+ overlay = _read_toml(profile_path, required=True)
134
+ path = profile_path
135
+ else:
136
+ profiles = _table(config.get("profiles", {}), "Codex profiles")
137
+ if selected not in profiles:
138
+ raise ImageToolError("The selected Codex profile was not found as a profile file or legacy profiles table.")
139
+ overlay = _table(profiles[selected], "Selected Codex profile")
140
+ return _merge(config, overlay), path, selected
141
+
142
+
143
+ def _provider(config: dict[str, Any]) -> tuple[str, dict[str, Any]]:
144
+ name = _text(config.get("model_provider", "openai"), "Codex model_provider")
145
+ providers = _table(config.get("model_providers", {}), "Codex model_providers")
146
+ if "openai" in providers:
147
+ raise ImageToolError("The built-in openai provider is reserved. Set top-level openai_base_url instead of model_providers.openai.")
148
+ if name == "openai":
149
+ return name, {}
150
+ if name not in providers:
151
+ raise ImageToolError("The selected custom provider is not defined in Codex model_providers.")
152
+ provider = _table(providers[name], "Selected Codex provider")
153
+ if not isinstance(provider.get("requires_openai_auth", False), bool):
154
+ raise ImageToolError("Provider requires_openai_auth must be a boolean.")
155
+ return name, provider
156
+
157
+
158
+ def _timeout(env: Mapping[str, str]) -> float:
159
+ try:
160
+ value = float(_env_text(env, "CODEX_IMAGE_TIMEOUT") or "300")
161
+ except (ValueError, TypeError):
162
+ raise ImageToolError("CODEX_IMAGE_TIMEOUT must be a positive number no greater than 3600 seconds.") from None
163
+ if not math.isfinite(value) or not 0 < value <= 3600:
164
+ raise ImageToolError("CODEX_IMAGE_TIMEOUT must be a positive number no greater than 3600 seconds.")
165
+ return value
166
+
167
+
168
+ def _base_url(value: Any) -> tuple[str, dict[str, str]]:
169
+ value = _text(value, "Provider base URL")
170
+ try:
171
+ parsed = urlsplit(value)
172
+ valid = (parsed.scheme in {"https", "http"} and bool(parsed.hostname)
173
+ and parsed.username is None and parsed.password is None
174
+ and "#" not in value and "\\" not in value
175
+ and not any(c.isspace() for c in value))
176
+ parsed.port
177
+ except ValueError:
178
+ valid = False
179
+ if not valid:
180
+ raise ImageToolError("Provider base URL must be an HTTP(S) URL without user information, whitespace, backslashes, or a fragment.")
181
+ query: dict[str, str] = {}
182
+ for key, item in parse_qsl(parsed.query, keep_blank_values=True):
183
+ if key in query:
184
+ raise ImageToolError("Provider base URL contains a repeated query parameter. Use one value per name; repeated values cannot be forwarded without changing their meaning.")
185
+ query[key] = item
186
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")), query
187
+
188
+
189
+ def _string_map(value: Any, label: str) -> dict[str, str]:
190
+ result: dict[str, str] = {}
191
+ for key, item in _table(value, label).items():
192
+ if not isinstance(key, str) or not key or not isinstance(item, str):
193
+ raise ImageToolError(f"{label} must contain string names and string values.")
194
+ if any(ord(c) < 32 or ord(c) == 127 for c in key + item):
195
+ raise ImageToolError(f"{label} must not contain control characters.")
196
+ result[key] = item
197
+ return result
198
+
199
+
200
+ def _env_header_declarations(provider: dict[str, Any]) -> dict[str, str]:
201
+ result = _string_map(provider.get("env_http_headers", {}), "Provider env_http_headers")
202
+ for name, variable in result.items():
203
+ if not _HEADER_NAME.fullmatch(name):
204
+ raise ImageToolError("Provider HTTP header names must be valid HTTP field names.")
205
+ result[name] = _variable(variable, "Provider env_http_headers")
206
+ return result
207
+
208
+
209
+ def _required_env(env: Mapping[str, str], name: str, provider: dict[str, Any], *,
210
+ codex_home: Path | None, profile: str | None) -> tuple[str, ...]:
211
+ names: set[str] = set()
212
+ for variable, relevant in (
213
+ ("CODEX_HOME", codex_home is None),
214
+ ("CODEX_IMAGE_PROFILE", profile is None),
215
+ ("CODEX_IMAGE_TIMEOUT", True),
216
+ ):
217
+ if relevant and _env_text(env, variable) is not None:
218
+ names.add(variable)
219
+ if "env_key" in provider:
220
+ names.add(_variable(provider["env_key"], "Provider env_key"))
221
+ # Declarations, not currently available values, determine what the child needs.
222
+ names.update(_env_header_declarations(provider).values())
223
+ if name == "openai" or provider.get("requires_openai_auth", False):
224
+ names.add("OPENAI_API_KEY")
225
+ if name == "openai":
226
+ for variable in ("OPENAI_ORG_ID", "OPENAI_PROJECT_ID"):
227
+ if _env_text(env, variable) is not None:
228
+ names.add(variable)
229
+ return tuple(sorted(names))
230
+
231
+
232
+ def get_launch_settings(*, environ: Mapping[str, str] | None = None,
233
+ codex_home: Path | None = None,
234
+ profile: str | None = None) -> dict[str, Any]:
235
+ """Describe child-process configuration without reading or requiring credentials."""
236
+ env = os.environ if environ is None else environ
237
+ home = _home(env, codex_home)
238
+ config, _, selected_profile = _load_config(home, env, profile)
239
+ name, provider = _provider(config)
240
+ return {
241
+ "codex_home": str(home),
242
+ "profile": selected_profile,
243
+ "required_env_vars": list(_required_env(env, name, provider, codex_home=codex_home, profile=profile)),
244
+ "request_timeout": _timeout(env),
245
+ }
246
+
247
+
248
+ def _headers(provider: dict[str, Any], env: Mapping[str, str]
249
+ ) -> tuple[dict[str, str], dict[str, str]]:
250
+ headers: dict[str, tuple[str, str, str]] = {}
251
+
252
+ def put(name: str, value: str, source: str) -> None:
253
+ if not _HEADER_NAME.fullmatch(name):
254
+ raise ImageToolError("Provider HTTP header names must be valid HTTP field names.")
255
+ normalized = name.lower()
256
+ previous = headers.get(normalized)
257
+ if previous and normalized == "authorization" and previous[1] != value:
258
+ raise ImageToolError("Conflicting Authorization headers are configured for the selected provider.")
259
+ headers[normalized] = (name, value, source)
260
+
261
+ for name, value in _string_map(provider.get("http_headers", {}), "Provider http_headers").items():
262
+ put(name, value, "provider.http_headers")
263
+ for name, variable in _env_header_declarations(provider).items():
264
+ value = _env_text(env, variable)
265
+ if value is not None:
266
+ put(name, value, f"provider.env_http_headers:{variable}")
267
+ return ({name: value for name, value, _ in headers.values()},
268
+ {name.lower(): source for name, _, source in headers.values()})
269
+
270
+
271
+ def _auth_file_key(home: Path, config: dict[str, Any]) -> tuple[str, str]:
272
+ store = config.get("cli_auth_credentials_store", "file")
273
+ if not isinstance(store, str):
274
+ raise ImageToolError("Codex cli_auth_credentials_store must be a string.")
275
+ if store == "auto":
276
+ raise ImageToolError("Codex automatic credential storage may prefer the OS keyring over auth.json, so this server cannot determine the active credential. Configure API-key authentication for the selected Codex provider.")
277
+ if store in {"keyring", "ephemeral"}:
278
+ raise ImageToolError("Codex keyring or ephemeral credentials cannot be reused by this server; configure API-key authentication for the selected Codex provider.")
279
+ if store != "file":
280
+ raise ImageToolError("The selected Codex credential storage mode is not supported by this server.")
281
+ try:
282
+ with (home / "auth.json").open("r", encoding="utf-8") as handle:
283
+ auth = json.load(handle)
284
+ except FileNotFoundError:
285
+ raise ImageToolError("No reusable API key was found. Configure API-key authentication for the selected Codex provider; ChatGPT OAuth, OS keyring, and ephemeral credentials are not supported.") from None
286
+ except (OSError, ValueError):
287
+ raise ImageToolError("Cannot read or parse Codex auth.json; inspect the file locally without sharing its contents.") from None
288
+ if not isinstance(auth, dict):
289
+ raise ImageToolError("Codex auth.json must contain an authentication object.")
290
+ mode = auth.get("auth_mode")
291
+ if isinstance(mode, str) and any(term in mode.lower() for term in ("chatgpt", "oauth", "ephemeral")):
292
+ raise ImageToolError("Codex is using ChatGPT OAuth or ephemeral authentication, not a reusable API key.")
293
+ if auth.get("OPENAI_API_KEY") is not None:
294
+ return _text(auth["OPENAI_API_KEY"], "Codex auth.json OPENAI_API_KEY"), "auth.json:OPENAI_API_KEY"
295
+ if auth.get("tokens"):
296
+ raise ImageToolError("Codex contains ChatGPT OAuth tokens, which this server cannot use as an API key.")
297
+ raise ImageToolError("Codex auth.json does not contain a reusable OPENAI_API_KEY.")
298
+
299
+
300
+ def _credentials(name: str, provider: dict[str, Any], headers: dict[str, str],
301
+ sources: dict[str, str], config: dict[str, Any], home: Path,
302
+ env: Mapping[str, str]) -> tuple[str, str]:
303
+ auth = _table(provider.get("auth", {}), "Provider auth")
304
+ if "command" in auth:
305
+ raise ImageToolError("Provider auth.command is not supported by this server; configure API-key authentication for the selected Codex provider.")
306
+ candidates: list[tuple[str, str]] = []
307
+ if "env_key" in provider:
308
+ variable = _variable(provider["env_key"], "Provider env_key")
309
+ key = _env_text(env, variable)
310
+ if key is None:
311
+ raise ImageToolError("The environment variable declared by provider.env_key is missing or empty; it must also be passed to the MCP process.")
312
+ candidates.append((key, f"provider.env_key:{variable}"))
313
+ if "experimental_bearer_token" in provider:
314
+ candidates.append((_text(provider["experimental_bearer_token"], "Provider experimental_bearer_token"),
315
+ "provider.experimental_bearer_token"))
316
+
317
+ bearer_header: str | None = None
318
+ explicit_authorization: str | None = None
319
+ other_auth: list[str] = []
320
+ for header, value in headers.items():
321
+ normalized = header.lower()
322
+ if normalized not in _AUTH_HEADERS:
323
+ continue
324
+ value = _text(value, "Provider authentication header")
325
+ source = f"{sources[normalized]}.{header}"
326
+ if normalized != "authorization":
327
+ other_auth.append(source)
328
+ continue
329
+ parts = value.split(None, 1)
330
+ if parts[0].lower() == "bearer":
331
+ if len(parts) != 2 or not parts[1].strip():
332
+ raise ImageToolError("Provider Authorization Bearer header is missing its token.")
333
+ candidates.append((parts[1].strip(), source))
334
+ bearer_header = header
335
+ else:
336
+ explicit_authorization = source
337
+
338
+ if len({key for key, _ in candidates}) > 1 or (candidates and explicit_authorization):
339
+ raise ImageToolError("Conflicting Authorization sources are configured for the selected provider.")
340
+ if candidates:
341
+ if bearer_header is not None:
342
+ del headers[bearer_header]
343
+ key, source = candidates[0]
344
+ elif explicit_authorization:
345
+ key, source = "", explicit_authorization
346
+ elif name == "openai" or provider.get("requires_openai_auth", False):
347
+ key = _env_text(env, "OPENAI_API_KEY")
348
+ if key is not None:
349
+ source = "env:OPENAI_API_KEY"
350
+ else:
351
+ key, source = _auth_file_key(home, config)
352
+ elif other_auth:
353
+ return "", " + ".join(other_auth)
354
+ else:
355
+ raise ImageToolError("The selected custom provider has no supported authentication source. Configure provider.env_key, experimental_bearer_token, or an authentication header; unrelated OpenAI credentials are not reused.")
356
+ return key, " + ".join([source, *other_auth])
357
+
358
+
359
+ def resolve_config(current_model: str | None = None, *,
360
+ environ: Mapping[str, str] | None = None,
361
+ codex_home: Path | None = None, profile: str | None = None,
362
+ require_model: bool = True) -> ResolvedConfig:
363
+ """Resolve only the caller's known model and the selected user's Codex settings."""
364
+ env = os.environ if environ is None else environ
365
+ home = _home(env, codex_home)
366
+ config, config_path, _ = _load_config(home, env, profile)
367
+ if current_model is not None:
368
+ model, model_source = _text(current_model, "Current model"), "caller"
369
+ elif "model" in config:
370
+ model, model_source = _text(config["model"], "Codex model"), "config"
371
+ elif require_model:
372
+ raise ImageToolError("No current model was supplied and the selected Codex configuration has no model. Pass the known current_model or configure model; no model will be guessed.")
373
+ else:
374
+ model, model_source = None, "unavailable"
375
+
376
+ name, provider = _provider(config)
377
+ if provider.get("wire_api", "responses") != "responses":
378
+ raise ImageToolError("The selected provider must use wire_api = 'responses' to call the image generation tool.")
379
+ if name == "openai":
380
+ if "openai_base_url" not in config:
381
+ raise ImageToolError("The built-in openai provider requires an explicit top-level openai_base_url for this server; no endpoint will be guessed.")
382
+ base_url, url_query = _base_url(config["openai_base_url"])
383
+ elif "base_url" in provider:
384
+ base_url, url_query = _base_url(provider["base_url"])
385
+ else:
386
+ raise ImageToolError("The selected custom provider must define its own base_url; no endpoint will be guessed.")
387
+ headers, sources = _headers(provider, env)
388
+ query = {**url_query, **_string_map(provider.get("query_params", {}), "Provider query_params")}
389
+ api_key, credential_source = _credentials(name, provider, headers, sources, config, home, env)
390
+ return ResolvedConfig(
391
+ base_url=base_url, api_key=api_key, headers=headers, query_params=query,
392
+ model=model, provider=name, model_source=model_source, credential_source=credential_source,
393
+ config_path=config_path, request_timeout=_timeout(env),
394
+ required_env_vars=_required_env(env, name, provider, codex_home=codex_home, profile=profile),
395
+ )
@@ -0,0 +1,5 @@
1
+ """Errors that are safe to show to an MCP client."""
2
+
3
+
4
+ class ImageToolError(Exception):
5
+ """A configuration, validation, generation, or storage failure."""