Skip to content

Fix optional records and tuples in OpenAI and Anthropic structured output codecs - #10502

Merged
tim-smart merged 1 commit into
Effect-TS:mainfrom
gabedemesa:fix-structured-output-optional-record
Nov 9, 2026
Merged

tim-smart merged 1 commit into
Effect-TS:mainfrom
gabedemesa:fix-structured-output-optional-record

Conversation

@gabedemesa

@gabedemesa gabedemesa commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

An optional property whose type is converted for the provider can't be decoded by the OpenAI or Anthropic structured-output codecs. This covers records, which become [key, value] pairs, and tuples, which become objects with numeric keys. The codec advertises the converted shape and then rejects that same shape.

Reproduce

import { Schema } from "effect"
import { toCodecOpenAI } from "effect/ai/OpenAiStructuredOutput"

const schema = Schema.Struct({
  value: Schema.optional(Schema.Record(Schema.String, Schema.Number))
})
const { codec } = toCodecOpenAI(schema)

Schema.decodeUnknownSync(codec)({ value: [{ 0: "a", 1: 1 }] })
// Throws: Expected array | null at ["value"]
// Expected: { value: { a: 1 } }

The same call succeeds when value is required. On 4.1.2 these also fail, with both toCodecOpenAI and toCodecAnthropic:

  • optionalKey(Record)
  • optional(NullOr(Record))
  • an optional struct that contains a record
  • optional(Tuple)

With @effect/ai-openai, a tool with any of these parameters fails the model's first call.

Fix

optionalToNullable reused the property's encoded form on the decoded side, so the conversion ran twice. It now uses SchemaAST.toType(type) for the decoded side. Types without a conversion are unaffected. New tests cover encoding and decoding an optional record and an optional tuple, including null and an absent key.

Validation

  • pnpm test --run packages/effect/test/ai/OpenAiStructuredOutput.test.ts packages/effect/test/ai/AnthropicStructuredOutput.test.ts: 111 passed. Both new tests fail without the fix.
  • pnpm lint and pnpm check pass.
  • The repro above throws on 4.1.2 and passes with this change.

🤖 Generated with Claude Code

Closes EFF-3562

@changeset-bot

changeset-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aa1c981

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added the 4.0 label Nov 9, 2026
@gabedemesa
gabedemesa marked this pull request as ready for review November 9, 2026 20:36
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 39.61 KB 39.61 KB 0.00 KB (0.00%)
basic.ts 7.31 KB 7.31 KB 0.00 KB (0.00%)
batching.ts 11.21 KB 11.21 KB 0.00 KB (0.00%)
brand.ts 7.00 KB 7.00 KB 0.00 KB (0.00%)
cache.ts 11.73 KB 11.73 KB 0.00 KB (0.00%)
config.ts 23.06 KB 23.06 KB 0.00 KB (0.00%)
differ.ts 22.20 KB 22.20 KB 0.00 KB (0.00%)
http-client.ts 27.42 KB 27.42 KB 0.00 KB (0.00%)
http-router.ts 37.13 KB 37.13 KB 0.00 KB (0.00%)
logger.ts 12.19 KB 12.19 KB 0.00 KB (0.00%)
metric.ts 9.64 KB 9.64 KB 0.00 KB (0.00%)
optic.ts 7.21 KB 7.21 KB 0.00 KB (0.00%)
pubsub.ts 16.21 KB 16.21 KB 0.00 KB (0.00%)
queue.ts 13.09 KB 13.09 KB 0.00 KB (0.00%)
schedule.ts 11.99 KB 11.99 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 14.29 KB 14.29 KB 0.00 KB (0.00%)
schema-binary.ts 42.38 KB 42.38 KB 0.00 KB (0.00%)
schema-class.ts 21.96 KB 21.96 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.89 KB 29.89 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 28.22 KB 28.22 KB 0.00 KB (0.00%)
schema-string-transformation.ts 15.54 KB 15.54 KB 0.00 KB (0.00%)
schema-string.ts 12.50 KB 12.50 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.42 KB 16.42 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 39.16 KB 39.16 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 26.36 KB 26.36 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 21.11 KB 21.11 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 21.28 KB 21.28 KB 0.00 KB (0.00%)
schema-toFormatter.ts 21.37 KB 21.37 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 26.38 KB 26.38 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 21.45 KB 21.45 KB 0.00 KB (0.00%)
schema.ts 21.05 KB 21.05 KB 0.00 KB (0.00%)
stm.ts 14.40 KB 14.40 KB 0.00 KB (0.00%)
stream.ts 10.56 KB 10.56 KB 0.00 KB (0.00%)

@gabedemesa gabedemesa changed the title Fix optional records in OpenAI and Anthropic structured output codecs Fix optional records and tuples in OpenAI and Anthropic structured output codecs Nov 9, 2026
…tput codecs

An optional property becomes a required nullable property through
optionalToNullable. Its decoded side reused the property's encoded AST,
so a provider conversion ran twice: a record's pair array, or a tuple's
numeric-key object, decoded once and then failed the second link with
"Expected array" or "Expected object". The decoded side is now the
property's type side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@gabedemesa
gabedemesa force-pushed the fix-structured-output-optional-record branch from 47efcc3 to aa1c981 Compare November 9, 2026 20:50
@tim-smart
tim-smart merged commit f665267 into Effect-TS:main Nov 9, 2026
14 of 15 checks passed

This branch is waiting to be deployed

1 waiting deployment
fork — aa1c9817 Waiting Nov 9, 2026 by gabedemesa via approval-gate #25614
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants