Skip to content

[Needs design] Update google provider types - #415

Draft
github-actions[bot] wants to merge 1 commit into
mainfrom
update-google-provider-types-394a94b7-30940246254
Draft

[Needs design] Update google provider types#415
github-actions[bot] wants to merge 1 commit into
mainfrom
update-google-provider-types-394a94b7-30940246254

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Automated update of Lingua provider types.

Provider: google

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:

  • Part.mediaResolution / V1mainMediaResolution (part-media-resolution-ref-retarget)

    • Question: What canonical public type name should the Part-level media-resolution object carry, now that Google publishes it under the internal Discovery surface id V1mainMediaResolution and the unprefixed name MediaResolution has been taken over by the GenerationConfig enum? Decide the name and the generator rule that produces it, for both the Rust type and the TypeScript file.
    • Evidence: specs/google/discovery.json:369 retargets Part.mediaResolution from the dangling "MediaResolution" to "V1mainMediaResolution", published at :8426-8449 with the same five levels including MEDIA_RESOLUTION_ULTRA_HIGH. quicktype derives public names verbatim from the Discovery schema id, and crates/generate-types/src/main.rs has no prefix normalization for type names - preserve_google_public_enum_variant_names (:2102-2131) rewrites enum variant names only, and only for Type and FunctionCallingConfigMode. The result is generated.rs:425 pub struct V1MainMediaResolution and a new public file bindings/typescript/src/generated/google/V1MainMediaResolution.ts, with MediaResolution.ts deleted. V1main is Google's internal API-surface disambiguator carrying no semantic meaning, and upstream is already emitting more of them (V1mainCreateTunedModelMetadata, V1mainTuningSnapshot in this same diff), so this is a recurring pattern rather than a one-off. The wire form is unchanged and non-lossy in every direction, so this is purely a public-naming decision. Renaming later is a breaking change for anyone deep-importing the TypeScript file, which is why it should be settled before merge.
    • Recommended option: Add a Google type-name normalization rule in crates/generate-types/src/main.rs that strips the V1main surface prefix from Discovery schema ids, and pin the GenerationConfig enum back to MediaResolutionEnum in the same rule so the object keeps the unprefixed name MediaResolution. That makes the whole media-resolution diff a no-op on public names - only the upstream doc-comment text changes - so no downstream Rust or TypeScript consumer breaks, and it generalizes to the other V1main ids Google already ships. Remove the dead google_missing_discovery_schema shim (main.rs:1782-1813) first, in the same change, so the two cannot both define the name MediaResolution.
    • Alternatives:
      • Accept V1MainMediaResolution and additionally pin the enum's name so at least the slot swap is prevented. Tradeoff: cheapest and honest to the upstream id, but it leaks a meaningless Google-internal prefix into Lingua's public Rust and TypeScript surface, breaks any consumer importing MediaResolution.ts, and sets a precedent that every future V1main id becomes a public V1Main type. Explicitly ruled out as a compatibility solution.
      • Strip the V1main prefix but leave the GenerationConfig enum unpinned as MediaResolution. Tradeoff: fails immediately - both types would want the same name, so generation would collide or quicktype would silently re-add a suffix to one of them, reintroducing the same instability from the other direction.
      • Give the object a hand-chosen semantic name such as PartMediaResolution or MediaResolutionSpec, keeping the enum as MediaResolution. Tradeoff: reads clearly and removes all ambiguity between the two concepts, but it is a name with no upstream basis so it needs its own mapping-table entry that a maintainer must remember, and it still breaks importers of MediaResolution.ts.
      • Pin only the enum name and let the object name follow upstream ids automatically. Tradeoff: minimal ongoing maintenance and no mapping table, but the object's public name then changes whenever Google renames its internal surface - exactly the churn this diff demonstrates.
    • Likely files:
      • crates/generate-types/src/main.rs
      • crates/lingua/src/providers/google/generated.rs
      • bindings/typescript/src/generated/google/V1MainMediaResolution.ts
      • bindings/typescript/src/generated/google/Part.ts
      • bindings/typescript/src/generated/google/Level.ts
      • crates/lingua/src/providers/google/convert.rs
    • Validation commands:
      • make generate-types PROVIDER=google
      • git diff --exit-code bindings/typescript/src/generated/
      • cargo test -p generate-types google_post_process_tests
      • cargo test -p lingua providers::google::convert::tests
      • cargo check -p lingua
      • cd bindings/typescript && pnpm run typecheck
  • MediaResolutionEnum -> MediaResolution (media-resolution-enum-name-slot-swap)

    • Question: Independently of the prefix question: should the GenerationConfig media-resolution enum keep a pinned distinct name so the public identifier MediaResolution can never again silently change which type it refers to, and should that be enforced by a generator rule plus a compile-time test?
    • Evidence: The GenerationConfig.mediaResolution inline enum is unchanged upstream - still exactly four values with no ULTRA_HIGH at specs/google/discovery.json:5360-5375, which I verified by reading the hunk. Yet its generated Rust name changed from MediaResolutionEnum to MediaResolution (generated.rs:599, :754), because the Enum suffix was only ever quicktype's collision avoidance against the MediaResolution object; once the object was renamed the suffix was dropped. The identifier MediaResolution therefore still exists but now denotes a bare string enum instead of a struct with a level field. A downstream use would type-shift rather than fail to resolve, and nothing in the repo imports either name today - grep across crates and bindings finds no non-generated user except the generator shim at crates/generate-types/src/main.rs:1784-1787 - so neither the compiler nor any existing test would catch it. Compounding the confusion, the two media-resolution enums have different value sets: Level has five variants including MediaResolutionUltraHigh (generated.rs:437-449) while the GenerationConfig enum has four (:754-763), so they are not interchangeable and any future part-to-request normalizer would be lossy for ULTRA_HIGH.
    • Recommended option: Yes - pin it. Extend the name-preservation logic in crates/generate-types/src/main.rs to cover type names as well as enum variant names, fixing this enum's public name explicitly, and add a focused test in crates/lingua/src/providers/google/convert.rs that constructs GenerationConfig { media_resolution: Some(..) } and asserts the value serializes to the scalar string "MEDIA_RESOLUTION_LOW" rather than an object. Because that test uses the type positionally as an enum it fails to compile if the identifier ever reverts to naming a struct, converting a silent semantic swap into a build failure. Resolve this together with part-media-resolution-ref-retarget, since the two symbols compete for the same identifier and cannot be decided separately.
    • Alternatives:
      • Accept the swap and rely on review to catch future recurrences. Tradeoff: zero implementation cost, but the failure mode is a silent type shift with no compiler or test signal, and this diff is the proof that it happens without anyone intending it.
      • Rename the enum to something unambiguous such as GenerationConfigMediaResolution and free MediaResolution for the object. Tradeoff: maximally clear at the call site and removes the collision permanently, but it is a longer invented name with no upstream basis and it changes a public Rust type name consumers may already use.
      • Merge the two into one enum covering all five levels. Tradeoff: superficially simpler but wrong and lossy in the request direction - the GenerationConfig field genuinely does not accept MEDIA_RESOLUTION_ULTRA_HIGH per specs/google/discovery.json:5360-5375, so a unified enum would let callers build requests Google rejects and would erase a real upstream distinction.
      • Add a deprecated alias pub type MediaResolutionEnum = MediaResolution; for source compatibility. Tradeoff: eases migration for external consumers, but it does not address the underlying hazard - the MediaResolution identifier itself still silently changed meaning - and it would have to be hand-maintained in generated output, which is prohibited.
    • Likely files:
      • crates/generate-types/src/main.rs
      • crates/lingua/src/providers/google/generated.rs
      • crates/lingua/src/providers/google/convert.rs
    • Validation commands:
      • cargo test -p generate-types google_post_process_tests
      • cargo test -p lingua providers::google::convert::tests
      • cargo check -p lingua
      • make generate-types PROVIDER=google
      • git diff --exit-code crates/lingua/src/providers/google/generated.rs
  • ToolCall.toolName (tool-call-tool-name)

    • Question: What is the canonical universal representation for Google server-side (provider-executed) tool calls - Part.toolCall and Part.toolResponse - given that a ToolCall now carries both an optional builtin tool_type discriminator and an optional free-form toolName, while the universal AssistantContentPart::ToolCall has a non-optional tool_name and no field able to carry tool_type?
    • Evidence: specs/google/discovery.json:7904-7907 adds the optional toolName, generated at generated.rs:472-474. Google's ToolCall is explicitly not a FunctionCall: the doc at generated.rs:455-459 states the client must NOT execute it and must instead pass it back in a subsequent turn. Its builtin identity is tool_type, a closed enum of FILE_SEARCH, GOOGLE_MAPS, GOOGLE_SEARCH_IMAGE, GOOGLE_SEARCH_WEB, URL_CONTEXT and TOOL_TYPE_UNSPECIFIED (generated.rs:486-501), optional in the spec despite the doc calling it required. Lingua converts none of this: Part.tool_call and Part.tool_response are never read, because convert.rs:255-336 and :350-430 are closed if/else chains over text, executable_code, code_execution_result, function_call, inline_data, file_data and function_response, generated::ToolCall is not imported at convert.rs:13-19, adapter.rs:19 or params.rs:8, every Part literal at convert.rs:572-680 uses ..Default::default(), and streaming uses GoogleStreamPart (adapter.rs:82) which has no toolCall field. So a server-side tool call is silently dropped in every direction and toolName enlarges that drop. Mapping onto AssistantContentPart::ToolCall as built at convert.rs:313-322 is lossy in all four states of the two optionals: tool_name there is a non-optional String so states with no name have nothing to supply and synthesizing one is a prohibited fallback, and there is no universal field for tool_type. provider_executed: Option exists at convert.rs:321 and is the right flag but carries no builtin identity. ToolResponse (generated.rs:512-524) has no toolName, so correlation on the way back is id plus tool_type only. Note also that Part has no deny_unknown_fields, so a consumer pinned to older types drops toolName silently rather than erroring - detection cannot be used as a version guard.
    • Recommended option: Decide in two steps, keeping the generated field as-is meanwhile since adding it is non-lossy and correct on its own. First, make the universal tool-call part able to represent a provider-executed builtin natively: reuse the builtin discriminator that already exists for tool declarations (UniversalToolType::Builtin, see convert.rs:916 and :1019) by lifting an equivalent builtin identity onto tool-call parts, and make the universal tool_name optional rather than inventing a name when Google supplies none. Then map Part.toolCall to that representation with provider_executed: Some(true), treating the correlation key for server-side tools as id plus builtin identity rather than name, because ToolResponse cannot echo a name. Until the universal shape is agreed, do not wire Part.toolCall into convert.rs at all: the only alternatives to an agreed representation are a silent drop or a synthesized name, both prohibited.
    • Alternatives:
      • Map Part.toolCall onto the existing AssistantContentPart::ToolCall using toolName when present and a name derived from tool_type otherwise. Tradeoff: no universal type change and immediate round-tripping for the common case, but it fabricates a name where Google supplies none, silently conflates provider-executed builtins with client-executed function calls, and still discards tool_type - a fallback and a silent coercion, both prohibited.
      • Reject Part.toolCall with an explicit TransformError until a representation exists. Tradeoff: replaces today's silent drop with a loud, honest failure and is fully compliant with the typed-boundary rules, but it turns payloads that currently transform lossily into hard errors, a visible regression for anyone relying on Google server-side tools today. Worth considering as a deliberate interim step, and strictly better than the silent drop.
      • Leave Part.toolCall unconverted as today and record the lossiness explicitly in the coverage expected-difference files. Tradeoff: lowest risk, no new surface, and it at least makes the gap visible instead of invisible, but it permanently forgoes Google server-side tool support and documents a hole rather than closing it.
      • Introduce a Google-specific universal part variant for server-side tool calls rather than extending the shared one. Tradeoff: fastest route to non-lossy Google round-tripping without disturbing other providers, but it fragments the universal model along provider lines, and OpenAI and Anthropic both have provider-executed builtin tools that would eventually want the same shared representation.
    • Likely files:
      • crates/lingua/src/providers/google/convert.rs
      • crates/lingua/src/providers/google/adapter.rs
      • crates/lingua/src/providers/google/generated.rs
      • bindings/typescript/src/generated/google/ToolCall.ts
      • crates/coverage-report/src/responses_expected_differences.json
      • payloads/cases/advanced.ts
    • Validation commands:
      • cargo test -p lingua providers::google::convert::tests
      • cargo test -p lingua providers::google::
      • cargo test -p coverage-report --test cross_provider_test cross_provider_transformations_have_no_unexpected_failures
      • make test-payloads
      • make typed-boundary-check
      • cd bindings/typescript && pnpm run typecheck
  • GenerationConfig.audioTranscriptionConfig / AudioTranscriptionConfig (generation-config-audio-transcription-config)

    • Question: How should GenerationConfig.audioTranscriptionConfig be represented so it is not silently lost - by introducing canonical universal audio-transcription semantics, or by making unmapped generationConfig subfields survive the google-to-universal-to-google round trip through a typed provider-scoped carrier? And is the field even accepted by the REST generateContent endpoint?
    • Evidence: specs/google/discovery.json:5323-5326 adds GenerationConfig.audioTranscriptionConfig, and :2449-2489 adds customVocabulary, diarization, languageCodes and wordTimestamp while deprecating adaptationPhrases, languageAuto and languageHints. Generated at generated.rs:559-561, :685-711 and :722-728. The value cannot survive a round trip: adapter.rs:137-185 lifts exactly six values out of generation_config (max_output_tokens, thinking_config, stop_sequences, temperature, top_p, top_k) and discards everything else, and the extras hatch is populated from GoogleParams.extras, which is #[serde(flatten)] at params.rs:42-43 and captures only unknown TOP-LEVEL keys. Because generationConfig is a known typed key the field is parsed into the typed struct and then dropped, with export at adapter.rs:463-465 able to merge back only top-level extras. I verified this reading of extras directly. crate::universal::request has no audio-transcription concept - UniversalParams as constructed at adapter.rs:202-226 has no field for diarization, word timestamps, custom vocabulary or BCP-47 hints - so there is no non-lossy universal representation. This is systemic rather than new: generationConfig.mediaResolution is lost the same way today, and payloads/snapshots/mediaResolutionParam is google-only with no cross-provider transform artifacts, so nothing tests it. Separately, whether the field is real REST surface is unverified: @google/genai@1.51.0 exposes AudioTranscriptionConfig only on Live-API configs via inputAudioTranscription/outputAudioTranscription, never on GenerationConfig. Note also that language_auto degrades to an untyped serde_json::Map at generated.rs:702-704 because LanguageAuto has empty properties, an untyped hole at a typed boundary even though the field is deprecated upstream.
    • Recommended option: Split the question. First resolve the systemic half, because it is higher value and subsumes this field: add a typed provider-scoped carrier for unmapped generationConfig subfields so anything Lingua does not lift into universal params still round-trips google-to-universal-to-google, pinned by a params-level test asserting the subtree is identical after from_value/to_value with extras empty. That closes this field, mediaResolution, and every future generationConfig addition in one place, without inventing universal semantics for a niche speech-recognition knob and without any marker field - the carrier holds real typed provider config, not an opaque round-trip token. Second, before adding any payload case, run make capture FILTER=audioTranscriptionConfigParam to determine whether REST generateContent accepts the field at all; if it rejects it as Live-API-only surface, the correct outcome is no payload case and a recorded divergence, leaving only the generated type. Defer canonical universal audio-transcription semantics until a second provider needs them.
    • Alternatives:
      • Add canonical universal audio-transcription fields (diarization, word timestamps, custom vocabulary, language hints) to the universal request now. Tradeoff: fully non-lossy and cross-provider capable, and the only option that lets the config reach a non-Google target, but it commits the universal model to speech-recognition semantics on the evidence of one provider, and OpenAI and Anthropic have no counterpart on their chat request surfaces to validate the shape against.
      • Declare it Google-only and accept the loss, recording it in the coverage expected-difference files. Tradeoff: zero new surface and consistent with how generationConfig.mediaResolution is treated today, but a Google request passing through Lingua would silently come out without the caller's transcription settings - the silent-loss pattern this update exists to eliminate.
      • Lift the whole typed generation_config into provider extras verbatim on import and merge it back on export. Tradeoff: mechanically simple and instantly non-lossy for every subfield, but it duplicates the six values already lifted into universal params, creating two sources of truth for temperature and friends and an ordering question about which wins on export.
      • Give GoogleParams.generation_config its own nested #[serde(flatten)] extras map. Tradeoff: a narrow local fix preserving the typed boundary for known fields while catching unknown subkeys, but it only helps subfields Lingua has not yet typed - audioTranscriptionConfig is now typed, so it would still be dropped, meaning this does not actually solve the stated problem.
    • Likely files:
      • crates/lingua/src/providers/google/adapter.rs
      • crates/lingua/src/providers/google/params.rs
      • crates/lingua/src/providers/google/generated.rs
      • payloads/cases/params.ts
      • crates/coverage-report/src/requests_expected_differences.json
    • Validation commands:
      • cargo test -p lingua providers::google::params::tests
      • cargo test -p lingua providers::google::convert::tests
      • cargo test -p lingua providers::google::
      • make typed-boundary-check
      • make test-payloads
      • cargo test -p coverage-report --test cross_provider_test cross_provider_transformations_have_no_unexpected_failures

Validation

  • ./pipelines/generate-provider-types.sh google: success
  • make generate-types PROVIDER=google: 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