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.
- codex_image_generation/__init__.py +10 -0
- codex_image_generation/__main__.py +6 -0
- codex_image_generation/cli.py +73 -0
- codex_image_generation/config.py +395 -0
- codex_image_generation/errors.py +5 -0
- codex_image_generation/generator.py +627 -0
- codex_image_generation/server.py +240 -0
- codex_image_generation/storage.py +526 -0
- codex_image_generation-0.3.0.dist-info/METADATA +185 -0
- codex_image_generation-0.3.0.dist-info/RECORD +13 -0
- codex_image_generation-0.3.0.dist-info/WHEEL +4 -0
- codex_image_generation-0.3.0.dist-info/entry_points.txt +3 -0
- codex_image_generation-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|