feat: experimental OpenAI to Anthropic cross-protocol translation #2

Merged
devtrev merged 8 commits from trevin/feat/openai-anthropic-translation into dev 2026-07-10 00:50:39 -06:00
Owner

Summary

Adds experimental OpenAI→Anthropic cross-protocol translation so that OpenAI-only clients (which discover models via GET /v1/models and POST /v1/chat/completions for everything) can reach Anthropic-served models without speaking the native Anthropic protocol.

Native protocol passthrough remains the recommended path — clients that speak the provider's native protocol (like the opencode aor plugin, which defers to the correct API per model) get verbatim passthrough with no transformation. Translation is per-attempt: a model served by a mixed-protocol provider tier may passthrough on one attempt and translate on the next. This also enables cross-protocol fallback (previously broken) — an OpenAI provider's 5xx can fall back to an Anthropic provider that translates on the fly.

Google translation is a planned extension point (no date). Images/vision are deferred (raises a clear 400 aor_translate_error).

What's supported (v1)

  • Text — string and block-list content
  • Tool/function calling — tools, tool choices, tool results, and streaming input_json_delta partials
  • Reasoning/thinkingreasoning_effort (low/medium/high) → Anthropic thinking.budget_tokens via a heuristic table; explicit thinking dicts pass through
  • Streaming — Anthropic SSE → OpenAI chat.completion.chunk SSE, including stream_options.include_usage
  • Error translation — Anthropic error bodies reshaped to OpenAI error format (e.g. 529 "overloaded" → 503 server_error)

Architecture

Translation is a separate aor/translate/ module (not an extension of the Adapter Protocol). Adapters stay body-shape-agnostic (model id + auth + path + error-classify only). The Translator Protocol + registry is a clean extension point for future Google support — a new module registered for ("openai", "google") would plug in without touching the proxy.

The proxy detects the client's protocol from the request path (client_protocol() helper in adapters/__init__.py) and the serving provider's protocol from config (api field). When they differ and a translator is registered, the request body is translated before forwarding and the response is translated back. All sticky/backoff/usage bookkeeping is preserved unchanged — translation is orthogonal to routing.

Commits

  1. bfcc344 — translate/ package + OpenAI→Anthropic request translator
  2. acdabe4 — Anthropic response + error translation
  3. 014300b — Anthropic stream translation (SSE → OpenAI chunks)
  4. b4309cc — proxy.py integration (per-attempt translator in buffered + streaming)
  5. 9a03e15 — proxy integration tests (cross-protocol fallback, streaming, errors)
  6. f8820b9 — README docs for experimental translation

Testing

  • uv run ruff check . — clean
  • uv run ruff format --check . — clean
  • uv run pytest -q219 passed (135 baseline + 73 translator unit tests + 11 proxy integration tests)

Test coverage

  • 73 unit tests (tests/test_translate_anthropic.py): registry, path suffix, request translation (system lifting, max_tokens injection, tools, tool_choice, reasoning, dropped fields, error cases), response translation (text, tool_use, stop_reason mapping, usage), error translation (status/type maps, malformed bodies), stream translation (message_start, content_block_start tool_use stub, text_delta, input_json_delta partials, message_delta stop_reason, message_stop finalize, mid-stream error, include_usage, ping/unknown).
  • 11 proxy integration tests (tests/test_proxy.py): basic text round-trip, tools round-trip, streaming text, streaming tools, streaming include_usage, cross-protocol fallback (openai 5xx → anthropic translates, 2 usage rows), error 429→OpenAI shape, error 529→503, error 400 non-retryable, image_url request error, native Anthropic passthrough with translator registered.

Known nuance

For translated errors, the usage row's http_status records the translated status (e.g. 503 for an Anthropic 529) rather than the original upstream status. The error_kind that drives backoff is correctly computed on the original native body, so routing decisions are unaffected — this is purely an admin-UI observability detail.

