Store generated image items as attachment references (#54398)

## What changed

- Upload generated images to the attachment store and expose `fileId` in image-generation items when available, with inline `result` fallback on upload or decoding failure.
- Preserve file references through legacy events, persisted rollouts, thread history, and task output summaries. Keep model-facing image preparation separate.
- Add attachment request tags and mark generated-image uploads with `usage=image_gen`, preserving the thread's ephemeral setting.
- Update public schemas and SDK types to accept either image representation, retain the Python `ImageGenerationThreadItem` alias, and prefer `fileId` when reading records containing both fields.

## Testing

Add coverage for file-backed generation and rollout persistence, legacy history replay, inline and transitional serialization, task output visibility, and Python SDK alias compatibility.

GitOrigin-RevId: f7a2bb5707e2f277348a547a988bc33c8e96d891
This commit is contained in:
Krish Chainani
2026-10-15 19:51:22 +00:00
committed by copyberry
parent 1ef30bcbe7
commit aabf13307a
64 changed files with 1229 additions and 125 deletions
@@ -591,6 +591,7 @@ def generate_v2_all(schema_dir: Path, *, format_output: bool = True) -> None:
cwd=sdk_root() if format_output else Path(td),
)
_preserve_inline_image_class_names(out_path)
_add_legacy_image_generation_thread_item_alias(out_path)
_require_nullable_field(out_path, "ChatgptAccount", r"email: str \| None")
_require_nullable_field(out_path, "McpResourceReadTarget", r"link_id: Annotated\[[\s\S]*?\]")
_preserve_open_enum(out_path, "CodexErrorInfoValue", allow_empty=True)
@@ -618,6 +619,22 @@ def _preserve_inline_image_class_names(out_path: Path) -> None:
out_path.write_text(source)
def _add_legacy_image_generation_thread_item_alias(out_path: Path) -> None:
"""Keep the inline image-generation class import available after splitting the wire type."""
source = out_path.read_text()
class_start = source.find("class ResultThreadItem(BaseModel):")
if class_start == -1:
raise RuntimeError("Generated SDK is missing ResultThreadItem")
if re.search(r"\bImageGenerationThreadItem\b", source):
raise RuntimeError("Generated SDK already defines ImageGenerationThreadItem")
class_end = source.find("\n\nclass ", class_start)
if class_end == -1:
class_end = len(source)
alias = "\n\nImageGenerationThreadItem = ResultThreadItem\n"
out_path.write_text(source[:class_end] + alias + source[class_end:])
def _require_nullable_field(out_path: Path, class_name: str, field_pattern: str) -> None:
"""Preserve required-but-nullable fields that codegen gives a None default."""
source = out_path.read_text()
@@ -5524,18 +5524,35 @@ class SleepThreadItem(BaseModel):
type: Annotated[Literal["sleep"], Field(title="SleepThreadItemType")]
class ImageGenerationThreadItem(BaseModel):
class FileIdThreadItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
failure: ImageGenerationFailure | None = None
id: str
result: str
revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None
saved_path: Annotated[AbsolutePathBuf | None, Field(alias="savedPath")] = None
status: str
transparent_background: Annotated[bool | None, Field(alias="transparentBackground")] = None
type: Annotated[Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType")]
file_id: Annotated[str, Field(alias="fileId")]
class ResultThreadItem(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
failure: ImageGenerationFailure | None = None
id: str
revised_prompt: Annotated[str | None, Field(alias="revisedPrompt")] = None
saved_path: Annotated[AbsolutePathBuf | None, Field(alias="savedPath")] = None
status: str
transparent_background: Annotated[bool | None, Field(alias="transparentBackground")] = None
type: Annotated[Literal["imageGeneration"], Field(title="ImageGenerationThreadItemType")]
result: str
ImageGenerationThreadItem = ResultThreadItem
class EnteredReviewModeThreadItem(BaseModel):
@@ -11647,7 +11664,8 @@ class ThreadItem(
| WebSearchThreadItem
| ImageViewThreadItem
| SleepThreadItem
| ImageGenerationThreadItem
| FileIdThreadItem
| ResultThreadItem
| EnteredReviewModeThreadItem
| ExitedReviewModeThreadItem
| ContextCompactionThreadItem
@@ -11672,7 +11690,8 @@ class ThreadItem(
| WebSearchThreadItem
| ImageViewThreadItem
| SleepThreadItem
| ImageGenerationThreadItem
| FileIdThreadItem
| ResultThreadItem
| EnteredReviewModeThreadItem
| ExitedReviewModeThreadItem
| ContextCompactionThreadItem
@@ -556,6 +556,30 @@ def test_generated_inline_image_class_names_remain_stable() -> None:
)
def test_generated_image_generation_thread_item_alias_remains_available() -> None:
"""Existing SDK callers can still construct and validate inline image-generation items."""
from openai_codex.generated.v2_all import (
ImageGenerationThreadItem,
ResultThreadItem,
)
item = ImageGenerationThreadItem.model_validate(
{
"failure": None,
"id": "image_1",
"result": "aW1hZ2U=",
"revisedPrompt": None,
"savedPath": None,
"status": "completed",
"transparentBackground": None,
"type": "imageGeneration",
}
)
assert ImageGenerationThreadItem is ResultThreadItem
assert isinstance(item, ResultThreadItem)
def test_runtime_package_template_has_no_checked_in_binaries() -> None:
runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin"
assert sorted(