Follow-up to PR #22942 (INSERT_PAGE_BREAK) and PR #23006 (ADD_SHEET). Applies the same RootModel[Union[...]] + extra="forbid" recipe to 10 more Google toolkit actions where XOR / oneOf semantics were previously enforced only at runtime (peer optional fields + a model_validator that raised if more than one was set).
Why
When a tool's XOR fields are flat optional fields at the request root, OpenAI strict mode marks them all required-but-nullable, so the model fills every field with a non-null default ("", 0, True). The runtime "exactly one" validator then rejects every call. We confirmed this on find_replace during the @composio/vercel strictifier rollout — strict-mode calls failed 9/10 trials on that tool.
Encoding the XOR structurally (anyOf with extra="forbid" per branch) lets the model — and any provider's schema validator — pick exactly one variant from the schema, not just be told to at runtime.
Two shapes
Sub-model XOR (5 tools) — recipe applies directly
Top-level Request stays a BaseModel (type: "object" at root, which OpenAI strict mode requires). The XOR Union lives on a nested sub-model. Same shape as the merged ADD_SHEET fix.
apps/googlesheets/actions/update_sheet_properties.py — 2-way ColorStyle.rgbColor ⊕ themeColor on SheetProperties.tab_color_style
apps/googlesheets/actions/update_spreadsheet_properties.py — same shape on its SpreadsheetProperties
apps/googlesheets/actions/spreadsheets_values_batch_get_by_data_filter.py — 2-way DataFilter.a1Range ⊕ gridRange
apps/googlesheets/actions/search_developer_metadata.py — 3-way DataFilter (a1Range ⊕ gridRange ⊕ developerMetadataLookup)
apps/googlesheets/actions/get_spreadsheet_by_data_filter.py — same 3-way DataFilter
Top-level XOR (5 tools) — wrapped in a new sub-field + backwards-compat shim
When the XOR fields live directly at the request root, RootModel[Union[...]] at the root breaks OpenAI strict (no type: "object"). Fix: move them into a new sub-field, and add a @model_validator(mode="before") that accepts the legacy flat shape and re-wraps it into the new sub-field, so existing production callers' code keeps working unchanged.
| Tool | New sub-field | Variants |
|---|
apps/googledocs/actions/create_footnote.py | insert_point | {location: {...}} ⊕ {endOfSegmentLocation: {...}} |
apps/googledrive/actions/update_drive.py | appearance | {themeId} ⊕ {colorRgb, backgroundImageFile?} ⊕ {backgroundImageFile} |
apps/googledrive/actions/create_drive.py | appearance | same 3-way |
apps/googledrive/actions/create_team_drive.py | appearance | same 3-way |
apps/googlesheets/actions/find_replace.py | scope | {all_sheets: true} ⊕ {sheet_id} ⊕ {sheet_name} ⊕ {range, range_sheet_id?} ⊕ {range_sheet_id, ...indices} (5-way) |
Each execute() body unwraps the variant via isinstance(request.scope.root, _XxxVariant) and builds the same Google API wire payload as before.
Skipped
apps/googledocs/actions/update_existing_document.py — its edit_docs field is list[dict] (no typed Operation model). XOR semantics for editDocs[i].insertText are already handled by runtime preprocessor functions (_filter_empty_insert_text, etc.), not by the Pydantic schema. There's nothing schema-level to migrate.
Backwards compatibility
For each of the 5 top-level-wrapped tools:
| Input shape from caller | Result |
|---|
Legacy flat (e.g. {themeId: "TH"}) | ✓ accepted — before-validator wraps into {appearance: {themeId: "TH"}} |
New wrapped ({appearance: {themeId: "TH"}}) | ✓ accepted as-is |
| Multiple legacy scope fields set | ✗ rejected with clear error (same as before) |
| Both new + legacy at same time | uses new; legacy is ignored |
| Wire payload to Google API | byte-identical (unwrapping happens before HTTP request) |
For the 5 sub-model XOR tools: no caller-facing change — the XOR field already lived in a sub-object, just gets a stricter shape.
Verification
Pydantic round-trip verified for every migrated tool, every variant, both legacy and new input shapes. Conflicts (multiple variants set) rejected; empty input rejected for required-scope tools.
To test end-to-end against a real OpenAI Responses-API call + Composio execution, deploy this branch as a preview and:
PREVIEW_VERSION=<preview-version> bun run temp/oneof_repro/ts/stress_test_multi_tool_strict.ts
That harness uses the @composio/vercel strictifier (from #23162's spiritual successor) to send each migrated tool through OpenAI Responses with strict: true. Expected outcome: 0 over-volunteer / 0 XOR violations across the 10 tools.
Net diff
10 files changed, +903 / −648 lines.
🤖 Generated with Claude Code