## Summary Adds **experimental OpenAI→Anthropic cross-protocol translation** so that OpenAI-only clients (which discover models via `GET /v1/models` and POST `/v1/chat/completions` for everything) can reach Anthropic-served models without speaking the native Anthropic protocol. Native protocol passthrough remains the recommended path — clients that speak the provider's native protocol (like the opencode aor plugin, which defers to the correct API per model) get verbatim passthrough with no transformation. Translation is **per-attempt**: a model served by a mixed-protocol provider tier may passthrough on one attempt and translate on the next. This also enables cross-protocol fallback (previously broken) — an OpenAI provider's 5xx can fall back to an Anthropic provider that translates on the fly. Google translation is a planned extension point (no date). Images/vision are deferred (raises a clear 400 `aor_translate_error`). ### What's supported (v1) - **Text** — string and block-list content - **Tool/function calling** — tools, tool choices, tool results, and streaming `input_json_delta` partials - **Reasoning/thinking** — `reasoning_effort` (low/medium/high) → Anthropic `thinking.budget_tokens` via a heuristic table; explicit `thinking` dicts pass through - **Streaming** — Anthropic SSE → OpenAI `chat.completion.chunk` SSE, including `stream_options.include_usage` - **Error translation** — Anthropic error bodies reshaped to OpenAI error format (e.g. 529 "overloaded" → 503 `server_error`) ### Architecture Translation is a separate `aor/translate/` module (not an extension of the `Adapter` Protocol). Adapters stay body-shape-agnostic (model id + auth + path + error-classify only). The `Translator` Protocol + registry is a clean extension point for future Google support — a new module registered for `("openai", "google")` would plug in without touching the proxy. The proxy detects the client's protocol from the request path (`client_protocol()` helper in `adapters/__init__.py`) and the serving provider's protocol from config (`api` field). When they differ and a translator is registered, the request body is translated before forwarding and the response is translated back. All sticky/backoff/usage bookkeeping is preserved unchanged — translation is orthogonal to routing. ## Commits 1. `bfcc344` — translate/ package + OpenAI→Anthropic request translator 2. `acdabe4` — Anthropic response + error translation 3. `014300b` — Anthropic stream translation (SSE → OpenAI chunks) 4. `b4309cc` — proxy.py integration (per-attempt translator in buffered + streaming) 5. `9a03e15` — proxy integration tests (cross-protocol fallback, streaming, errors) 6. `f8820b9` — README docs for experimental translation ## Testing - `uv run ruff check .` — clean - `uv run ruff format --check .` — clean - `uv run pytest -q` — **219 passed** (135 baseline + 73 translator unit tests + 11 proxy integration tests) ### Test coverage - **73 unit tests** (`tests/test_translate_anthropic.py`): registry, path suffix, request translation (system lifting, max_tokens injection, tools, tool_choice, reasoning, dropped fields, error cases), response translation (text, tool_use, stop_reason mapping, usage), error translation (status/type maps, malformed bodies), stream translation (message_start, content_block_start tool_use stub, text_delta, input_json_delta partials, message_delta stop_reason, message_stop finalize, mid-stream error, include_usage, ping/unknown). - **11 proxy integration tests** (`tests/test_proxy.py`): basic text round-trip, tools round-trip, streaming text, streaming tools, streaming include_usage, cross-protocol fallback (openai 5xx → anthropic translates, 2 usage rows), error 429→OpenAI shape, error 529→503, error 400 non-retryable, image_url request error, native Anthropic passthrough with translator registered. ## Known nuance For translated errors, the usage row's `http_status` records the translated status (e.g. 503 for an Anthropic 529) rather than the original upstream status. The `error_kind` that drives backoff is correctly computed on the original native body, so routing decisions are unaffected — this is purely an admin-UI observability detail.
Add a new  package for experimental cross-protocol
translation, separate from the body-shape-agnostic adapters. The
 Protocol +  + a (client_proto,
serving_proto) registry live in ;
returns None for matching protocols (passthrough unchanged) or
unsupported pairs.

 converts an OpenAI
/v1/chat/completions body to an Anthropic /v1/messages body:
- system messages lifted to top-level  (joined with \n\n)
- assistant tool_calls → tool_use blocks (arguments JSON-string→dict)
- tool result messages → tool_result blocks (matched by tool_call_id)
- tools → input_schema; tool_choice mapped (auto/required/none/named)
- max_tokens injected as 4096 when absent (Anthropic requires it);
  max_completion_tokens (o-series alias) also accepted
- reasoning_effort (low/medium/high) → thinking.budget_tokens via a
  documented heuristic table; explicit  dict passthrough
- temperature/top_p/stop(→stop_sequences) passed through; n, penalties,
  logprobs, seed, user, response_format dropped
- image_url blocks raise TranslateError (vision deferred to a later phase)

The other three Translator methods (translate_response, translate_stream,
translate_error) are stubbed and implemented in subsequent commits.

36 new unit tests in tests/test_translate_anthropic.py cover every
branch of translate_request plus the registry. No proxy integration yet
(get_translator is not called outside tests).
translate_response converts a buffered Anthropic /messages body to an
OpenAI chat.completion object: text blocks join into message.content,
tool_use blocks become tool_calls (input dict -> arguments JSON string),
thinking blocks are dropped (no standard OpenAI home in v1), stop_reason
maps to finish_reason (end_turn/stop_sequence->stop, max_tokens->length,
tool_use->tool_calls, refusal->content_filter, default stop), and usage
remaps input_tokens/output_tokens -> prompt_tokens/completion_tokens with
cache_* fields dropped.

translate_error converts Anthropic's {type:error, error:{type,message}}
shape to OpenAI's {error:{message,type,param,code}}. Notable remaps: 529
(Anthropic "overloaded") -> 503 with type server_error; most other statuses
and types pass through. Malformed upstream error bodies yield a generic
server_error instead of crashing.

26 new unit tests cover every stop_reason, text/tool/thinking blocks, usage
remapping, id synthesis, and each error status path. The stream method
remains stubbed for the next commit.
translate_stream is a stateful async generator that consumes Anthropic
SSE lines and yields OpenAI chat.completion.chunk SSE bytes:

- message_start: capture id/model/input_tokens, emit the initial
  {role:"assistant"} delta chunk (once).
- content_block_start (tool_use): emit a tool_calls stub delta with
  id/name and an empty arguments string. A sequential tool_ordinal is
  tracked separately from Anthropic's block index (which also counts
  text blocks) so OpenAI tool_calls[].index starts at 0.
- content_block_delta text_delta: emit a content delta.
- content_block_delta input_json_delta: stream the partial_json string
  through as tool_calls[].function.arguments partials (the client
  reassembles them into valid JSON).
- thinking_delta: dropped (no OpenAI home in v1).
- message_delta: capture stop_reason -> finish_reason and output_tokens.
- message_stop: finalize emits the terminal finish chunk + optional
  usage chunk (when include_usage) + [DONE].
- ping/unknown: ignored.
- mid-stream error: emits an OpenAI-shaped error event chunk then
  [DONE] and terminates (consistent with the proxy's no-mid-stream-
  retry rule; the _ERROR_TYPE_MAP is applied so overloaded_error ->
  server_error). An abrupt upstream end without message_stop is
  finalized defensively with a default "stop" finish + [DONE].

The proxy will pass resp.aiter_lines() through this generator and set
media_type=text/event-stream; the background-task close semantics are
preserved by the proxy integration in a later commit.

37 new unit tests cover text/tool/thinking/usage/ping/error/abrupt-end
paths. Test SSE fixtures are built via json.dumps helpers to avoid
manual escape errors. The full suite is now 208 passing.
Wire the experimental OpenAI→Anthropic translator into the proxy's
per-attempt loop. Translation activates only when the client protocol
(diffed from the request path) differs from the serving provider's
declared API — when the translator is None the proxy's verbatim
passthrough behaviour is unchanged.

Per-attempt selection matters because a model's provider tier can mix
protocols: one request may passthrough attempt 1 (OpenAI provider) and
translate attempt 2 (Anthropic provider). This also enables previously
broken cross-protocol fallback as a side benefit.

Changes:
- adapters/__init__.py: add client_protocol(path) helper, reusing the
  existing _GOOGLE_PATH_RE to classify /messages (anthropic),
  /models/{id}:… (google), or everything else (openai).
