Skip to content

[Needs design] Update openai provider types - #441

Draft
github-actions[bot] wants to merge 1 commit into
mainfrom
update-openai-provider-types-442ed9df-33418592787
Draft

github-actions[bot] wants to merge 1 commit into
mainfrom
update-openai-provider-types-442ed9df-33418592787

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated update of Lingua provider types.

Provider: openai

Publication mode: needs-design draft

Feedback: comment /bt good or /bt bad to log review feedback to the Braintrust trace.

Human decisions required

The agent completed every unblocked item. These decisions require human input before the update can be finished:

  • FunctionToolCallOutput.name, FunctionToolCallOutput.namespace, FunctionCallOutputItemParam.name, FunctionCallOutputItemParam.namespace (and the BetaFunctionToolCallOutput / BetaFunctionCallOutputItemParam mirrors) (openai-function-call-output-name-namespace)

    • Question: How should a namespace-qualified tool-result identity be represented in the universal format, and what must the response-import path do with a function_call_output output item? Concretely, pick one: (a) add a first-class 'namespace: Option' to ToolResultContentPart, and correspondingly promote the existing function_call namespace out of the provider_options marker into AssistantContentPart::ToolCall so the two directions are symmetric, then decide explicitly whether Anthropic and Google hard-error on a namespaced tool result or reuse Anthropic's existing flatten-and-error-on-collision policy; or (b) declare namespace an OpenAI-Responses-only field with no universal representation, and return an explicit unsupported-mapping error when importing a tool result that carries one, accepting that such a request cannot round-trip. Additionally, and coupled to the same answer because one match arm must handle both: should the response-import path gain a FunctionCallOutput / CustomToolCallOutput arm producing a Message::Tool, or return an explicit error, instead of today's silent skip?
    • Evidence: The spec added name and namespace to the function-call-output schemas in four hunks: specs/openai/openapi.yml:58269 and :97344 (plain 'type: string') and :86999 and :94730 (anyOf-with-null, name maxLength 128 / minLength 1, namespace maxLength 64 / minLength 1 / pattern ^[a-zA-Z0-9_-]+$). The generated diff is doc-comment-only (crates/lingua/src/providers/openai/generated.rs:6017-6019 and 6032-6034) because OutputItem.name (generated.rs:6031) and OutputItem.namespace (generated.rs:6040) already existed via sibling union members, so nothing fails to compile and no test currently notices. The universal type has no home for namespace: ToolResultContentPart at crates/lingua/src/universal/message.rs:219-227 declares exactly tool_call_id, tool_name, output, custom_tool_call, caller, provider_options. Today namespace is dropped in every direction - import hardcodes provider_options: None at convert.rs:1679 while the sibling function_call branch preserves it at convert.rs:1642, and all three export sites set name then '..Default::default()' at convert.rs:2639, 2916, and 3869, which is also why adding a spec field produced no compiler error. The response-import match has no arm for these item types at all: they appear only as export constants at convert.rs:3856-3858, so such an item hits the wildcard at convert.rs:3805-3808 ('Skip unknown output item types' then continue) and is discarded whole, losing name as well. Reusing provider_options is not an available shortcut: AGENTS.md states 'No hidden marker fields: Do not encode provider semantics via internal marker keys (for example in provider_options) to fake lossless roundtrips', and the existing function_call use is a marker by construction - written at convert.rs:832-843, read back only by openai_tool_call_provider_options_view at convert.rs:821-830 to write the identical string out again, with no cross-provider adapter reading that key. That (namespace, name) is a real identity pair rather than decoration is proved by Anthropic's own collision error at crates/lingua/src/providers/anthropic/tool_discovery.rs:445-452, "cannot convert Responses tools with duplicate local tool name '{}' from namespace '{}' and ...", which only makes sense if the same local name in two namespaces is a different tool; Anthropic models namespaces only on tool DEFINITIONS and flattens them (tool_discovery.rs:379-407, 422-462) with no per-result namespace on tool_result, and Google has no namespace concept anywhere under crates/lingua/src/providers/google/. Coverage cannot be relied on to catch a wrong choice: the closest existing test, responses_programmatic_items_and_callers_export_to_input_json at crates/lingua/src/providers/openai/responses_adapter.rs:2000, passes today with namespace dropped, and crates/coverage-report/src/requests_expected_differences.json already blanket-excuses messages[].content[].tool_name at :477 and messages[].content[].provider_options at :92, :101, :152, :481 for exactly the responses_to_anthropic and responses_to_google pairs a new case would exercise.
    • Recommended option: Option (a): add a first-class 'namespace: Option' to ToolResultContentPart, wire it through all four OpenAI directions, and in the same change promote the function_call namespace out of provider_options into AssistantContentPart::ToolCall so calls and results are symmetric and the marker is retired. For the coupled response-import question, add a real FunctionCallOutput / CustomToolCallOutput arm mirroring the InputItem branch at convert.rs:1653-1685, because the current silent skip already violates the AGENTS.md rule 'Reject unsupported mappings explicitly ... Do not silently drop it'. For Anthropic and Google, return an explicit unsupported-mapping error when a tool result carries a namespace, rather than flattening: flattening a RESULT cannot be made safe the way Anthropic's definition-side flattening is, since a result has no sibling set against which to detect a collision, so silently flattening would change the tool identity the model sees. Rationale for preferring (a): namespace is genuinely provider-neutral tool-addressing information rather than provider-managed operational state, it already has a partial universal presence on the call side, and (a) is the only option that removes an AGENTS.md violation instead of extending it. The main cost is that it touches a public universal type and its TypeScript bindings, and that retiring the function_call marker is a behavioural change for existing consumers reading provider_options['namespace'] - if that migration is unacceptable in this update, take (b) and file the marker retirement separately rather than leaving both mechanisms live.
    • Alternatives:
      • Option (a) - first-class 'namespace: Option' on ToolResultContentPart, plus promoting the function_call namespace out of provider_options into AssistantContentPart::ToolCall. Tradeoffs: the only fully non-lossy answer and the only one that retires an existing hidden-marker violation, and it makes calls and results symmetric; but it changes a public universal type and its generated TypeScript bindings, forces an explicit Anthropic and Google decision, and breaks any consumer currently reading provider_options['namespace'] on tool calls. Also requires deciding whether the maxLength 64 and pattern ^[a-zA-Z0-9_-]+$ constraints are validated at the boundary or passed through.
      • Option (b) - declare namespace OpenAI-Responses-only, add no universal field, and return an explicit unsupported-mapping error on importing a tool result that carries one. Tradeoffs: smallest diff, no public universal type change, and it is honest rather than silent; but it makes a valid OpenAI request non-importable, which sits badly against the AGENTS.md cross-provider policy that 'Provider-native types and same-format passthrough should preserve valid provider-specific fields even when cross-provider conversion rejects them', and it leaves calls preserving namespace while results reject it - an asymmetry that will confuse users.
      • Option (c) - copy input.namespace into ToolResultContentPart.provider_options at convert.rs:1679 and read it back on the three export paths, mirroring the function_call precedent. Tradeoffs: by far the smallest diff, restores OpenAI-to-OpenAI round-trip fidelity, and is consistent with existing code; but it is explicitly forbidden - it is a round-trip-only marker under AGENTS.md 'No hidden marker fields', it extends rather than repays existing debt, no cross-provider adapter can act on it, and the expected-difference entries at requests_expected_differences.json:92, :101, :152, :481 would hide any future regression in it. Listed for completeness and NOT recommended; two of the four audits proposed it and it should be rejected deliberately rather than by omission.
      • Option (d) - preserve namespace only on the OpenAI-to-OpenAI same-format passthrough path (which already works via the InputItem<->OutputItem bridge at convert.rs:3294-3295) and error on any cross-provider conversion of a namespaced tool result, adding no universal field. Tradeoffs: cleanly separates representation from transformability exactly as the AGENTS.md cross-provider policy describes, and needs no public type change; but it does not fix the universal-hop loss for OpenAI-to-OpenAI flows that pass through universal (the actual reported gap), so it is a partial answer only.
    • Likely files:
      • crates/lingua/src/universal/message.rs
      • crates/lingua/src/providers/openai/convert.rs
      • crates/lingua/src/providers/openai/responses_adapter.rs
      • crates/lingua/src/providers/anthropic/adapter.rs
      • crates/lingua/src/providers/anthropic/tool_discovery.rs
      • crates/lingua/src/providers/google/adapter.rs
      • crates/coverage-report/src/requests_expected_differences.json
      • payloads/cases/advanced.ts
      • bindings/
    • Validation commands:
      • cargo test -p lingua --lib providers::openai::convert
      • cargo test -p lingua --lib providers::openai::responses_adapter
      • cargo test -p lingua --lib providers::anthropic
      • cargo test -p lingua --lib providers::google
      • cargo test -p lingua --lib universal
      • cargo test -p lingua --test import_fixtures
      • cargo test -p lingua --test fuzz
      • cargo test -p coverage-report --test cross_provider_test cross_provider_transformations_have_no_unexpected_failures
      • make typed-boundary-check
      • make test-payloads
  • ModelIdsShared.anyOf[1].enum += gpt-5.5 (specs/openai/openapi.yml:62020) and BetaModelIdsShared.anyOf[1].enum += gpt-5.5 (specs/openai/openapi.yml:96173) (openai-model-ids-shared-gpt-5-5)

    • Question: Which reasoning-effort family does gpt-5.5 belong to - specifically, does it support ReasoningEffort::Minimal, and does it support ReasoningEffort::Max? The answer selects one of the existing EffortFamily variants in crates/lingua/src/providers/openai/capabilities.rs (NoneLowMediumHighXhigh, as gpt-5.4 uses, versus NoneLowMediumHighXhighMax, as gpt-5.6 uses, versus a new variant), which in turn determines whether Minimal clamps to Low and whether Max clamps to Xhigh. Secondary question for the same fix: should the family matcher be corrected in place by adding a '5' arm, or rewritten to parse the minor version numerically the way crates/braintrust-llm-router/src/catalog/spec.rs:55-69 already does, which would additionally close the latent misclassification of gpt-5.7 and above and of a future gpt-5.14?
    • Evidence: gpt-5.5 was added to ModelIdsShared at specs/openai/openapi.yml:62020 and BetaModelIdsShared at :96173. There is no generated model enum - grep for ModelIdsShared in crates/lingua/src/providers/openai/generated.rs returns zero matches and every model field is String or Option (generated.rs:148, 675, 1332, 1563, 1606, 1718, 1904, 5468; params.rs:22, 85, 108) - so the identifier was already accepted on the wire and needs no table row; what the spec change does is make an official model out of an identifier that the capability matcher silently fails to classify. Traced arm by arm in reasoning_effort_family_for_model (capabilities.rs:174-203) for 'gpt-5.5': gpt_5_point_release_suffix (capabilities.rs:168-172) yields Some("5"), then the '6' arm at capabilities.rs:178 misses, the '4' or '2' arm at capabilities.rs:180-181 misses, the '3-codex' arm at :188 misses, the '1-codex' arm at :190 misses, the '1' arm at :192 misses, and the final fallback at capabilities.rs:194-198 also misses because model.starts_with("gpt-5-") fails on a dot separator. The function returns None at capabilities.rs:201, and clamp_reasoning_effort_for_model early-returns the effort unclamped at capabilities.rs:210-212, so Minimal and Max are emitted verbatim - whereas gpt-5.4 clamps Max to Xhigh and Minimal to Low (capabilities.rs:219-223, asserted at capabilities.rs:495-496). The two call sites are adapter.rs:552 and responses_adapter.rs:1045, both canonical or cross-provider emission rather than same-provider raw extras, per the doc comment at capabilities.rs:205-208. The dashed Databricks form databricks-gpt-5-5 fails identically via capabilities.rs:171. The correct family is not derivable from this diff: the reasoning-effort enum in the spec is a single global list untouched by all 11 hunks, so it carries no per-model information, and gpt-5.5's neighbours bracket different answers for both Minimal and Max. The existing test table test_clamp_reasoning_effort_for_model at capabilities.rs:488-540 covers point releases 5.1 through 5.6 but has no gpt-5.5 row and no gpt-5.7 row, so the hole is untested in both directions. By contrast the router already handles gpt-5.5 correctly by parsing the minor numerically (crates/braintrust-llm-router/src/catalog/spec.rs:55-69, asserted for gpt-5.5-chat-latest at spec.rs:91 and openai.gpt-5.5 at spec.rs:96), and supports_prompt_cache_breakpoint likewise parses numerically and correctly returns false for gpt-5.5 (capabilities.rs:78-95, tested at capabilities.rs:435) - which is the clearest evidence that the character-prefix matching in reasoning_effort_family_for_model is the defect rather than an intentional design.
    • Recommended option: Confirm gpt-5.5's supported reasoning efforts against OpenAI's model documentation or a single live probe, then rewrite reasoning_effort_family_for_model to parse the minor version numerically rather than by first character, mirroring the approach already proven in crates/braintrust-llm-router/src/catalog/spec.rs:55-69. Rewriting rather than adding a '5' arm is recommended because the current design has the same latent hole for gpt-5.7 and above and, worse, an ordering bug: the '4' or '2' arm at capabilities.rs:180 is tested before the '1' arm at :192 and matches on the first character of the suffix, so a future gpt-5.14 would be misclassified into the '1' family. If the documentation is unambiguous that gpt-5.5 sits between gpt-5.4 and gpt-5.6 in effort support, NoneLowMediumHighXhigh (the gpt-5.4 family, clamping Minimal to Low and Max to Xhigh) is the most likely answer, but do NOT adopt that without confirmation - guessing a clamp target is exactly the unapproved fallback logic AGENTS.md prohibits, and the current fail-open behaviour at least surfaces a clear provider-side rejection rather than silently altering the caller's requested effort.
    • Alternatives:
      • Assign gpt-5.5 to EffortFamily::NoneLowMediumHighXhigh (the gpt-5.4 family) by adding a '5' arm to capabilities.rs:174-203. Tradeoffs: smallest correct-shaped diff and consistent with the immediately preceding point release; but it is a guess unless confirmed, it silently rewrites a caller's Max to Xhigh which is wrong and hard to debug if gpt-5.5 in fact supports Max, and it leaves the gpt-5.7-and-above hole and the gpt-5.14 ordering bug in place.
      • Assign gpt-5.5 to EffortFamily::NoneLowMediumHighXhighMax (the gpt-5.6 family). Tradeoffs: fails safe on Max by passing it through while still correctly clamping Minimal to Low; but it is equally a guess, and if gpt-5.5 does not support Max the provider rejects the request, which is no better than today's behaviour for that value.
      • Rewrite the matcher to parse the minor version numerically, mirroring crates/braintrust-llm-router/src/catalog/spec.rs:55-69, and drive families from an explicit minor-version range table. Tradeoffs: fixes gpt-5.5, the untested gpt-5.7-and-above hole, and the gpt-5.14 ordering bug in one change, and aligns the capability layer with the router that already got this right; but it is a larger diff touching classification for every gpt-5.x model, so it needs the full existing table at capabilities.rs:488-540 plus new rows as a regression net, and it still requires the same answer about which family gpt-5.5 lands in.
      • Leave the fall-through as-is and document it as intentional fail-open. Tradeoffs: zero risk of silently altering a caller's requested effort, and any unsupported value produces a clear provider-side rejection rather than a wrong local clamp; but it is inconsistent with every adjacent point release, it means the clamp helper silently does nothing for a now-official model, and it leaves the inconsistency to be rediscovered at the next point release.
    • Likely files:
      • crates/lingua/src/providers/openai/capabilities.rs
      • crates/lingua/src/providers/openai/adapter.rs
      • crates/lingua/src/providers/openai/responses_adapter.rs
      • crates/braintrust-llm-router/src/catalog/spec.rs
    • Validation commands:
      • cargo test -p lingua --lib providers::openai::capabilities
      • cargo test -p lingua --lib providers::openai::capabilities::tests::test_clamp_reasoning_effort_for_model
      • cargo test -p lingua --lib providers::openai::responses_adapter
      • cargo test -p lingua --lib providers::openai::adapter
      • cargo test -p braintrust-llm-router model_requires_responses_api
      • cargo check -p lingua

Validation

  • ./pipelines/generate-provider-types.sh openai: success
  • make generate-types PROVIDER=openai: success
  • Braintrust workflow trace: success
  • Claude integration plan: success
  • Structured plan validation: success
  • Human design blockers: true
  • Claude focused implementation: success
  • Immutable plan revalidation: success
  • Initial post-implementation Rust regeneration: success
  • Initial provider update path policy: success
  • Initial provider semantic policy: success
  • Initial post-implementation TypeScript regeneration: success
  • Initial formatting: success
  • Initial focused provider tests: success
  • Initial conditional generator tests: success
  • Initial build: success
  • Initial clippy: success
  • Bounded Claude mechanical repair: skipped
  • Effective mechanical validation source: initial
  • Effective mechanical validation: success
  • make lingua-wasm: success
  • Planned live capture cases: ``
  • Live capture (OpenAI): skipped
  • Live capture (Anthropic): skipped
  • Live capture (Google): skipped
  • Planned cross-provider transform capture: skipped
  • Payload fixture sync: success
  • make test-payloads: success
  • make typed-boundary-check: success
  • cargo test -p coverage-report --test cross_provider_test cross_provider_transformations_have_no_unexpected_failures: success
  • Claude read-only verification: success
  • Verification report validation: success
  • Verification verdict: fail
  • Recoverable binary patch archive: success

Ready PRs have no blockers and a passing verification verdict. Draft PRs may contain explicit human design blockers, but every automated safety and deterministic validation still passed. Failed runs retain the exact binary patch in the workflow artifact for manual recovery.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant