Skip to content

docs(proposals): How? for Google Vertex AI translation (#779) - #784

Closed
mkoushni wants to merge 2 commits into
praxis-proxy:mainfrom
mkoushni:proposal/779-vertex-translation-how
Closed

docs(proposals): How? for Google Vertex AI translation (#779)#784
mkoushni wants to merge 2 commits into
praxis-proxy:mainfrom
mkoushni:proposal/779-vertex-translation-how

Conversation

@mkoushni

Copy link
Copy Markdown
Contributor

Summary

Adds the How? section for #779, one of the four per-provider sub-tasks split out of #762. Builds on the accepted What?/Why? in docs/proposals/00762_api-translation.md, including its resolution that Vertex shares the SSE decoder family via a forced ?alt=sse.

  • Two new filters (vertex_translate, vertex_stream_events) mirroring the Bedrock design in #783.
  • project/location are operator-config-only and never read from the request at all (stronger than "strip"); model is independently allowlisted before path construction, same pattern as Bedrock.
  • Reuses the existing apis/src/openai/sse::SseFrameParser + HttpFilterContext::insert_filter_state pattern (already used by apis/src/openai/responses/stream_events/) for the byte-level SSE reassembly, instead of a new parser.
  • Reuses the existing credential_inject bearer-token seam for the upstream OAuth2 access token, with the shorter (~1h) GCP token lifetime explicitly flagged as a graduation criterion.
  • Closes with an explicit acceptance-criteria → design mapping table.

Test plan

  • Maintainer review of design direction (this PR is design-only, no code)
  • Stakeholder review of graduation criteria (fixture manifest, OAuth2 token rotation strategy)

…oxy#779)

Adds the implementation design for the Google Vertex AI (Gemini)
sub-task split out of praxis-proxy#762, building on the accepted What?/Why? in
00762_api-translation.md.

Signed-off-by: mkoushni <mkoushni@redhat.com>
@mkoushni
mkoushni marked this pull request as ready for review August 19, 2026 14:39
@mkoushni
mkoushni requested review from a team and nerdalert August 19, 2026 14:39
@mkoushni
mkoushni requested a review from shaneutt as a code owner August 19, 2026 14:39
@praxis-bot-app

Copy link
Copy Markdown

Proposal validation failed:

  • docs/proposals/00779_vertex-translation.md: new proposals must not include the ## How? section in the first PR. Submit What? and Why? first; add How? in a follow-up.
    See proposal process for requirements.

@szedan-rh

Copy link
Copy Markdown
Contributor

LGTM
@alexsnaps - please do 2nd eye review

@alexsnaps alexsnaps left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid to me.
Got the missing file now, dependent on #761 being merged 👌

Comment thread docs/proposals/00779_vertex-translation.md Outdated
Signed-off-by: mkoushni <mkoushni@redhat.com>
@franciscojavierarceo

Copy link
Copy Markdown
Contributor

the example chains use credential_inject with nested secret_ref/strategy configuration, but the repository currently provides credential_injection with a cluster-scoped configuration (header, value, header_prefix, and strip_client_credential). the proposed metadata contract is not implemented by the existing filter, so these examples cannot inject credentials as written. we should either align the design with the existing filter contract or specify the shared-infrastructure changes required before claiming the functional integration test is covered.

@aslakknutsen aslakknutsen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API-spec review of the How? mapping tables. Six inline comments on streaming, tools, safety 200s, errors, fail-closed request fields, and the Chat Completions-only consumer surface.

Comment on lines +31 to +45
that bidirectionally rewrites OpenAI Chat Completions-shaped traffic to and
from Vertex AI's Gemini `generateContent` / `streamGenerateContent` APIs,
including forcing genuine SSE framing on the streaming path (`?alt=sse`) so
it can share the SSE decoder family with Azure/Cohere rather than needing a
bespoke transport, per 00762's explicit resolution of this point. This is
the Vertex-scoped instance of the general translation stage already accepted
in [docs/proposals/00762_api-translation.md](00762_api-translation.md); this
proposal adds the **How?** for
[praxis-proxy/ai#779](https://github.com/praxis-proxy/ai/issues/779) only.

### Goals

- Translate OpenAI Chat Completions requests to Gemini `generateContent`
request bodies, and translate Gemini responses (including errors) back to
OpenAI Chat Completions shape.

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This locks the consumer contract to Chat Completions.

Praxis already has Responses ↔ Chat Completions (responses_to_chat_completions / apis/src/openai/translation). Either state that /v1/responses is out of scope for #779, or name the composition hop. As written, a Responses client has no documented path onto this Vertex chain. The e2e later (POST /v1/chat/completions, ~L409) only covers one of the two OpenAI surfaces this repo actually serves.

Comment on lines +267 to +282
- OpenAI `messages[]` (role `system`/`user`/`assistant`/`tool`) become
Gemini `contents[]` with role `user`/`model` (`assistant` → `model`;
`system` messages are hoisted into the top-level `systemInstruction`
field, the same hoisting pattern `anthropic_to_openai` already applies
for Anthropic's `system` field, just targeting a differently-named Gemini
field); each message's text becomes one `parts: [{text: ...}]` entry.
- OpenAI `tool_calls`/`tool`-role messages become Gemini `functionCall`/
`functionResponse` parts; OpenAI `tools[]` (JSON-schema function defs)
become `tools: [{functionDeclarations: [...]}]`.
- `temperature`/`top_p`/`max_tokens`/`stop` map to Gemini's
`generationConfig {temperature, topP, maxOutputTokens, stopSequences}`.

Malformed or untranslatable input (no `messages`, unsupported field shape)
is rejected `400` before ever reaching Vertex — the same fail-closed rule
00762 requires for every provider, and the same rule `bedrock_translate`
and `anthropic_to_openai` already apply.

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fail-closed is claimed here, but the mapped field set is not enumerated.

Listed: messages, tools, temperature / top_p / max_tokens / stop. Not decided (400 vs drop vs leak): tool_choice, parallel_tool_calls, response_format, n, logprobs, seed, max_completion_tokens, multimodal image_url parts, and Gemini thought: true parts.

Roles also need a rule: Gemini Content.role is only user | model; OpenAI allows consecutive same-role messages and multiple system messages. Hoisting every system into one systemInstruction and mapping each message 1:1 to contents[] will 400 or scramble history on real Chat Completions payloads. Specify merge/split (and that thought text must not land in message.content).

Comment on lines +273 to +275
- OpenAI `tool_calls`/`tool`-role messages become Gemini `functionCall`/
`functionResponse` parts; OpenAI `tools[]` (JSON-schema function defs)
become `tools: [{functionDeclarations: [...]}]`.

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a legal Chat Completions tool round-trip.

Gemini functionCall has name + args (object) and no id. OpenAI needs tool_calls[].id, string function.arguments, and role: tool messages with tool_call_id. Gemini functionResponse.response is a Struct, not a string.

The 2xx table below also maps finishReason: STOPfinish_reason: stop even when functionCall parts are present. OpenAI requires finish_reason: tool_calls in that case (this is already how apis/src/openai/translation and anthropic/to_openai treat the tool path).

Document: id synthesis, JSON stringify/parse both ways, and the tool_calls finish reason.

Comment on lines +289 to +295
- **2xx**: `response::from_generate_content_body` maps
`candidates[0].content.parts[]` (`text` / `functionCall`) to OpenAI
`choices[0].message.content` / `tool_calls[]`; `candidates[0].finishReason`
(`STOP`, `MAX_TOKENS`, `SAFETY`, `RECITATION`, `OTHER`) to OpenAI
`finish_reason` (`stop`, `length`, `content_filter`, `content_filter`,
`stop`); `usageMetadata.{promptTokenCount,candidatesTokenCount,totalTokenCount}`
to OpenAI `usage.{prompt,completion,total}_tokens`.

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini safety blocks are often HTTP 200, not 4xx.

promptFeedback.blockReason with empty/missing candidates is a successful generateContent response (and, on the stream, only the first SSE chunk). Mapping only candidates[0] — and treating missing candidates as a malformed frame (~L351) — turns those into errors or empty stops. OpenAI's contract for this is finish_reason: content_filter (or an error object), not a parse failure.

The FinishReason table is also a stale subset. Current Vertex includes BLOCKLIST, PROHIBITED_CONTENT, SPII, MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL, MODEL_ARMOR, IMAGE_*. MALFORMED_FUNCTION_CALL is a 200 with empty parts. Map them or give an explicit fallback; do not leave unmapped 200s looking like stop.

Comment on lines +296 to +301
- **non-2xx**: `error::to_openai_error` maps Gemini's error envelope
(`{"error": {"code", "message", "status"}}` — already wrapped in an
`"error"` object, unlike Bedrock's flat `{"message": ...}`) into OpenAI's
`{"error": {"message", "type", "code"}}`: `message` copies directly,
`status` (an enum string like `INVALID_ARGUMENT`) maps to `type`, `code`
(Google's numeric gRPC-style code) maps to OpenAI's `code` field.

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error.code in Google REST JSON is not a gRPC numeric code.

AIP-193: error.code is the HTTP status (400, 401, 403, 429, 500); error.status is the google.rpc.Code name (INVALID_ARGUMENT). OpenAI's envelope is error.type = invalid_request_error | authentication_error | rate_limit_error | …, and error.code is a string or null.

Copying statustype and codecode produces type: INVALID_ARGUMENT / code: 400, which is not OpenAI. Map HTTP status → OpenAI type, same idea as error_type_for_status in apis/src/anthropic/to_openai/response.rs.

Comment on lines +344 to +356
Vertex-specific work is only what comes *after* frame reassembly: each
complete `SseFrame`'s `data` is Gemini's own streaming delta JSON — one
`candidates[0].content.parts[]` fragment plus, on the terminal frame,
`finishReason` and `usageMetadata` — translated into an OpenAI
`chat.completion.chunk` SSE frame, using the same field mapping as the
non-streaming response path (`response::from_generate_content_body`,
applied incrementally instead of once), followed by a final
`data: [DONE]\n\n`. A structurally malformed frame (not valid JSON, or
missing `candidates`) does not abort the whole response — it emits one
terminal OpenAI-shaped error chunk and stops, the same fail-safe-degrade
behavior `bedrock_stream_events` uses for a corrupted binary frame,
adapted from the same overflow/error-recovery philosophy behind the
token-accounting fix (praxis-proxy/ai#674).

@aslakknutsen aslakknutsen Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transport reuse (SseFrameParser + ?alt=sse) is fine. The payload mapping is not.

Each Gemini SSE event is a GenerateContentResponse: incremental parts[].text, finishReason empty until the last chunk, no [DONE]. OpenAI streaming is object: chat.completion.chunk with choices[].delta (role on the first chunk, content/tool-call argument fragments after that, delta: {} + finish_reason on the last), then data: [DONE].

from_generate_content_body applied incrementally emits non-streaming choices[].message objects as SSE data. OpenAI clients will not parse that. Specify a delta state machine; do not reuse the buffered mapper.

Also: missing candidates is not always a malformed frame — see promptFeedback on the 2xx path above.

@praxis-bot

Copy link
Copy Markdown
Collaborator

We're closing this pull request because we've moved all proposals to praxis-proxy/enhancements. Please see our announcement for more details.

We apologize for any inconvenience. Please retarget this PR and the new repository and let us know if you need any help. 🖖

@praxis-bot praxis-bot closed this Aug 20, 2026
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.

6 participants