- proxy.py: compute client_proto + include_usage before the attempt
  loop; select translator = get_translator(client_proto, prov.api) per
  attempt. When translating, translator.translate_request replaces
  adapter.rewrite_request and translator.upstream_path_suffix replaces
  adapter.rewrite_path; upstream headers still come from the adapter.

Buffered 2xx: translate_response → json.dumps; non-2xx: classify
retryability on the ORIGINAL native body (drives the walk decision),
then translate_error for client surfacing (new status + body +
content-type application/json).

Streaming 2xx: _safe_translate_stream wraps the translator's async
generator so a mid-stream TranslateError yields an OpenAI error chunk +
[DONE] instead of a dropped connection (honours the existing "commit at
first byte, never retry mid-stream" rule). Non-2xx: same translate-error
pattern as buffered.

TranslateError on request → 400 (deterministic, no retry). TranslateError
or ValueError on response translation → 502 _translate_fail_outcome
(non-retryable). All sticky/backoff/usage bookkeeping is preserved
unchanged — translation is purely orthogonal to routing.
11 end-to-end tests exercising the full proxy path with OpenAI→Anthropic
translation, using a combined fake upstream that serves both
/v1/chat/completions (OpenAI) and /v1/messages (Anthropic) steered by
mode.

Tests cover:
- test_translate_basic_text: request translated (system lifted, max_tokens
  injected, x-api-key + anthropic-version headers), response translated
  back (OpenAI chat.completion shape with usage + finish_reason)
- test_translate_tools_roundtrip: tools → Anthropic input_schema, tool_use
  response → OpenAI tool_calls with JSON arguments
- test_translate_streaming_text: Anthropic SSE events → OpenAI
  chat.completion.chunk SSE with [DONE]
- test_translate_streaming_tools: input_json_delta partials → tool_calls
  function.arguments partials in streamed chunks
- test_translate_streaming_include_usage: stream_options.include_usage →
  usage chunk emitted before [DONE]
- test_translate_cross_protocol_fallback: openai provider 5xx → anthropic
  provider translates + succeeds, 2 usage rows with correct providers
- test_translate_error_429: Anthropic 429 → OpenAI-shaped 429
- test_translate_error_529_maps_to_503: Anthropic 529 → 503 server_error
- test_translate_error_400_not_retryable: 400 not retried, one attempt
- test_translate_request_error_image_url: image_url → TranslateError →
  400 aor_translate_error, zero upstream calls
- test_native_anthropic_passthrough_with_translator_registered: native
  /v1/messages client gets passthrough (translator not selected for
  matching protocols), response stays Anthropic shape

Config uses the new [api_keys.X] schema with mixed openai + anthropic
providers serving model "m1" (for fallback tests) and "claude-test"
(anthropic-only, for pure translation tests).
Add a Features bullet and a dedicated "Experimental cross-protocol
translation" section describing the OpenAI→Anthropic translation layer.

The new section covers:
- why passthrough remains recommended (opencode aor plugin uses native)
- how translation works (path-based client protocol detection, per-attempt
  translator selection, cross-protocol fallback as a side benefit)
- what v1 supports (text, tools, reasoning, streaming, error translation)
- what is not supported (Google, image input, native mismatch passthrough,
  /v1/responses) and the max_tokens=4096 default-injection behavior
- how translation failures surface (400 request, 502 response, mid-stream
  error chunk + [DONE])
A TranslateError from translate_request (e.g. image_url input, which is
deferred) used to bail the entire walk with a 400. That skipped any later
passthrough provider (whose api matches the client protocol) or a
differently-translating provider that could have served the request
natively — a regression for mixed-protocol tiers, and nondeterministic
per client key due to consistent-hash ordering.

Now the failed attempt is skipped via `continue` (no upstream call, no
usage row, no retry-budget consumption) and the walk proceeds. If every
attempt fails for translation reasons, the loop-exhaustion safety net
surfaces the 400 via _surface(last_error).

Adds test_translate_skip_falls_through_to_passthrough: a mixed-tier
config (anthropic priority 1, openai priority 2, both serving "mix")
confirms an image_url request skips the anthropic translator and
succeeds via the openai passthrough, with exactly one usage row.
Three minor fixes from PR review:

1. max_tokens: 0 was silently coerced to the 4096 default because the
   guard used truthiness (`if max_tokens:`) instead of `is not None`.
   An explicit 0 is now preserved. Same applies to max_completion_tokens.

2. The `created` field in translated responses and stream chunks was
   hardcoded to 0 (1970-01-01). Now uses `int(time.time())` as expected
   for the OpenAI unix-timestamp field.

3. The Anthropic message id (e.g. `msg_01…`) was reused verbatim as the
   OpenAI response/chunk id. Now wrapped as `chatcmpl-aor-{native_id}` so
   the id carries the `chatcmpl-` prefix that some OpenAI clients expect.
   Falls back to `chatcmpl-aor` when the upstream sends no id.
Author
Owner

Addressed all four review findings in two follow-up commits (e6ba08a, bb37d75). 221 tests passing, ruff clean.

Medium — request TranslateError bailed the entire walk (e6ba08a)

The real bug. A TranslateError from translate_request (e.g. image_url input, which is deferred) used to return a 400 immediately, skipping all remaining providers. In a mixed-protocol tier — the explicit design goal — a later passthrough provider (whose api matches the client protocol) could have served the request natively. The bug was also nondeterministic per client key because consistent-hash ordering determines which provider is tried first within a tier.

Fix: the failed attempt now continues the walk (no upstream call → no usage row, no retry-budget consumption). A loop-exhaustion safety net surfaces the 400 via _surface(last_error) if every attempt fails for translation reasons.

New test: test_translate_skip_falls_through_to_passthrough — a mixed-tier config (anthropic priority 1, openai priority 2, both serving mix) confirms an image_url request skips the anthropic translator and succeeds via the openai passthrough, with exactly one usage row (the openai attempt only).

Low — three polish fixes (bb37d75)

  1. max_tokens: 0 was silently coerced to 4096 (if max_tokens:if max_tokens is not None:). Explicit 0 is now preserved.
  2. created timestamp was hardcoded to 0 (1970-01-01); now uses int(time.time()).
  3. Response/chunk id now wraps the Anthropic id as chatcmpl-aor-{native_id} instead of reusing msg_01… verbatim, so the chatcmpl- prefix is present for OpenAI clients that expect it.
Addressed all four review findings in two follow-up commits (e6ba08a, bb37d75). 221 tests passing, ruff clean. ### Medium — request TranslateError bailed the entire walk (e6ba08a) **The real bug.** A `TranslateError` from `translate_request` (e.g. `image_url` input, which is deferred) used to `return` a 400 immediately, skipping all remaining providers. In a mixed-protocol tier — the explicit design goal — a later passthrough provider (whose `api` matches the client protocol) could have served the request natively. The bug was also **nondeterministic per client key** because consistent-hash ordering determines which provider is tried first within a tier. **Fix:** the failed attempt now `continue`s the walk (no upstream call → no usage row, no retry-budget consumption). A loop-exhaustion safety net surfaces the 400 via `_surface(last_error)` if every attempt fails for translation reasons. **New test:** `test_translate_skip_falls_through_to_passthrough` — a mixed-tier config (anthropic priority 1, openai priority 2, both serving `mix`) confirms an `image_url` request skips the anthropic translator and succeeds via the openai passthrough, with exactly one usage row (the openai attempt only). ### Low — three polish fixes (bb37d75) 1. **`max_tokens: 0`** was silently coerced to 4096 (`if max_tokens:` → `if max_tokens is not None:`). Explicit 0 is now preserved. 2. **`created` timestamp** was hardcoded to 0 (1970-01-01); now uses `int(time.time())`. 3. **Response/chunk id** now wraps the Anthropic id as `chatcmpl-aor-{native_id}` instead of reusing `msg_01…` verbatim, so the `chatcmpl-` prefix is present for OpenAI clients that expect it.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
devtrev/actually-open-router!2
No description provided.