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,240 @@
|
|
|
1
|
+
"""One image tool using the current task context and existing relay settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import io
|
|
7
|
+
from typing import Annotated, Any, Literal, Self
|
|
8
|
+
|
|
9
|
+
from fastmcp import Context, FastMCP
|
|
10
|
+
from fastmcp.tools import ToolResult
|
|
11
|
+
from fastmcp.utilities.types import Image
|
|
12
|
+
from PIL import Image as PILImage
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import resolve_config
|
|
17
|
+
from .errors import ImageToolError
|
|
18
|
+
from .generator import generate
|
|
19
|
+
|
|
20
|
+
INSTRUCTIONS = """Generate or edit raster images when the user requests them.
|
|
21
|
+
Reuse the existing Codex relay configuration; never ask for another URL, API key or image model.
|
|
22
|
+
Supply cwd as the absolute working directory of the CURRENT task, not the MCP installation.
|
|
23
|
+
Without a requested destination, omit output_dir and filename to save directly under cwd.
|
|
24
|
+
Split a requested file path into output_dir and filename.
|
|
25
|
+
Pass current_model only when its exact API ID is supplied by reliable session/runtime context.
|
|
26
|
+
Never guess your model identity. Otherwise omit it: the server may use the configured model and
|
|
27
|
+
reports model_source=config. This does not prove that it matches a temporary UI selection.
|
|
28
|
+
Keep the selected provider/profile context consistent with the session.
|
|
29
|
+
Use filename/output_format for PNG, JPEG or WebP. Transparency requires PNG/WebP.
|
|
30
|
+
Forward image options requested by the user; upstream decides model-specific capabilities.
|
|
31
|
+
Use input_images for local paths, URLs/data URLs or provider file IDs, and mask for editing.
|
|
32
|
+
previous_response_id and previous_image_ids must belong to the same provider that created them.
|
|
33
|
+
Return saved original paths, previews, warnings and response/image IDs needed for later edits.
|
|
34
|
+
Do not automatically repeat a failed or timed-out generation: it may already have incurred charges.
|
|
35
|
+
Previews are not originals. partial_images enables upstream streaming and progress, but real-time
|
|
36
|
+
image display depends on the host. Intermediate previews are drafts, not saved final files.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
mcp = FastMCP(
|
|
40
|
+
"codex-image-generation", version=__version__, instructions=INSTRUCTIONS,
|
|
41
|
+
mask_error_details=True, strict_input_validation=True,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ImageSource(BaseModel):
|
|
46
|
+
"""Exactly one local or upstream image source."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
|
|
49
|
+
path: str | None = Field(default=None, min_length=1, description="Local path, absolute or relative to cwd.")
|
|
50
|
+
image_url: str | None = Field(default=None, min_length=1, description="HTTP(S) or data URL; masks should use data URLs or file IDs.")
|
|
51
|
+
file_id: str | None = Field(default=None, min_length=1, description="File ID from the configured provider.")
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def exactly_one_source(self) -> Self:
|
|
55
|
+
if sum(value is not None for value in (self.path, self.image_url, self.file_id)) != 1:
|
|
56
|
+
raise ValueError("Provide exactly one of path, image_url or file_id.")
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ImageInput(ImageSource):
|
|
61
|
+
detail: Literal["auto", "low", "high", "original"] | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def configuration_status() -> dict[str, Any]:
|
|
65
|
+
"""CLI-only inspection; it cannot discover the active session model."""
|
|
66
|
+
try:
|
|
67
|
+
settings = resolve_config(require_model=False)
|
|
68
|
+
except ImageToolError as exc:
|
|
69
|
+
return {"configured": False, "connection_verified": False, "error": str(exc)}
|
|
70
|
+
return {
|
|
71
|
+
**settings.public_status(), "configured": True,
|
|
72
|
+
"model_available_in_config": settings.model is not None,
|
|
73
|
+
"connection_verified": False, "server_version": __version__,
|
|
74
|
+
"default_output_directory": "<cwd>", "formats": ["png", "jpeg", "webp"],
|
|
75
|
+
"session_model_auto_detection": False,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _preview(data: bytes) -> tuple[bytes, str]:
|
|
80
|
+
"""Bound inline previews without changing saved originals."""
|
|
81
|
+
with PILImage.open(io.BytesIO(data)) as source:
|
|
82
|
+
alpha = "A" in source.getbands() or "transparency" in source.info
|
|
83
|
+
preview = source.convert("RGBA" if alpha else "RGB")
|
|
84
|
+
preview.thumbnail((1024, 1024), PILImage.Resampling.LANCZOS)
|
|
85
|
+
image_format = "png" if alpha else "jpeg"
|
|
86
|
+
while True:
|
|
87
|
+
buffer = io.BytesIO()
|
|
88
|
+
options: dict[str, Any] = {"optimize": True}
|
|
89
|
+
if image_format == "jpeg":
|
|
90
|
+
options["quality"] = 85
|
|
91
|
+
preview.save(buffer, format=image_format.upper(), **options)
|
|
92
|
+
encoded = buffer.getvalue()
|
|
93
|
+
if len(encoded) <= 1024 * 1024 or max(preview.size) <= 128:
|
|
94
|
+
return encoded, image_format
|
|
95
|
+
preview.thumbnail(
|
|
96
|
+
(max(1, preview.width // 2), max(1, preview.height // 2)),
|
|
97
|
+
PILImage.Resampling.LANCZOS,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _error_result(code: str, message: str) -> ToolResult:
|
|
102
|
+
return ToolResult(
|
|
103
|
+
content=[message], is_error=True,
|
|
104
|
+
structured_content={
|
|
105
|
+
"status": "error", "images": [], "response_id": None,
|
|
106
|
+
"warnings": [], "errors": [{"code": code, "message": message}],
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool(annotations={
|
|
112
|
+
"readOnlyHint": False, "destructiveHint": False,
|
|
113
|
+
"idempotentHint": False, "openWorldHint": True,
|
|
114
|
+
})
|
|
115
|
+
async def generate_image(
|
|
116
|
+
prompt: Annotated[str, Field(min_length=1, description="Describe what to draw or edit and important visual requirements.")],
|
|
117
|
+
cwd: Annotated[str, Field(min_length=1, description="Absolute CURRENT task working directory, supplied by Codex from context.")],
|
|
118
|
+
ctx: Context,
|
|
119
|
+
current_model: Annotated[str | None, Field(description="Exact dialogue-model API ID only when reliably known from the session. Otherwise omit; config fallback is explicit.")] = None,
|
|
120
|
+
output_dir: Annotated[str | None, Field(description="Directory relative to cwd or an explicitly requested absolute directory. Default: cwd itself.")] = None,
|
|
121
|
+
filename: Annotated[str | None, Field(description="Filename only, e.g. cover.webp. Infer format from extension; default is a unique name. Never overwrite existing files.")] = None,
|
|
122
|
+
output_format: Annotated[Literal["png", "jpeg", "jpg", "webp"] | None, Field(description="Infer from filename if omitted, otherwise default PNG. No SVG or animated output.")] = None,
|
|
123
|
+
size: Annotated[str | None, Field(description="auto or WIDTHxHEIGHT. Model-specific limits are decided by upstream.")] = None,
|
|
124
|
+
quality: Annotated[Literal["auto", "low", "medium", "high", "xhigh", "max"] | None, Field(description="Optional quality; available levels depend on the upstream image model.")] = None,
|
|
125
|
+
background: Annotated[Literal["auto", "opaque", "transparent"] | None, Field(description="Transparent backgrounds require PNG or WebP.")] = None,
|
|
126
|
+
output_compression: Annotated[int | None, Field(strict=True, ge=0, le=100, description="JPEG/WebP only: strictly integer 0–100.")] = None,
|
|
127
|
+
action: Annotated[Literal["auto", "generate", "edit"] | None, Field(description="Choose automatically, generate, or edit supplied/previous images.")] = None,
|
|
128
|
+
input_images: Annotated[list[ImageInput] | None, Field(description="Reference images, each with exactly one path, image_url or file_id.")] = None,
|
|
129
|
+
mask: Annotated[ImageSource | None, Field(description="Mask for the first image; local/data masks need alpha and must match comparable local input dimensions/format.")] = None,
|
|
130
|
+
input_fidelity: Annotated[Literal["low", "high"] | None, Field(description="Optional fidelity only if supported upstream; omit by default.")] = None,
|
|
131
|
+
previous_response_id: Annotated[str | None, Field(description="Prior response ID from the same provider; requires retained upstream state.")] = None,
|
|
132
|
+
previous_image_ids: Annotated[list[str] | None, Field(description="Prior image_generation_call IDs from the same provider.")] = None,
|
|
133
|
+
moderation: Annotated[Literal["auto", "low"] | None, Field(description="Optional official moderation setting; upstream policy still applies.")] = None,
|
|
134
|
+
partial_images: Annotated[int, Field(strict=True, ge=0, le=3, description="0: final-only default. 1–3: streaming drafts and progress; host live display varies.")] = 0,
|
|
135
|
+
safety_identifier: Annotated[str | None, Field(max_length=128, description="Optional privacy-preserving end-user ID for multi-user deployments.")] = None,
|
|
136
|
+
include_preview: Annotated[bool, Field(strict=True, description="Return bounded inline previews and original file paths.")] = True,
|
|
137
|
+
) -> ToolResult:
|
|
138
|
+
"""Generate/edit images and save originals in the current directory or requested folder.
|
|
139
|
+
|
|
140
|
+
Reuse Codex relay URL and credentials, without a separate image-model setting.
|
|
141
|
+
Supports PNG/JPEG/WebP, visual options, reference images and masks. Returns previews,
|
|
142
|
+
absolute paths and edit IDs; successful images survive partial failures.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
settings = resolve_config(current_model=current_model)
|
|
146
|
+
except ImageToolError as exc:
|
|
147
|
+
return _error_result("configuration_error", str(exc))
|
|
148
|
+
progress_count = 0
|
|
149
|
+
|
|
150
|
+
async def progress(event: dict[str, Any]) -> None:
|
|
151
|
+
nonlocal progress_count
|
|
152
|
+
progress_count += 1
|
|
153
|
+
messages = {
|
|
154
|
+
"partial_image": "收到中间预览,正在继续生成最终图片",
|
|
155
|
+
"completed": "上游生成完成,正在保存图片",
|
|
156
|
+
"created": "图像请求已接收",
|
|
157
|
+
}
|
|
158
|
+
try:
|
|
159
|
+
await ctx.report_progress(
|
|
160
|
+
progress_count, message=messages.get(event.get("stage"), "正在生成图片")
|
|
161
|
+
)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass # Unsupported host progress must not fail a paid request.
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = await generate(
|
|
167
|
+
settings, prompt=prompt, cwd=cwd, output_dir=output_dir,
|
|
168
|
+
filename=filename, output_format=output_format, size=size, quality=quality,
|
|
169
|
+
background=background, output_compression=output_compression, action=action,
|
|
170
|
+
input_images=[item.model_dump(exclude_none=True) for item in input_images] if input_images is not None else None,
|
|
171
|
+
mask=mask.model_dump(exclude_none=True) if mask is not None else None,
|
|
172
|
+
input_fidelity=input_fidelity, previous_response_id=previous_response_id,
|
|
173
|
+
previous_image_ids=previous_image_ids, moderation=moderation,
|
|
174
|
+
partial_images=partial_images, safety_identifier=safety_identifier,
|
|
175
|
+
on_progress=progress,
|
|
176
|
+
)
|
|
177
|
+
except ImageToolError as exc:
|
|
178
|
+
return _error_result("invalid_request", str(exc))
|
|
179
|
+
except Exception:
|
|
180
|
+
return _error_result("internal_error", "生成请求处理失败。请检查本地配置与输出目录,不要自动重复付费生成。")
|
|
181
|
+
|
|
182
|
+
metadata: dict[str, Any] = {
|
|
183
|
+
"status": result.status, "images": [], "partials": [], "text": result.text,
|
|
184
|
+
"response_id": result.response_id, "request_id": result.request_id,
|
|
185
|
+
"model": settings.model, "model_source": settings.model_source,
|
|
186
|
+
"reported_model": getattr(result, "model", None), "usage": result.usage,
|
|
187
|
+
"warnings": list(result.warnings), "errors": list(result.errors),
|
|
188
|
+
"session_model_auto_detection": False,
|
|
189
|
+
}
|
|
190
|
+
content: list[Any] = []
|
|
191
|
+
preview_count = 0
|
|
192
|
+
|
|
193
|
+
async def add_preview(data: bytes) -> bool:
|
|
194
|
+
nonlocal preview_count
|
|
195
|
+
if not include_preview or preview_count >= 4:
|
|
196
|
+
return False
|
|
197
|
+
try:
|
|
198
|
+
preview_data, preview_format = await asyncio.to_thread(_preview, data)
|
|
199
|
+
content.append(Image(data=preview_data, format=preview_format))
|
|
200
|
+
preview_count += 1
|
|
201
|
+
return True
|
|
202
|
+
except Exception:
|
|
203
|
+
metadata["warnings"].append("有一张预览无法显示;已保存的原图不受影响。")
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
for image in result.images:
|
|
207
|
+
path = str(image.path)
|
|
208
|
+
content.append(f"图片已保存:{path} ({image.width}x{image.height}, {image.mime_type})")
|
|
209
|
+
if image.call_id:
|
|
210
|
+
content.append(f"Image call ID: {image.call_id}")
|
|
211
|
+
metadata["images"].append({
|
|
212
|
+
"path": path, "mime_type": image.mime_type, "width": image.width, "height": image.height,
|
|
213
|
+
"bytes": len(image.data), "call_id": image.call_id, "revised_prompt": image.revised_prompt,
|
|
214
|
+
"actual": image.metadata, "preview_included": await add_preview(image.data),
|
|
215
|
+
"preview_is_original": False,
|
|
216
|
+
})
|
|
217
|
+
for draft in result.partials:
|
|
218
|
+
entry = {
|
|
219
|
+
"call_id": draft.call_id, "width": draft.info.width, "height": draft.info.height,
|
|
220
|
+
"mime_type": draft.info.mime_type, "metadata": draft.metadata,
|
|
221
|
+
"kind": "intermediate_preview", "preview_included": False,
|
|
222
|
+
}
|
|
223
|
+
if include_preview and preview_count < 4:
|
|
224
|
+
content.append("以下是生成过程中的中间预览,不是最终保存的原图。")
|
|
225
|
+
entry["preview_included"] = await add_preview(draft.data)
|
|
226
|
+
metadata["partials"].append(entry)
|
|
227
|
+
if result.response_id:
|
|
228
|
+
content.append(f"Response ID: {result.response_id}")
|
|
229
|
+
if result.request_id:
|
|
230
|
+
content.append(f"Request ID: {result.request_id}")
|
|
231
|
+
content.append(f"Model: {settings.model} (source: {settings.model_source})")
|
|
232
|
+
if metadata["reported_model"] and metadata["reported_model"] != settings.model:
|
|
233
|
+
content.append(f"Upstream reported model: {metadata['reported_model']}")
|
|
234
|
+
if settings.model_source == "config":
|
|
235
|
+
metadata["warnings"].append("模型来自 Codex 配置文件,未验证其是否等于界面临时选择的当前会话模型。")
|
|
236
|
+
if result.text:
|
|
237
|
+
content.append(result.text)
|
|
238
|
+
content.extend(f"提示:{warning}" for warning in metadata["warnings"])
|
|
239
|
+
content.extend(f"错误 [{error.get('code', 'unknown')}]:{error.get('message', '生成失败')}" for error in result.errors)
|
|
240
|
+
return ToolResult(content=content, structured_content=metadata, is_error=result.status == "error")
|