focusreactive / focusreactive/payload-plugins

[translator] Epic: more translation providers (Anthropic, Gemini, OpenRouter) + refresh the OpenAI one

Open
#98 1 comment 0 reactions 1 assignee Claimed by @SearheiParkhamchuk View on GitHub
enhancement epic translator
Dominant language
TypeScript
Stars
19
Forks
0
Avg merge
16h 54m
Merged PRs (30d)
19

Description

> **Framing corrected 2026-08-28, after #99 landed as PR #101.** The shared design below was written
> assuming every vendor gets a built-in *adapter* carrying its SDK as an `optionalDependency`. That
> turned out to be the expensive half of an adapter and is being removed, not extended. Struck-through
> lines are kept so the reasoning is visible; the replacements follow each one. #100 was rewritten to
> match.

## Summary

The plugin ships exactly one built-in translation provider — OpenAI — and it has not been touched since the early releases. We need three more vendors (**Anthropic**, **Google Gemini**, **OpenRouter**) and a pass over the existing OpenAI adapter, which is now the weakest part of an otherwise well-layered pipeline.

**What "support a vendor" now means:** a `complete` helper that knows how to call the service and carries **no dependency on its SDK**. The consumer constructs the client; we build the request. See `docs/DEPRECATIONS.md#openai-client-construction`.

## Current state

- The port is `TranslationProvider` (`src/core/domain/translation-providers/TranslationProvider.interface.ts`) — a single `translate(input, sourceLng, targetLng)` returning `Record | null`. It lives in the dependency-free core; implementations live outside it, in `src/translation-providers//`.
- The only implementation is `src/translation-providers/openai/OpenAITranslation.provider.ts`:
- `chat.completions.create` with `response_format: { type: "json_object" }`,
- default model `gpt-4o`,
- hardcoded `temperature: 0`, `top_p`, `frequency_penalty`, `presence_penalty`,
- `model` typed against `ChatModel` imported from `openai/resources/index.mjs`,
- every failure path collapses to `return null`, which the pipeline turns into a generic `Error("Translation provider returned null")`,
- no check that the returned keys match the input keys.
- `openai` is an `optionalDependency`; the provider is opt-in and the core stays dependency-free. That layering is good and must survive this work.
- Users can already bring their own provider (documented in the README), so this epic is about *batteries included*, not about a missing extension point.

## Why now

- Customers ask for a vendor they already have a contract with; OpenAI-only is a blocker for some projects.
- OpenRouter alone unlocks a long tail of models behind one key.
- Translation quality and cost differ a lot per vendor and per language pair — being able to switch is a product feature, not a nicety.
- The OpenAI adapter's failure handling (`null` for everything) makes support painful: a content filter, a malformed JSON reply, and a dropped key all look identical from the admin UI.

## Scope

Two sub-issues, in this order:

1. #99 — **Refresh the OpenAI provider + extract the shared provider toolkit.** Establishes the shape every vendor adapter follows, so the three new ones are thin.
2. #100 — ~~**Add the Anthropic, Gemini and OpenRouter providers.** Three adapters on top of that shape~~ **Add the Anthropic, Gemini and OpenRouter completion helpers**, each taking a client the consumer constructed; can be split across parallel PRs once #99 has landed.

## Shared design decisions

These apply to every adapter and are settled here so the sub-issues don't re-litigate them:

- **The port does not change.** `TranslationProvider` stays a one-method contract. ~~New vendors are adapters under `src/translation-providers//Translation.provider.ts`~~ New vendors are `CompletionFn` factories under `src/translation-providers//`, composed by the consumer as `createTranslationProvider({ complete: vendorComplete({ client, model }) })`.
- ~~**Every vendor SDK is an `optionalDependency`**, imported lazily so installing the plugin never pulls four SDKs. `@anthropic-ai/sdk` for Anthropic, `@google/genai` for Gemini; OpenRouter is OpenAI-wire-compatible and reuses the `openai` SDK with a custom `baseURL`.~~

**No vendor SDK is a dependency of ours, optional or otherwise.** Carrying one optionally is what costs: a lazy import shaped around deployment file-tracers that resolve `import()` statically, plus a classifier telling "not installed" from "installed but broken" across four runtimes. A helper that takes an already-constructed client skips all of it. OpenRouter still speaks the OpenAI wire format and may be a configuration of `openAIComplete` rather than a helper of its own.
- ~~**One common option surface** across vendors: `apiKey`, `model`, `systemPrompt`, `dryRun`, `timeout`, `maxRetries` — same names, same semantics, so switching providers is a one-line change in `payload.config.ts`.~~

Those options belonged to the client-building layer that is going away. `apiKey`, `timeout` and `maxRetries` are now settings on the client the consumer constructs; `systemPrompt` and `dryRun` live on `createTranslationProvider` and are already vendor-neutral. What stays common is the seam itself: every vendor ends up behind the same `CompletionFn`.
- **The generic parts move to a shared module** (`src/translation-providers/shared/`): system-prompt building, the dry-run machinery (`DryRunConfig` + the recursive value transformer), JSON parsing, and key-set validation. A vendor file should contain little more than "call this API, hand back the raw JSON string".
- **Strict JSON output wherever the vendor supports it** (OpenAI structured outputs, Gemini response schema, Anthropic tool-shaped output), with the shared JSON parse + key validation as the common fallback.
- **Key-set validation is mandatory**: if the model drops, renames or invents an index, that is an error with a useful message, not a silently half-translated document.
- Each new public export carries `@since` and a `Since vX.Y.Z` note in the README, per the package guide.

## Out of scope

- Chunking large documents. The pipeline sends the whole `textMap` in one request (`TranslationStage`). ~~which will eventually hit output-token limits on big documents. Real problem, different issue — none of the new providers make it worse.~~ Still a different issue, but no longer only about output tokens: a strict response schema also has a size limit, so a large document can fail on the schema before generation starts. Recorded in `docs/plans/2026-08-28-provider-review-findings.md` as needing a measurement, not a guess.
- Picking or configuring the provider from the admin UI, and storing API keys anywhere but env vars.
- Non-LLM translation services (DeepL, Google Translate v3). The README's custom-provider recipe already covers them.

## Acceptance criteria

- [ ] #99 closed
- [ ] #100 closed
- [ ] ~~`createOpenAIProvider`, `createAnthropicProvider`, `createGeminiProvider` and `createOpenRouterProvider` are exported from the package root~~ `openAIComplete`, `anthropicComplete` and `geminiComplete` are exported from the package root and documented in the README with a comparison table. (`createOpenAIProvider` is deprecated — `docs/DEPRECATIONS.md#openai-client-construction`.)
- [ ] ~~Installing the plugin without any vendor SDK still builds and type-checks; each provider fails with a clear "install X" message if its SDK is missing.~~ The package declares no vendor SDK at all, so there is nothing to be missing — the consumer imports the SDK they chose, and a missing one is their own import error, at build time rather than at the first translation.
- [ ] Switching a project from one provider to another requires changing the client construction and the helper — two adjacent lines in `payload.config.ts`, not a rewrite.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.