feat: audit strict format conversion contracts

This commit is contained in:
elky
2026-06-03 20:27:15 +08:00
parent 5ac153a0bb
commit 7507af5829
28 changed files with 12206 additions and 203 deletions
+229
View File
@@ -0,0 +1,229 @@
# Format Conversion Audit
Last audited: 2026-06-03
This audit tracks `source format -> Canonical -> target format` behavior. It is intentionally stricter than historical best-effort conversion.
Statuses:
- `native`: emitted as a target-native field without semantic change.
- `mapped`: converted through canonical/provider-specific mapping.
- `extension-preserved`: preserved in same-format canonical roundtrip or target-approved extension namespace.
- `lossy-blocked`: conversion fails closed.
- `invalid-enum`: conversion fails closed because a provider enum value is not valid for the target mapping.
- `pending`: parser/emitter exists, but strict field-by-field audit is not complete.
## Implemented Boundary Changes
| Area | Current behavior |
| --- | --- |
| Pure conversion API | `convert_request_pure` and response equivalents do not apply model override or stream policy. |
| Legacy conversion API | `convert_request` / `convert_response` are retained for migration and may still use legacy context behavior. |
| Same-format provider path | Bypasses canonical conversion and copies the parsed JSON object before transport edits. |
| Cross-format same-format-provider path | Uses `convert_request_pure`, then applies model/body/stream edits in transport. |
| Conversion errors | Added `UnsupportedField`, `InvalidEnumValue`, `LossyConversionBlocked`, and `InvalidTargetField`. |
| Reporting | Added `ConversionReport` with field statuses. Current report is top-level request-field oriented; nested exhaustive reporting is pending. |
| Tool schema roundtrip | Claude `input_schema` and Gemini `functionDeclarations.parameters` preserve raw same-format schema through provider-specific extensions. |
| Tool result ids | Chat `tool_call_id`, Responses `call_id`, Claude `tool_use_id`, and Gemini `functionResponse.id` are mapped through canonical tool IDs. |
## OpenAI Chat -> OpenAI Responses
| Chat field | Canonical handling | Responses output | Status |
| --- | --- | --- | --- |
| `model` | request identity | `model` | native |
| `messages` | canonical messages/instructions | `input`, `instructions` | mapped |
| `max_tokens` | generation max tokens | `max_output_tokens` | mapped |
| `max_completion_tokens` | generation max tokens | `max_output_tokens` | mapped |
| `temperature` | generation | `temperature` | native |
| `top_p` | generation | `top_p` | native |
| `top_logprobs` | generation | `top_logprobs` | native |
| `n` | generation but no Responses equivalent | none | lossy-blocked |
| `stop` | generation but no Responses equivalent | none | lossy-blocked |
| `presence_penalty` | generation but no Responses equivalent | none | lossy-blocked |
| `frequency_penalty` | generation but no Responses equivalent | none | lossy-blocked |
| `seed` | generation but no Responses equivalent | none | lossy-blocked |
| `logprobs` | generation but no Responses equivalent | none | lossy-blocked |
| `stream` | OpenAI extension | `stream` | mapped if explicit |
| `stream_options` | Chat-specific extension | none | lossy-blocked |
| `tools[].function.name` | canonical tool | `tools[].name` | mapped |
| `tools[].function.description` | canonical tool | `tools[].description` | mapped |
| `tools[].function.parameters` | canonical tool | `tools[].parameters` | mapped |
| `tools[].function.strict` | canonical tool strict | `tools[].strict` | mapped, implemented |
| assistant `tool_calls[].id` | canonical tool use id | `function_call.call_id` | mapped, implemented |
| tool message `tool_call_id` | canonical tool result id | `function_call_output.call_id` | mapped, implemented |
| `tool_choice` | canonical tool choice | `tool_choice` | mapped |
| `parallel_tool_calls` | canonical bool | `parallel_tool_calls` | native |
| `metadata` | canonical metadata | `metadata` | native |
| `response_format` | canonical response format | `text.format` | mapped |
| `reasoning_effort` | OpenAI enum | `reasoning.effort` | mapped; invalid enum blocked |
| `verbosity` | OpenAI extension | `text.verbosity` | mapped |
| `store` | OpenAI extension | `store` | extension-preserved |
| `service_tier` | OpenAI extension | `service_tier` | extension-preserved |
| `safety_identifier` | OpenAI extension | `safety_identifier` | extension-preserved |
| `prompt_cache_key` | OpenAI extension | `prompt_cache_key` | extension-preserved |
| `user` | legacy Chat user field | none | lossy-blocked |
| unknown top-level fields | OpenAI extension | none unless target-approved | pending strict nested reporting |
## OpenAI Responses -> OpenAI Chat
| Responses field | Canonical handling | Chat output | Status |
| --- | --- | --- | --- |
| `model` | request identity | `model` | native |
| `input` | canonical messages/content/tool I/O | `messages` | mapped |
| `instructions` | canonical instruction/system | `messages` system/developer | mapped |
| `max_output_tokens` | generation max tokens | `max_completion_tokens` | mapped |
| `temperature` | generation | `temperature` | native |
| `top_p` | generation | `top_p` | native |
| `top_logprobs` | generation | `top_logprobs` | native |
| `metadata` | canonical metadata | `metadata` | native |
| `parallel_tool_calls` | canonical bool | `parallel_tool_calls` | native |
| `text.format` | canonical response format | `response_format` | mapped |
| `text.verbosity` | Responses extension | `verbosity` | mapped |
| `tools[].type=function` | canonical tool | `tools[].type=function` | mapped |
| `tools[].name` | canonical tool | `tools[].function.name` | mapped |
| `tools[].parameters` | canonical tool | `tools[].function.parameters` | mapped |
| `tools[].strict` | canonical tool strict | `tools[].function.strict` | mapped, implemented |
| `function_call.call_id` | canonical tool use id | `tool_calls[].id` | mapped, implemented |
| `function_call_output.call_id` | canonical tool result id | tool message `tool_call_id` | mapped, implemented |
| `tools[].type=custom` | raw Responses tool | none | lossy-blocked to Chat |
| `tools[].type=web_search*` | raw Responses tool | none | lossy-blocked to Chat |
| `tool_choice` | canonical tool choice | `tool_choice` | mapped |
| `reasoning.effort` | OpenAI enum | `reasoning_effort` | mapped; invalid enum blocked |
| `reasoning.summary` | Responses-only | none | lossy-blocked |
| `reasoning.budget_tokens` | Responses-only | none | lossy-blocked |
| `stream` | Responses extension | `stream` | extension-preserved |
| `include` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks |
| `previous_response_id` | Responses-only | none | lossy-blocked; legacy emitter no longer leaks |
| `truncation` | Responses-only | none | lossy-blocked |
| `prompt` | Responses-only | none | lossy-blocked |
| `conversation` | Responses-only | none | lossy-blocked |
| `background` | Responses-only | none | lossy-blocked |
| `max_tool_calls` | Responses-only | none | lossy-blocked |
| unknown top-level fields | Responses extension | none unless Chat-approved | pending strict nested reporting |
## Claude Messages <-> OpenAI
Current parser/emitter coverage exists, but strict audit is pending for the third batch.
High-risk fields:
| Claude field | OpenAI target risk | Required status |
| --- | --- | --- |
| `system` with cache blocks | Chat/Responses system instructions | same-format preserved; cross-format `cache_control` loss is blocked |
| `thinking` | OpenAI reasoning | Claude request-level thinking config maps to OpenAI reasoning; message-level thinking blocks are blocked for Responses |
| `cache_control` | OpenAI content/tool extensions | same-format preserved; cross-format blocked when no target equivalent exists |
| `tools[].input_schema` | OpenAI tool parameters | mapped; raw same-format schema preservation implemented |
| `tool_choice.disable_parallel_tool_use` | OpenAI `parallel_tool_calls` | mapped, implemented |
| `tool_result` multi-block content | OpenAI tool output/content | same-format preserved; cross-format to Chat/Responses is lossy-blocked |
| `metadata` / container fields | OpenAI metadata or extension | field-by-field decision pending |
## Gemini GenerateContent <-> OpenAI/Claude
Current parser/emitter coverage exists, but strict audit is pending for the fourth batch.
High-risk fields:
| Gemini field | Target risk | Required status |
| --- | --- | --- |
| `contents[].parts[].thoughtSignature` | OpenAI/Claude thinking | Chat/Claude preserve; Responses cross-format is lossy-blocked |
| `tools[].functionDeclarations` | OpenAI/Claude tool schema | mapped; raw same-format `parameters` preservation implemented |
| `toolConfig.functionCallingConfig.allowedFunctionNames` | OpenAI/Claude tool choice | single-name mapping implemented; multi-name input is lossy-blocked |
| `toolConfig.functionCallingConfig.mode` | OpenAI/Claude tool choice enum | valid enum required; invalid values fail with `InvalidEnumValue` |
| `generationConfig.thinkingConfig.thinkingLevel` | OpenAI/Claude reasoning effort | low/medium/high mapping implemented; invalid values fail closed |
| `safetySettings` | OpenAI/Claude no direct equivalent | lossy-blocked |
| `cachedContent` | OpenAI/Claude no direct equivalent | lossy-blocked |
| `codeExecution` | OpenAI/Claude tool/builtin mismatch | lossy-blocked |
| `generationConfig.responseModalities` | OpenAI/Claude modality mismatch | lossy-blocked |
| `functionResponse.id` | tool result id | conversion preserves id; Gemini upstream cleanup is transport-layer edit only |
## Embedding And Rerank
Fifth batch first pass is implemented for request parse/emit capability and strict target
guards. Nested per-field reporting is still pending.
Embedding source capability:
| Source format | Parsed request shape | Canonical fields | Status |
| --- | --- | --- | --- |
| OpenAI Embedding | `model`, `input`, `encoding_format`, `dimensions`, `user`, `parameters`, `task` | OpenAI-like embedding | mapped |
| Jina Embedding | OpenAI-like plus provider extension namespace | OpenAI-like embedding | mapped |
| Doubao Embedding | OpenAI-like `model` + text `input` | OpenAI-like embedding | mapped |
| Gemini Embedding | single `content.parts[].text` or batch `requests[]` | text input, `dimensions`, `task` | mapped |
| Aliyun Multimodal Embedding | `input.contents[]`, `parameters.dimension` | text/multimodal input, `dimensions`, `parameters` | mapped |
Embedding target guards:
| Target format | Accepted canonical fields | Blocked fields/cases | Status |
| --- | --- | --- | --- |
| OpenAI Embedding | text or token input, `encoding_format`, `dimensions`, `user` | multimodal input, `task`, generic `parameters` | lossy-blocked |
| Jina Embedding | text input, `dimensions`, `task`, `parameters` | token/multimodal input, `encoding_format`, `user` | lossy-blocked |
| Gemini Embedding | text input, `dimensions`, valid `taskType` | token/multimodal input, `encoding_format`, `user`, generic `parameters`, invalid `taskType` | lossy-blocked / invalid-enum |
| Doubao Embedding | text input, `dimensions` | token/multimodal input, `encoding_format`, `user`, `task`, generic `parameters` | lossy-blocked |
| Aliyun Multimodal Embedding | text or multimodal input, `dimensions`, `parameters` | token input, `encoding_format`, `user`, `task` | lossy-blocked |
Cross-format embedding invariants:
- Embedding formats can only convert to embedding formats.
- Unknown provider-specific embedding extension namespaces are blocked cross-format unless the namespace matches the target.
- Aliyun `parameters.dimension` maps to canonical `dimensions` and is not treated as generic `parameters`.
- Gemini batch embedding parse requires every batch item to share the same model, dimensions, and task.
Rerank first pass:
| Area | Current behavior | Status |
| --- | --- | --- |
| Source formats | OpenAI Rerank and Jina Rerank parse OpenAI-like `model`, `query`, `documents`, `top_n`, `return_documents` | mapped |
| Target formats | OpenAI Rerank and Jina Rerank emit OpenAI-like rerank bodies | mapped |
| Boundary | Rerank formats can only convert to rerank formats | lossy-blocked |
| Validation | Empty query/documents and `top_n=0` fail closed | invalid-target-field |
| Extensions | Unknown provider-specific rerank extension namespaces are blocked cross-format | unsupported |
## Sync Response Conversion
Cross-format sync response conversion now validates source stop/finish/status
enums before emitting a target body. Same-format runtime response passthrough is
still outside canonical conversion.
| Source field | Target risk | Current behavior | Status |
| --- | --- | --- | --- |
| Same-format response raw stop/status fields | canonical emitters would otherwise normalize unknown enum/status to default target stop values | raw OpenAI Chat `finish_reason`, OpenAI Responses `status`, Claude `stop_reason`/`stop_sequence`, and Gemini `finishReason` are preserved through provider extension metadata | extension-preserved |
| OpenAI Chat `choices[].finish_reason` | unknown value would otherwise emit as target normal stop | valid Chat enum required; unknown values fail with `InvalidEnumValue` | invalid-enum |
| OpenAI Responses `status` | `queued`, `in_progress`, and `cancelled` have no sync target equivalent | non-terminal valid states fail with `LossyConversionBlocked`; invalid states fail with `InvalidEnumValue` | lossy-blocked / invalid-enum |
| OpenAI Responses `incomplete_details.reason=content_filter` | previously mapped to max tokens/`length` | maps to canonical content filter and emits Chat `content_filter` / Claude `content_filtered` / Gemini `SAFETY` | mapped |
| Claude `stop_reason` | unknown value would otherwise emit as target normal stop | valid known stop enum required for cross-format conversion | invalid-enum |
| Gemini `candidates[].finishReason` | known-but-unmappable reasons would otherwise emit as target normal stop | mappable safety/max/stop reasons convert; known unmappable values such as `OTHER`, `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL`, `MISSING_THOUGHT_SIGNATURE`, and `MALFORMED_RESPONSE` fail with `LossyConversionBlocked`; future unknown values fail with `InvalidEnumValue` | lossy-blocked / invalid-enum |
| Canonical `Unknown` stop reason | target emitters default to normal stop values | cross-format response conversion blocks canonical unknown stop reasons | lossy-blocked |
## Stream Conversion
Sixth batch first pass is implemented for unknown event handling and runtime
same-format boundaries. Sync response finish/status parity has a first strict
pass; stream finish-reason guardrails are implemented for unknown/unmappable
terminal reasons, while nested stream field snapshots still need
provider-by-provider golden fixtures.
Current stream behavior:
| Area | Current behavior | Status |
| --- | --- | --- |
| Provider parsers | OpenAI Chat, OpenAI Responses, Claude, and Gemini unknown stream payloads become `CanonicalStreamEvent::UnknownEvent` | mapped |
| Cross-format stream matrix | Unknown canonical stream events emit a target-format error SSE with `unsupported_stream_event` and terminate conversion | lossy-blocked |
| Stream finish reason guard | Unknown OpenAI finish reasons, unknown Claude `stop_reason`, and Gemini known-but-unmappable `finishReason` values such as `OTHER` are preserved as raw canonical finish strings, then blocked by the matrix with `unsupported_finish_reason` | lossy-blocked |
| OpenAI Responses stream target | Canonical `length` and `content_filter` terminal reasons emit `response.incomplete` with `incomplete_details.reason=max_output_tokens` or `content_filter` instead of `response.completed` | mapped |
| Terminal observer | Unknown provider stream events increment `unknown_event_count`; OpenAI Responses failed events mark terminal error state | mapped |
| Stream -> sync aggregate | Unknown OpenAI Chat, OpenAI Responses, Claude, and Gemini stream events make the runtime finalize checked path return an error and block `body_json` fallback; legacy public aggregate helpers keep `Option` compatibility | lossy-blocked |
| Runtime strict fallback guard | `InvalidEnumValue`, `UnsupportedField`, `LossyConversionBlocked`, and `InvalidTargetField` from registry response conversion are not allowed to fall through legacy conversion helpers | lossy-blocked |
| Runtime same-format stream | Same-format stream passthrough remains outside canonical conversion; stream policy edits are transport-layer only | native |
Stream fixture coverage:
| Provider stream | Covered fixture areas |
| --- | --- |
| OpenAI Chat | sync aggregation for text, tool call IDs/names/argument deltas, finish reason, and usage; cross-format unknown finish/event blocking |
| OpenAI Responses | text snapshot de-duplication, multi-part messages, reasoning/items, function calls, image generation calls, same-family stream sync, unknown event blocking |
| Claude Messages | thinking signatures, tool input deltas, cache/usage aggregation, media emission, unknown stop/event blocking |
| Gemini GenerateContent | text/media/signature aggregation, function calls/results, safety finish mapping, unknown parts/events, and unmappable finish reason blocking |
Matrix-level interception remains the authoritative runtime path for cross-format
unknown events; direct client emitters are covered only as provider/client
building blocks.
+144
View File
@@ -0,0 +1,144 @@
# Format Enum Mapping
Last audited: 2026-06-03
Status values used below:
- `native`: same semantic value exists in the target format.
- `mapped`: explicit provider-specific mapping is required.
- `blocked`: no lossless target value; conversion must fail closed.
- `preserve-same-format`: unknown/raw values are preserved only when source and target format are the same.
## OpenAI Reasoning Effort
Provider-specific type: `OpenAiReasoningEffort`.
| Source field | Source value | Target field | Target value | Status |
| --- | --- | --- | --- | --- |
| Chat `reasoning_effort` | `none` | Responses `reasoning.effort` | `none` | native |
| Chat `reasoning_effort` | `minimal` | Responses `reasoning.effort` | `minimal` | native |
| Chat `reasoning_effort` | `low` | Responses `reasoning.effort` | `low` | native |
| Chat `reasoning_effort` | `medium` | Responses `reasoning.effort` | `medium` | native |
| Chat `reasoning_effort` | `high` | Responses `reasoning.effort` | `high` | native |
| Chat `reasoning_effort` | `xhigh` | Responses `reasoning.effort` | `xhigh` | native |
| Chat `reasoning_effort` | `max` | Responses `reasoning.effort` | none | blocked, invalid OpenAI enum |
| Responses `reasoning.effort` | `none` | Chat `reasoning_effort` | `none` | native |
| Responses `reasoning.effort` | `minimal` | Chat `reasoning_effort` | `minimal` | native |
| Responses `reasoning.effort` | `low` | Chat `reasoning_effort` | `low` | native |
| Responses `reasoning.effort` | `medium` | Chat `reasoning_effort` | `medium` | native |
| Responses `reasoning.effort` | `high` | Chat `reasoning_effort` | `high` | native |
| Responses `reasoning.effort` | `xhigh` | Chat `reasoning_effort` | `xhigh` | native |
| Responses `reasoning.summary` | any | Chat | none | blocked |
| Responses `reasoning.budget_tokens` | any | Chat | none | blocked |
Internal model directive values:
| Internal value | OpenAI Chat | OpenAI Responses | Claude output effort | Gemini thinking level | Notes |
| --- | --- | --- | --- | --- | --- |
| `none` | `none` | `none` | `low` | `low` | Budget maps to `0`. |
| `minimal` | `minimal` | `minimal` | `low` | `low` | Budget maps to `512`. |
| `low` | `low` | `low` | `low` | `low` | Budget maps to `1280`. |
| `medium` | `medium` | `medium` | `medium` | `medium` | Budget maps to `2048`. |
| `high` | `high` | `high` | `high` | `high` | Budget maps to `4096`. |
| `xhigh` | `xhigh` | `xhigh` | `xhigh` | `high` | Budget maps to `8192`. |
| `max` | `xhigh` | `xhigh` | `max` | `high` | Internal directive only; not accepted as raw OpenAI input. |
## Tool Choice
| Canonical | OpenAI Chat | OpenAI Responses | Claude Messages | Gemini GenerateContent |
| --- | --- | --- | --- | --- |
| auto | `"auto"` | `"auto"` | `{"type":"auto"}` | unset / function calling config auto |
| none | `"none"` | `"none"` | `{"type":"none"}` | mode none |
| required | `"required"` | `"required"` | `{"type":"any"}` | mode any |
| named function | `{"type":"function","function":{"name":...}}` | `{"type":"function","name":...}` | `{"type":"tool","name":...}` | allowed function name |
Implemented guardrails:
- Claude `output_config.effort=max` maps to OpenAI `xhigh`; unknown Claude effort enums are blocked cross-format.
- Claude `tool_choice.disable_parallel_tool_use` maps inversely to OpenAI `parallel_tool_calls`.
- Gemini `allowedFunctionNames` maps to canonical named tool choice and emits back to `allowedFunctionNames`.
- Gemini `thinkingLevel` maps `low|medium|high` to OpenAI reasoning effort `low|medium|high`; unknown values are blocked cross-format.
- Responses `custom`, `web_search*`, and other built-in tools are blocked when converting to Chat unless a target raw passthrough is explicitly added.
## Tool Definition Kind
| Source | Target | Mapping |
| --- | --- | --- |
| OpenAI Chat `tools[].function.name` | Responses `tools[].name` | mapped |
| OpenAI Chat `tools[].function.parameters` | Responses `tools[].parameters` | mapped |
| OpenAI Chat `tools[].function.strict` | Responses `tools[].strict` | mapped, implemented |
| Responses `tools[].strict` | Chat `tools[].function.strict` | mapped, implemented |
| OpenAI Chat assistant `tool_calls[].id` | Responses `function_call.call_id` | mapped, implemented |
| OpenAI Chat tool `tool_call_id` | Responses `function_call_output.call_id` | mapped, implemented |
| Responses `function_call.call_id` | Chat `tool_calls[].id` | mapped, implemented |
| Responses `function_call_output.call_id` | Chat tool `tool_call_id` | mapped, implemented |
| Gemini `functionCall.id` | OpenAI/Claude canonical tool use id | mapped, implemented |
| Gemini `functionResponse.id` | OpenAI `tool_call_id` / Responses `call_id` / Claude `tool_use_id` | mapped, implemented |
| Claude `tools[].input_schema` | OpenAI `parameters` | mapped; raw same-format schema preserved |
| Gemini `functionDeclarations[].parameters` | OpenAI `parameters` | mapped; raw same-format schema preserved |
## Roles
| Canonical role | OpenAI Chat | OpenAI Responses | Claude Messages | Gemini |
| --- | --- | --- | --- | --- |
| system | `system` | `instructions` or system input item | top-level `system` | `systemInstruction` |
| developer | `developer` | `instructions` or developer input item | extension-preserved | systemInstruction extension |
| user | `user` | `message.role=user` | `user` | `user` |
| assistant | `assistant` | `message.role=assistant` / output item | `assistant` | `model` |
| tool | `tool` | `function_call_output` | `tool_result` inside user message | `functionResponse` |
Known lossy risks:
- Multiple system/developer instruction ordering needs full golden fixtures.
- Provider-specific role extensions must be preserved in same-format roundtrip and blocked cross-format if no target equivalent exists.
## Finish Reasons
| Canonical | OpenAI Chat | OpenAI Responses | Claude | Gemini |
| --- | --- | --- | --- | --- |
| stop | `stop` | completed output | `end_turn` | `STOP` |
| length | `length` | `status=incomplete`, `incomplete_details.reason=max_output_tokens` | `max_tokens` | `MAX_TOKENS` |
| tool calls | `tool_calls` | output contains `function_call` | `tool_use` | `functionCall` part, usually with `STOP` |
| content filter/safety | `content_filter` | `status=incomplete`, `incomplete_details.reason=content_filter` | `content_filtered` or refusal-compatible stops | `SAFETY`, `RECITATION`, `LANGUAGE`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`, image safety/recitation stops |
| unknown | preserve-same-format | preserve-same-format | preserve-same-format | preserve-same-format |
Implemented response guardrails:
- Cross-format sync response conversion validates source finish/status enums before emitting the target response.
- Same-format canonical response roundtrip preserves raw OpenAI Chat `finish_reason`, OpenAI Responses `status`, Claude `stop_reason`, and Gemini `finishReason` values through provider extension metadata.
- OpenAI Chat unknown `choices[].finish_reason` fails with `InvalidEnumValue`.
- OpenAI Responses non-terminal `status` values (`queued`, `in_progress`, `cancelled`) are valid provider states but are blocked for sync response conversion because target sync formats cannot represent them losslessly.
- Runtime sync finalize does not fall back to legacy conversion when registry response conversion reports strict errors such as invalid enums, unsupported fields, lossy blocks, or invalid target fields.
- Stream terminal reasons now follow the same strict policy: unknown OpenAI / Claude raw finish reasons and Gemini known-but-unmappable values such as `OTHER` surface as `unsupported_finish_reason`, while OpenAI Responses `length` and `content_filter` stream finals emit `response.incomplete`.
- Gemini known but unmappable finish reasons such as `OTHER`, `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL`, `MISSING_THOUGHT_SIGNATURE`, and `MALFORMED_RESPONSE` are blocked with `LossyConversionBlocked`; unknown future Gemini values fail with `InvalidEnumValue`.
- Stream finish reason guardrails are covered with provider-specific fixtures for usage, tool calls, reasoning signatures, media, and unknown payloads. Unknown provider stream events are not mapped as finish reasons; cross-format runtime conversion emits a target-format `unsupported_stream_event` error and terminates.
## Embedding Task Types
Provider-specific type: `GeminiEmbeddingTaskType`.
Gemini embedding task values are stored in canonical `embedding.task` only after
source parsing. They are emitted to Gemini as `taskType` and validated before
cross-format conversion to a Gemini target.
| Canonical task input | Gemini `taskType` output | Status |
| --- | --- | --- |
| `QUERY` | `RETRIEVAL_QUERY` | mapped alias |
| `RETRIEVAL_QUERY` | `RETRIEVAL_QUERY` | native |
| `DOCUMENT` | `RETRIEVAL_DOCUMENT` | mapped alias |
| `RETRIEVAL_DOCUMENT` | `RETRIEVAL_DOCUMENT` | native |
| `TEXT_MATCHING` | `SEMANTIC_SIMILARITY` | mapped alias |
| `SEMANTIC_SIMILARITY` | `SEMANTIC_SIMILARITY` | native |
| `CLASSIFICATION` | `CLASSIFICATION` | native |
| `CLUSTERING` | `CLUSTERING` | native |
| `QUESTION_ANSWERING` | `QUESTION_ANSWERING` | native |
| `FACT_VERIFICATION` | `FACT_VERIFICATION` | native |
| `CODE_RETRIEVAL_QUERY` | `CODE_RETRIEVAL_QUERY` | native |
| `TASK_TYPE_UNSPECIFIED` | `TASK_TYPE_UNSPECIFIED` | native |
| unknown value | none | blocked with `InvalidEnumValue` when targeting Gemini |
Cross-provider rules:
- Gemini `taskType` to OpenAI Embedding is blocked because OpenAI has no equivalent task field.
- Gemini `taskType` to Doubao/Aliyun is blocked for the same reason.
- Jina `task` may carry through canonical and emit as Jina `task`; when targeting Gemini it must match the valid Gemini task set above.
+82
View File
@@ -0,0 +1,82 @@
# Format Passthrough Contract
Last audited: 2026-06-03
This document defines the boundary between runtime passthrough, canonical roundtrip tests, and cross-format conversion.
## Runtime Same-Format Path
Runtime same-format provider paths must not call canonical conversion.
Current implementation:
- `crates/aether-provider-transport/src/same_format_provider/mod.rs` checks `api_format_alias_matches(client_api_format, provider_api_format)`.
- When formats match, the provider body is built by copying the parsed JSON object field-for-field.
- When formats differ, the provider body is built through `aether_ai_formats::convert_request_pure`.
- Model override, body rules, model directives, Claude Code sanitization, Gemini function-response id stripping, and stream policy are applied only after the passthrough/conversion branch in provider transport.
Important limitation:
- The current transport helper receives `body_json: &serde_json::Value`, not raw request bytes. It therefore guarantees no canonical conversion and JSON value preservation at this layer, but it cannot preserve original whitespace or object key order by itself.
- True byte-level passthrough for requests with no transport edits requires a higher-level raw-body path that can forward the original bytes directly. Until that raw-body plumbing exists, tests should assert "conversion module not called" and JSON value equivalence for this helper, not byte-for-byte serialization equivalence.
## Canonical Same-Format Roundtrip
Canonical same-format roundtrip is only a test/audit mode:
```text
source format -> Canonical -> same source format
```
Required behavior:
- JSON-normalized equality, ignoring object field order and whitespace.
- Field values, array order, unknown fields, extension namespaces, and unknown enum strings must be preserved.
- This path may parse and emit; it is not the runtime path.
## Cross-Format Conversion
Cross-format conversion is strict:
```text
source format -> Canonical -> target format
```
Required behavior:
- Emit only fields valid for the target provider format.
- Map provider-specific enum values through explicit provider enum types.
- Preserve source fields only when the target has an equivalent field or documented extension passthrough.
- Fail closed with `FormatError::LossyConversionBlocked`, `FormatError::UnsupportedField`, `FormatError::InvalidEnumValue`, or `FormatError::InvalidTargetField` when no lossless mapping exists.
- Do not use `None` or silent omission to represent conversion failure.
## Pure Conversion Interface
Pure conversion lives in `crates/aether-ai-formats` and is limited to:
- parse
- emit
- provider-specific field/enum mapping
- `ConversionReport`
Pure conversion must not:
- override `model`
- add, remove, or force `stream`
- apply body rules
- apply model directives
- read the original request body to patch missing target fields
- perform provider transport policy edits
Current pure entrypoints:
- `parse_request_pure`
- `emit_request_pure`
- `convert_request_pure`
- `convert_request_pure_with_context`
- `parse_response_pure`
- `emit_response_pure`
- `convert_response_pure`
`convert_request` and `convert_response` remain legacy wrappers for existing callers that still need mapped model/report-context behavior during migration.
File diff suppressed because it is too large Load Diff