Create AI Translate CCC context consumer plugin
> [!note]
>
> MR to merge: !55
## Summary
Add an AI Translate CCC context consumer type on top of the generic consumer plugin API from ai_context#3586346+s. Everything that receives context is a consumer; agents are one type. CKEditor tools were the first non-agent production type owned by an external project (ai_ckeditor#3615834+s). This issue is the Translate type on that same generic path.
ai_context#3586346+s is on `1.0.x`. The Automator type (ai_context#3586451+s) and the CKEditor type (ai_ckeditor#3615834+s) already proved the as-built contract. The public API is not expected to change. Use as-built names from ai_context#3586346+s (`ckeditor:…`, `automator:…`, `agent:…`).
## Why this is not covered by ai_translate#3585523+s
ai_translate#3585523+s added a Tool API / agent entrypoint. CCC verified that path against the shipped `agent` type in ai_context#3586446+s. That work did **not** replace the existing chat-proxy path.
UI batch translation and Drush still go through `ChatTranslationProvider`, which calls:
`$this->realTranslator->chat($messages, $model, ['ai_translate'])`
Those requests never hit `BuildSystemPromptEvent`, so they never use the `agent` consumer type. Earlier CCC planning treated #3585523+s as “no consumer plugin needed.” That only covers the agent entrypoint. This issue is the remaining chat-proxy path.
CKEditor’s **Translate** tool is a different consumer (`ckeditor:ai_ckeditor_translate`). Do not merge the two.
## Why this lives in AI Translate
Non-agent types belong in the consuming project. Automators were the documented exception (AI core will not take a Composer or module dependency on CCC). Translate is the normal case, same as CKEditor:
- Plugin lives in this project (standalone `ai_translate`)
- AI Context discovers it; no AI Context code change is required for the type itself
- `ai_translate` does not take a hard dependency on `ai_context`
- Gate on the `ai_translate` module machine name only, never on package layout. The copy bundled in the AI project is `lifecycle: deprecated` and keeps the same machine name.
## Solution approach
Follow the path CKEditor actually shipped (ai_ckeditor#3615834+s / ai_ckeditor!12+s), unless maintainers prefer a submodule:
1. **Recommended (as-built CKEditor):** put the plugin in the main `ai_translate` module. The type extends the AI Context consumer type base class and is discovered by the AI Context consumer type manager, both of which arrived with the API in 1.0.0-beta5. On an older AI Context the plugin is never discovered, so discovery is the gate and there is no runtime version check. No version constraint in `info.yml` and no install-time type-settings default. `composer.json` suggests `drupal/ai_context` (1.0.0-beta5 or later) and require-devs it for tests.
2. **Alternative:** a small integration submodule (e.g. `ai_translate_context`). Its `info.yml` constrains the AI Context version and `config/install` ships `ai_context.consumer_type_settings.translate`.
Sites that do not use CCC, or do not want this integration, already have off switches: do not enable `ai_context` (the type then does not exist), the type-wide **Enable this consumer type** flag, and per-instance `push_enabled` (default off).
Two different strings:
| String | Role |
|--------|------|
| `translate:default` | CCC consumer ID (config row) |
| `ai_translate` | Request tag already emitted by `ChatTranslationProvider` |
Type plugin ID is `translate` (no colon, no `ai_` prefix). That matches as-built `ckeditor`, `automator`, and `agent`. Do not use the older plan name `ai_translate:default`.
One instance is enough to _claim_. Unlike CKEditor, there is no second `ai_translate:{something}` tag in 1.0.
## Decisions
1. **The chat-proxy translator is the consumer.** One instance. Canonical ID `translate:default`. Type plugin ID is `translate`.
2. **No per-language-pair instances in 1.0.** English→French and English→German share one subscription set. A later issue can add `translate:{lang_from}-{lang_to}` when structured language metadata exists on the provider request. `TextTranslatorInterface::translateContent()` accepts an `$entityContext` array (renamed from `$context`; it only ever carries `entity_context`). If that follow-up lands, derive pair instances from configured languages, do not hardcode them.
3. **Do not reconstruct the instance from the route or from language codes in the URL.** The consumer router fails closed on ambiguity. Matching requires the `ai_translate` request tag. Native `translate_text` providers never emit a chat request and never get injection. Document that in the consumer description.
4. **One instance, not a plugin-manager list.** There is no Translate tool plugin manager. `getInstances()` returns the single `default` instance. UI, Drush, interface translation, and any other chat-proxy call share that row.
5. **Push is opt-in.** `getInstanceDefaults()` sets `push_enabled: FALSE`. A row-less site gets no injection.
6. **Selection mode follows the site default.** The type does not override the AI Context base defaults. With AI Context `1.0.x` after ai_context#3586535+s the editor opens on **Site default** and stores `inherit`; earlier 1.0.x betas preselect **Minimal** on the first save. Either way an unsaved row inherits the site-wide mode at runtime, and an editor can lock a ladder mode on the row. Automatic injection never widens the stored mode.
7. **Chat only.** Image, speech, embeddings, native `translate_text`, and other non-chat `llmType` values never hit the generic subscriber.
8. **No agent-only features.** Loop-aware and Debug / Explore stay on `AiContextConsumerTypeAgent`. Translate does not get Context Tools. Loop-aware does not apply here: each field is an independent chat request, so there is no earlier iteration whose context the model still sees.
9. **Agent / Tool API translation stays on `agent:{id}`.** The CCC consumer router returns no routing types for requests tagged `ai_agents` or `ai_assistant_api`, so this type is never asked about them and carries no excluded-tag check of its own. Nested `ChatTranslationProvider` chats from an agent tool still emit only `['ai_translate']` and **may** receive this type’s push. That is intended: the inner chat uses its own system prompt (`You are a helpful translator.`) which the agent path does not populate.
10. **Do not scrape the route for the translated entity.** The generic path never uses the route. Attach `entity_context` on the `ChatInput` from the orchestrator / `$entityContext` array.
11. **Publish source and target langcodes on the chat request as `language_context`.** Chat-proxy sets request metadata `language_context` with `target` (required) and `source` (when known). Values come from the translation job, never from the route. The key is deliberately generic, not translation-specific: other consumers with a target language (CKEditor Translate, the agent path, automators on a translation) can emit the same key, and the CCC Language scope reads one contract (ai_context#3586532+s). CCC does not read it yet. French-only vs any-language items still stay on `translate:default`. Do not add `translate:fr` consumer rows for that.
12. **Cached translations are keyed per entity only while context is pushed.** Pushed context can depend on the entity being translated (Entity Types, Taxonomy, Specific Entities), so while push is on for `translate:default` the translation cache ID includes the sanitized `entity_context`, and entries are tagged with `ai_context_item_list` and `config:ai_context.settings`. Push is off by default; while it is off the ID is unchanged and one string is shared across the site, so a site that installs AI Context for agents pays nothing. Whenever `ai_context` is enabled, entries are also tagged with `config:ai_context.consumers` and `config:ai_context.consumer_type_settings.translate`, so switching push on or off, or disabling the type, invalidates the cache and an entry is never served under the other setting. "Invalidates" means the next translation of that text reaches the provider again; translations saved on content are never touched. A cache hit still skips the chat call, so nothing is pushed and no CCC usage is recorded for that field; that is documented.
13. **Opt back into one cached translation per string.** A Translate consumer type setting, **Share cached translations across content while context is pushed** (`ai_context.consumer_type_settings.translate`, `share_entity_cache`, off by default, stored only while checked), keeps one entry per string even while pushing, for sites whose context items are site-wide guidance. Tags are unchanged. It lives on the type settings form in CCC because it only means anything while CCC pushes.
## What this ships
- `AiContextConsumerType` plugin `translate` (`AiContextConsumerTypeTranslate`) in the main module
- `getRoutingRequestTags()` returns `['ai_translate']`
- `resolveConsumerId()` claims a request only when tags include `ai_translate`; otherwise `NULL` (decline). Agent and assistant requests never reach it (decision 9). The returned consumer ID is `translate:default`
- `getInstances()` returns the one `default` instance
- `getLabelRoute()` returns `ai_translate.settings_form` (there is a single settings form, unlike CKEditor tools)
- Combined push gate already provided by AI Context: instance `push_enabled` AND type `isEnabled()`. `isAvailable()` is the base class default; discovery is the gate
- Config schema for `ai_context.consumer_type_settings.translate` (`enabled`, `share_entity_cache`); no install config
- `buildTypeSettingsForm()` / `submitTypeSettingsForm()` on the type add the share option below the enabled flag; the description says when push is currently off
- Attach `entity_context` on the `ChatInput` (`entity_type`, `bundle`, `id`) so Entity Types / Taxonomy / Specific Entities can match. Source it from the existing orchestrator / `translateContent()` `$entityContext` path, not URL scraping in the consumer plugin. Calls with no entity leave it unset; those scopes simply do not match
- Attach `language_context` (`target`, optional `source`) on the `ChatInput` (decision 11)
- Translation cache keyed on `entity_context` while push is on, tagged on AI Context items and settings while push is on, and on the consumers and type configuration whenever `ai_context` is enabled (decision 12), plus the share option (decision 13)
- Docs: matching, Site default (inherit) selection mode, `entity_context`, `language_context`, chat-proxy vs native `translate_text`, agent entrypoint vs this type, per-field billing, anonymous Drush / cron runs, cached translations skipping CCC and the share option, and how to enable the integration
- Kernel coverage modeled on the Automator / CKEditor / test consumer types, kernel and unit coverage for the cache keying, push gate, invalidation, and the type settings form, plus functional coverage that the type appears when the integration is available and push stays off until configured
The generic subscriber, consumer listing, editor, invocation result (`AiContextInvocationResult`), and usage `consumer_id` / `consumer_type` fields already exist in AI Context.
## AI Translate call-site changes (this MR)
`ChatTranslationProvider` currently calls:
`$this->realTranslator->chat($messages, $model, ['ai_translate'])`
That is enough for the single instance to be _claimed_. Unlike CKEditor, no extra instance tag is required in 1.0.
| \# | Change | Why |
|----|--------|-----|
| 1 | Keep the `ai_translate` chat tag | Type eligibility and instance claim |
| 2 | Attach `entity_context` on the `ChatInput` | Generic path never uses the route. Without this, Entity Types / Taxonomy / Specific Entities never match |
| 3 | Thread entity identity through `translateContent()` `$entityContext` from the orchestrator | The provider plugin does not currently receive the entity |
| 4 | Attach `language_context` on the `ChatInput` (`target`, optional `source`) | Prep for CCC Language matching under a generic key. Not a second consumer instance. |
| 5 | Key the translation cache on `entity_context` and tag it on AI Context changes while push is on | A cache hit skips the chat call. Without this, one entity's context-shaped translation is served to another and stale entries survive context changes |
| 6 | Type setting `share_entity_cache` | Lets sites with site-wide items keep one cached translation per string while pushing |
Do not add a language-pair or route reconstruction fallback in the consumer plugin. If `ai_translate` is missing, decline.
No temporary AI Context subscriber is required. This project owns the call site.
## Site-builder path
1. Enable AI Context and AI Translate.
2. Enable the **Translate** consumer type (Consumer Types).
3. Open **Context consumers**.
4. Edit `translate:default`.
5. Turn on **Push context automatically**.
6. Subscribe (for example Use Case = Working with Text, Entity Types = `node:article`) and optionally Always / Never include.
Next tagged chat-proxy run (UI or Drush, when the configured provider is the chat proxy) → tag match → push → system prompt gets the CCC block. `ChatTranslationProvider` already sets a system prompt; the subscriber appends and `ProviderProxy` re-applies it.
Invocation result lands on event / input metadata (`PUSHED`, `NO_ITEMS`, `PUSH_DISABLED`, `TYPE_DISABLED`, `STALE`, `DECLINED`, `AMBIGUOUS`). Translate may surface that later; CCC does not.
Pull via `getRenderedContext()` / `getResult()` still works for custom callers. This issue is the automatic push path.
## Limits that stay
- **Chat only.** Native `translate_text` providers never hit this subscriber
- **Push is opt-in.** No saved row and no `push_enabled` means no injection
- **Site default on first save.** The row inherits the site-wide selection mode (`inherit` with AI Context 1.0.x after ai_context#3586535+s; Minimal on earlier betas). A Minimal site default with no subscriptions means almost no items, not “all published context”
- **Not agent tools.** Loop-aware injection and Context Tools stay on the agent path (`agent:{id}` from #3585523+s)
- **No language-pair matching.** Missing later `ai_translate:{pair}` tags are out of scope; do not reconstruct from URL language codes. `language_context` is published but not read until ai_context#3586532+s
- **UI, Drush, and interface translation share one row**
- **Per-field chats.** One chat call per field today (until ai_translate#3585527+s), same tags, so context is selected, billed, and usage-tracked N times. Per-instance `max_tokens` is the control; document this
- **Cached translations skip CCC.** With **Cache translation results** on, a cache hit makes no chat call, so nothing is pushed and no CCC usage is recorded for that field. While push is on, entries are keyed per entity (unless the type's share option is on) and invalidated on context item and AI Context settings changes; rebuild the cache or turn the setting off when every run must reach the provider
- **Batch / Drush / cron may be anonymous.** Roles fail closed, and context item view access fails closed too, so anonymous runs match the consumer but push no items (`status=no_items`) unless that role has been granted access to view context items. Run Drush with `--user` or an equivalent account when pushed context is needed. Always include still bypasses hard filters but not view access
- **CKEditor Translate is out of scope.** That is `ckeditor:ai_ckeditor_translate`
- **2.x off-ramp.** If the chat-proxy path becomes an agent later, migrate `translate:default` → `agent:{id}` and remove this type
## Acceptance criteria
- [ ] `translate` type plugin with the as-built contract (`getRoutingRequestTags()`, `resolveConsumerId()`, `getInstances()`, `getInstanceDefaults()`, `getLabelRoute()`, type settings form)
- [ ] Canonical ID is `translate:default`; type plugin ID has no colon
- [ ] Exactly one instance; not a hardcoded language-pair list
- [ ] The type does not exist without `ai_context`; no runtime version check
- [ ] `push_enabled` defaults false; type `enabled` kill switch works
- [ ] Selection mode follows the site default; the type does not override the AI Context base defaults
- [ ] `resolveConsumerId()` returns `translate:default` when tags include `ai_translate`, or `NULL`; the router keeps `ai_agents` / `ai_assistant_api` requests off this type
- [ ] No route or language-code reconstruction fallback
- [ ] Chat tag `ai_translate` stays on the chat-proxy call
- [ ] `entity_context` is on the `ChatInput` when the orchestrator has an entity
- [ ] `language_context` (`target`, optional `source`) is on the `ChatInput`
- [ ] Translation cache is keyed on `entity_context` and invalidated on AI Context item and settings changes while push is on; unchanged while push is off or without `ai_context`; push and type toggles invalidate the cache
- [ ] Type setting `share_entity_cache` keeps one entry per string while pushing; off by default; stored only while checked
- [ ] No loop-aware or Debug / Explore UI on this type
- [ ] Config schema for the type settings; no install config
- [ ] Docs cover matching, Site default (inherit) selection mode, `entity_context`, `language_context`, native `translate_text`, agent vs chat-proxy, per-field billing, anonymous runs, cached translations skipping CCC, and the share option
- [ ] Kernel + functional tests as above, plus kernel and unit coverage for cache keying, the push gate, invalidation, and the type settings form
## Related issues
- ai_context#3586346+s Implement plugin-based generic context consumers
- ai_context#3586535+s Store inherit as a consumer selection mode and default types to it
- ai_context#3586532+s Honor `language_context` metadata in Language scope
- ai_ckeditor#3615834+s Create AI CKEditor CCC context consumer plugin
- ai_context#3586451+s Create automators context consumer plugin
- ai_context#3586452+s Placeholder for the CKEditor type
- ai_translate#3585523+s Tool API / agent-driven translation
- ai_context#3586446+s Test CCC with AI Translate agent MR
- ai_context#3586319+s Move from agent-specific to generic context consumer API
- ai_translate#3585527+s Opt-in batch translation (per-field billing until this lands)
## AI usage
- [x] AI assisted issue
issue
GitLab AI Context
Project: project/ai_translate
Instance: https://git.drupalcode.org
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://git.drupalcode.org/project/ai_translate/-/raw/1.4.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/ai_translate
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD