AI Integration - ECA: Interceptor
Every AI call that goes through the AI module's provider proxy dispatches events: before the provider is called, after it has answered, when a streamed answer has finished, and when the call throws. This submodule turns those four moments into ECA events, so a site builder can inspect and change AI traffic from a model instead of from code.
The interceptor governs calls that other code makes — an AI Assistant, an automator, a custom module. If you want ECA itself to make an AI call, use the actions of the parent module instead (see Usage). The two are complementary and can be enabled together.
Requirements
ai:ai1.4 or newer. The failover action relies onAiExceptionEvent::setForcedOutputObject(), which was added in AI 1.4.0.eca:eca- Drupal
^10.3 || ^11 || ^12
The submodule does not depend on the parent ai_integration_eca module, so it
can be enabled on its own:
drush en ai_eca_interceptor
There is nothing to configure. Enabling the module makes its plugins available
in the ECA modeler at /admin/config/workflow/eca.
Events
Four event derivatives, all of them derived from the single event plugin
ai_eca_interceptor:
| Plugin id | Label | Fires on |
|---|---|---|
ai_eca_interceptor:ai_request |
AI request: before provider call | PreGenerateResponseEvent — before the provider is called |
ai_eca_interceptor:ai_response |
AI response: after provider call | PostGenerateResponseEvent — after the provider answered |
ai_eca_interceptor:ai_stream_finished |
AI response: stream finished | PostStreamingResponseEvent — after a streamed answer completed |
ai_eca_interceptor:ai_response_failed |
AI response: failed | AiExceptionEvent — the provider call threw |
ai_response_failed fires for every consumer of the AI provider proxy, not
just for chat, and it fires for any exception class.
Streaming and event order
When the answer is streamed, neither response event fires where its name suggests, and the ordering decides which of the two an action can still influence.
On a streamed chat request the provider returns an iterator, not text. The
provider proxy dispatches PostGenerateResponseEvent — and with it
ai_response — before it has looked at what kind of output it is holding: the
check for a streamed iterator, and the attaching of stream metadata, happen only
after that dispatch. So ai_response runs while the answer is still an
iterator nobody has read from yet, before the first character is visible to
the client. "After provider call" is literally true; "the answer is ready"
is not.
ai_stream_finished is the derivative that sees the complete answer. It is
dispatched from inside the iterator itself, after the last chunk has been
yielded to the caller and the full text has been reassembled — by the time it
fires, the client already has every character. That is also the only place the
event is dispatched from, which has two further consequences: it never fires for
a non-streamed call, and it never fires for a stream the consumer abandons half
way, because the dispatching code sits past the end of the loop.
For the response actions this means:
- On
ai_responsethey are effective. Nothing has been sent yet, so replacing or blocking the output still changes what the caller receives. - On
ai_stream_finishedthey are observational. The chunks are already gone; rewriting the text cannot un-send what the client has received.
So ai_response is where you intervene in a streamed answer, and
ai_stream_finished is where you log, measure or record one. On ai_response,
[event:output_is_streamed] tells the two cases apart.
Filtering an event
All four derivatives expose the same three configuration fields:
| Field | Description |
|---|---|
operation_type |
Limit to one AI operation type, e.g. chat, embeddings, moderation. |
provider_id |
Limit to one provider id. |
model_id |
Limit to one model id. |
Each field is matched exactly, and an empty field means * — any value.
The three values are combined into a single wildcard of the form
operation_type:provider_id:model_id, which is what ECA subscribes with. So:
- everything empty →
*:*:*— every AI call chat/ empty / empty →chat:*:*— every chat call, whatever providerchat/openai/gpt-4o-mini→chat:openai:gpt-4o-mini— that one model
Two things to know about these fields:
- They are plain text fields with no token replacement. The wildcard is
computed when the model is saved, so a
[token]in a filter field would be matched literally and never hit. - The filter is a coarse pre-selection. For anything finer — a tag, a prompt
length, an exception type — use a condition or a
[event:*]token inside the model.
Conditions
There are two conditions.
| Plugin id | Label | Available on | Configuration |
|---|---|---|---|
ai_eca_interceptor_has_tag |
AI event: has tag | all four derivatives | tag (required, supports token replacement) |
ai_eca_interceptor_response_failed |
AI response: failed (any exception) | ai_response_failed only |
none beyond ECA's own negate |
AI event: has tag matches a tag whether it sits in the tag array as a value or
as a key — see Tags below for why both shapes occur.
AI response: failed (any exception) is simply true when the current event is a
failure event. It is useful when a model listens to more than one derivative and
has to branch. To distinguish exception types, do not look for more condition
plugins — use the [event:is_*] tokens, for example [event:is_rate_limit]
equals 1, with ECA's generic comparison condition.
Both conditions support ECA's standard negation.
Actions
19 actions. Each one declares which event it can run on, and denies access with an explicit reason when it is placed on the wrong one — ECA logs that reason and the process debugger shows the action as access-denied rather than executed. So a misplaced action is visible, not silent.
The label prefix tells you where a plugin belongs before you place it:
| Prefix | Runs on |
|---|---|
AI request: |
the request event only (AI chat request: narrows that to chat operations) |
AI response: |
the response events only, and AI response failed: on the failure event |
AI event: |
every derivative — request, both responses and the failure event |
AI: |
not bound to an AI event at all |
On ai_request (before the provider call)
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_reroute |
AI request: reroute provider/model | provider_id (required), model_id (required) — both support token replacement |
ai_eca_interceptor_set_config |
AI request: set config value | key (required), value, cast (string | int | float | bool, default string) |
ai_eca_interceptor_unset_config |
AI request: unset config value | key (required) |
ai_eca_interceptor_attach_guardrail_set |
AI request: attach guardrail set | guardrail_set_id (required) — machine name of an ai_guardrail_set config entity |
ai_eca_interceptor_block_request |
AI request: block | mode (fallback — default — or exception), message |
ai_eca_interceptor_force_chat_output |
AI request: force chat output | text (required), role (default assistant) — chat operations only |
Notes:
- Reroute and set config both act on the request that is about to go
out.
set configwrites into the provider configuration array (temperature,max_tokens, …); thecastoption exists because a value arriving through token replacement is always a string. - Attach guardrail set adds to the request rather than replacing, so guardrail sets that were already attached survive. It denies access with a distinct reason for an empty id and for an id that names no guardrail set.
- Block request in
fallbackmode returns a fallback answer without calling the provider. That fallback output can currently only be built forchat, so on any other operation type this mode does nothing at all and the request goes through. Inexceptionmode it throws aRequestBlockedExceptionwhatever the operation type, which is the mode to use when blocking has to be reliable. - Force chat output is the deliberate version of the same idea: return a canned answer and skip the provider entirely.
On ai_request, chat requests only
These four additionally require the request input to be a ChatInput, and say
so in their denial reason when it is not.
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_chat_set_system_prompt |
AI chat request: set system prompt | prompt |
ai_eca_interceptor_chat_append_message |
AI chat request: append message | role (default user), text |
ai_eca_interceptor_chat_replace_message_text |
AI chat request: replace message text | index (required, zero-based), text |
ai_eca_interceptor_chat_set_streamed |
AI chat request: set streamed | streamed (checkbox) |
AI chat request: replace message text checks the index while deciding access,
so an out-of-bounds index is reported and logged instead of quietly doing
nothing. The denial reason names the index and how many messages the request
actually carries.
On ai_response and ai_stream_finished
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_set_response_text |
AI response: set text | text (required) |
ai_eca_interceptor_replace_chat_output |
AI response: replace chat output | text (required), role (default assistant) — chat operations only |
ai_eca_interceptor_block_response |
AI response: block | mode (exception — default — or fallback), message |
Notes:
- Set text replaces the textual part of the answer and keeps everything else. It understands chat, summarization, translation and speech-to-text output; for any other output type it does nothing.
- Replace chat output builds a completely fresh chat answer from
textandrole, discarding the provider's raw output and metadata. - Block response defaults to throwing a
ResponseBlockedException. Itsfallbackmode replaces the answer, and — as its own option label says — that replacement can only be built for chat, so on any other operation type this mode lets the original answer through unchanged. - On a streamed chat answer, both Set text and Replace chat output
end the streaming. The normalized output is an iterator, not a
ChatMessage, so instead of editing the text in place they build a freshChatOutputaround a single staticChatMessage: the caller receives one complete message where it expected a sequence of chunks. Nothing warns about it. That is the price of acting at the only point where acting still has an effect — see Streaming and event order.
On ai_response_failed
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_failover_to_provider |
AI response failed: fail over to provider/model | provider_id (required), model_id (required) — both support token replacement |
ai_eca_interceptor_failover_chat_output |
AI response failed: return chat output | text (required), role (default assistant) — chat operations only |
ai_eca_interceptor_rewrite_exception_message |
AI response failed: rewrite message | message (required) |
Notes:
- Fail over to provider/model retries the same operation, with the same
input and configuration, against the backup target. If the retry produces a
usable output it becomes the answer the caller receives, and the recovery is
recorded in event metadata under
ai_eca_interceptor.failover.executed. If it does not, the attempt is logged and recorded underai_eca_interceptor.failover.failed(with amessage), and the original exception propagates unchanged. The action never throws an exception of its own, precisely so the provider's original cause is not lost. - Both provider and model are required, on this action and on reroute: model ids are not portable between providers, so there is no sensible default.
- Rewrite message replaces only the message. The exception class is
preserved by AI core, so existing
catchblocks keep working. Use it to turn a raw provider error into something a user can read.
On any AI event
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_set_tag |
AI event: set tag | tag (required) |
ai_eca_interceptor_set_metadata |
AI event: set metadata value | key (required), value |
Both run on the request event, both response events and the failure event —
which is what the AI event: prefix marks. Metadata is the module's own side
channel: the reroute and failover paths write their results there, and a later
condition can read it back through [event:metadata].
Not bound to an event
| Plugin id | Label | Configuration |
|---|---|---|
ai_eca_interceptor_count_tokens |
AI: count tokens | text (required), model, token_name (required) |
AI: count tokens runs the text through the AI module's tokenizer and writes the
integer count into the ECA token named by token_name, so later conditions can
branch on it. It works in a plain ECA model as well as inside an AI event flow.
Leave model empty inside an event flow and it uses the event's model id; with
neither, it falls back to gpt-3.5-turbo to pick an encoding. It is the one
action here that denies access for a configuration reason only — no result token
name means the count would have nowhere to go.
Tokens
The event plugin exposes an [event:*] token set. Which tokens carry a value
depends on the derivative.
Always available
| Token | Description |
|---|---|
[event:machine_name] |
Machine name of the triggered ECA event, e.g. ai.pre_generate_response. |
[event:request_thread_id] |
Unique id of the AI request thread. |
[event:request_parent_id] |
Parent request id when this request was started from another AI request. |
[event:provider_id] |
The provider that will execute, or did execute, the call. |
[event:model_id] |
The model id. |
[event:operation_type] |
chat, embeddings, moderation, speech_to_text, text_to_speech, … |
[event:tags] |
Tags attached to the request (array). |
[event:tag_count] |
Number of tags attached. |
[event:configuration] |
Provider configuration array (temperature, max_tokens, …). |
[event:debug_data] |
Debug data attached to the request. |
[event:metadata] |
All metadata on the event, including the reroute and failover keys. |
[event:is_chat] |
1 when the operation type is chat, 0 otherwise. |
[event:input] |
The input object flattened to an array. |
[event:input_class] |
Class name of the input object. |
[event:input_text] |
Best-effort text of the input: chat prompts joined with newlines, or the summarization / translation text. |
These are available on all four derivatives, including the failure event —
AiExceptionEvent carries the original request, so a failed call still knows its
provider, model, operation type and input.
Chat only
Present on every request-carrying event, but only meaningful when the input is a chat input. For anything else they hold empty or zero defaults, so a model can read them unconditionally.
| Token | Description |
|---|---|
[event:chat_tools] |
The declared tools rendered to an array. |
[event:chat_tool_names] |
Names of the declared tool functions (array). |
[event:chat_tool_count] |
Number of declared tool functions. |
[event:chat_has_tools] |
1 when the request declares any tools. |
[event:chat_has_schema] |
1 when the request declares a structured JSON schema. |
[event:chat_streamed] |
1 when the request asked for streamed output. |
Response only
On ai_response and ai_stream_finished.
| Token | Description |
|---|---|
[event:output] |
The normalized output as an array. |
[event:output_class] |
Class name of the output object. |
[event:output_normalized] |
The normalized output, in array form where possible. |
[event:output_raw] |
The provider's raw output payload. |
[event:output_metadata] |
Metadata returned by the provider. |
[event:output_text] |
Best-effort text of the output. |
[event:output_is_streamed] |
Chat. 1 when the output is a streamed iterator. |
[event:token_usage:input] |
Chat. Input tokens consumed. |
[event:token_usage:output] |
Chat. Output tokens produced. |
[event:token_usage:total] |
Chat. Total tokens used. |
[event:token_usage:reasoning] |
Chat. Reasoning tokens used, if reported. |
[event:token_usage:cached] |
Chat. Cached tokens reused, if reported. |
[event:rate_limits:max_requests] |
Chat. Rate limit request window. |
[event:rate_limits:max_tokens] |
Chat. Rate limit token window. |
[event:rate_limits:remaining_requests] |
Chat. Requests left in the window. |
[event:rate_limits:remaining_tokens] |
Chat. Tokens left in the window. |
[event:rate_limits:reset_requests] |
Chat. Seconds until the request window resets. |
[event:rate_limits:reset_tokens] |
Chat. Seconds until the token window resets. |
[event:moderation_flagged] |
Moderation. 1 when the response is flagged. |
[event:moderation_message] |
Moderation. The moderation response message. |
Failed event only
On ai_response_failed.
| Token | Description |
|---|---|
[event:exception_class] |
Class name of the exception that was thrown. |
[event:exception_message] |
Exception message. This is the possibly rewritten message. |
[event:exception_code] |
Exception code. |
[event:exception_file] |
File where the exception originated. |
[event:exception_line] |
Line where the exception originated. |
[event:is_rate_limit] |
1 for AiRateLimitException. |
[event:is_quota] |
1 for AiQuotaException. |
[event:is_unsafe_prompt] |
1 for AiUnsafePromptException. |
[event:is_missing_feature] |
1 for AiMissingFeatureException. |
[event:is_bad_request] |
1 for AiBadRequestException. |
[event:is_response_error] |
1 for AiResponseErrorException. |
[event:is_request_error] |
1 for AiRequestErrorException. |
The is_* tokens replace what used to be one condition plugin per exception
type: branch on them with ECA's generic comparison condition. Note that
is_bad_request only fires for exceptions AI core actually wrapped into
AiBadRequestException; a provider throwing a plain RuntimeException sets
none of the is_* flags, and only exception_class identifies it.
Worked example: reroute tagged traffic to a cheaper model
Goal: any chat request tagged bulk should be answered by a cheap model, while
everything else keeps using whatever the calling code chose.
- Event —
AI request: before provider call. Set the operation type filter tochatand leave provider and model empty, giving the wildcardchat:*:*. - Condition on the successor —
AI event: has tag, with Tag set tobulk. - Action —
AI request: reroute provider/model, using the ids from your own provider configuration:- Provider id:
openai - Model id:
gpt-4o-mini
- Provider id:
That is the whole model. What happens at runtime is worth understanding, because the action alone does not perform the switch:
- The action only records the intent, writing
ai_eca_interceptor.reroute.provider_idand…reroute.model_idinto the event metadata. - A subscriber runs later on the same event, at priority
-100, and performs the actual call against the target provider. It sets the result as the forced output, so the original provider is never called. - On success it records
ai_eca_interceptor.reroute.executedin the metadata, with the provider and model it used. A later model can read that through[event:metadata]. - If the target provider throws, the failure is logged and no forced output is set, so the original provider call proceeds as if nothing had happened.
Because the reroute happens inside the same request, the target call dispatches
its own AI request: before provider call event. A recursion guard on the
subscriber makes the reroute action deny access while a reroute is in flight, so
the rerouted call cannot reroute again.
Tagging can be part of the same model: put AI event: set tag on the request
event to add bulk yourself, based on whatever ECA can see.
Worked example: fail over to a backup provider
Goal: when the primary provider throws, retry once against a backup, and if that also fails, hand the user a polite message rather than a stack trace.
- Event —
AI response: failed, all three filters empty (*:*:*). - Action —
AI response failed: fail over to provider/model, using the ids of your own backup provider configuration:- Backup provider id:
anthropic - Backup model id:
claude-haiku-4-5
- Backup provider id:
- Action —
AI response failed: rewrite message, with Exception message set to something likeThe assistant is temporarily unavailable. Please try again shortly.
At runtime:
- The failover action retries the same input and configuration against the
backup. If that returns a usable output, it becomes the answer the caller
receives and
ai_eca_interceptor.failover.executedis written to the event metadata. - If it does not, the failure is logged, written to
ai_eca_interceptor.failover.failedtogether with amessage, and the original exception continues on its way out. - Step 3 is safe to leave in place unconditionally, because rewriting the message touches only the exception and never the output. Whatever the failover did, the answer it produced is not affected.
For a chat call you can go one step further and return an answer instead of an
exception: add AI response failed: return chat output after the failover.
It stands aside when the backup answered and supplies its canned text only when
nothing else did — see Ordering these actions
matters below.
Ordering these actions matters
Both output-producing recovery actions — AI response failed: fail over to
provider/model and AI response failed: return chat output — check first and
do nothing when an output has already been forced. Neither overwrites an answer
another action produced, so the first one to produce an output wins and the
order you put them in is what decides the recovery strategy.
That makes the useful combination straightforward: put the failover first and the canned output after it. The failover tries the backup provider; if it answers, that answer is what the caller gets and the canned action steps aside. If the backup fails too, nothing has been forced, and the canned text fills in as the last resort instead of the exception propagating.
The reverse order is a trap: return chat output forces its output
immediately, so the failover that follows it sees a forced output and returns
without ever trying the backup. Put the last resort last.
return chat output on its own is still the right tool when you have no backup
provider and simply want a canned answer for a failed chat call. And when you
want the backup answer and a friendlier exception message on the paths where
nothing recovered, use rewrite message for the message, as above.
Failover recursion protection
The backup call is made synchronously, so if the backup fails it dispatches its
own failure event, which would trigger the same failover again. The guard that
prevents this tracks specific backup targets, keyed provider_id:model_id
and reference counted, rather than a single global "a failover is running" flag:
- A failure on a target that is currently being attempted is suppressed. That is the backup failing on its own target — the genuine loop.
- Any other failure that happens while the backup runs is free to fail over on its own. This matters in practice: an LLM-based guardrail evaluated on the backup request may classify the prompt with its own chat call, and that call must be recoverable — even when it happens to reuse the provider and model whose failure started the whole thing.
Tags
Tags reach the event from two directions, and they end up in two different shapes in the same array:
- The AI module seeds the event with a plain list of tag strings.
AI event: set tagstores the tag name as an array key.
So a request that already carried tags and then had one added by ECA ends up
with something like [0 => 'alpha', 1 => 'beta', 'bulk' => TRUE]. AI event: has
tag accepts either shape, so a model can match a tag without caring where it
came from.
When the reroute or failover path calls another provider, it first normalizes that array into a clean list before passing it on:
- A string key is a tag name; a string value is a tag name.
- Where both could apply, the string value wins.
- Empty entries are dropped, duplicates are dropped keeping the first occurrence, and the original order is preserved — list tags first, then tags added later.
- Normalization produces the list handed to the target provider; the event's own tags are left untouched, so later listeners still see what they expect.
The practical consequence: tags survive a reroute or a failover, which keeps provider-side routing, logging and cost attribution correct on the retry.
Guardrails and the subscriber order
AI request: attach guardrail set needs some care, because AI core's own
guardrails subscriber runs at priority 0 — the same priority as ECA — and wins
the tie by module registration order. By the time ECA attaches a guardrail set,
core has already looked and found none.
The submodule therefore ships a second subscriber that applies the pre-generate guardrails ECA attached, after ECA has finished. Post-generate guardrails need no help: they fire on the response event, by which time the guardrail set is already on the input.
The resulting order on AI request: before provider call is fixed, and pinned by
a test:
| Priority | Listener |
|---|---|
0 |
AI core's guardrails subscriber |
0 |
ECA — runs the interceptor's actions |
-10 |
Guardrail applier — applies guardrail sets attached by an ECA action |
-100 |
Reroute subscriber — executes reroutes after all actions and guardrails |
All four event derivatives declare subscriber_priority: 0, and that value is
load-bearing rather than incidental. It is what keeps ECA ahead of the two
subscribers above.
Troubleshooting
- The model never fires. Check the event filter first. All three fields match
exactly and do not resolve tokens, so a typo in a provider or model id, or a
token placed in a filter field, silently narrows the wildcard to something no
call matches. Empty is
*. - An action is skipped. Look at the ECA log. Every action here denies access with a specific reason — wrong event, wrong operation type, input that is not a chat input, a key that is empty after token replacement — and ECA records the denial along with that reason. The process debugger shows the action as access-denied rather than executed.
- A configured value silently disappears. Actions that gate on a value —
provider ids, model ids, configuration keys, metadata keys, tags, guardrail set
ids — resolve it with a token replacement that removes tokens it cannot
resolve, rather than leaving the literal
[event:something]behind. So a mistyped token produces an empty value and a logged denial, instead of a configuration key or tag literally named[event:something]. - A reroute did nothing. The reroute is executed by a subscriber after all
ECA actions have run. If its call to the target provider throws, that is logged
and the original provider is used instead, so the request still succeeds —
check the log for
AI ECA reroute failed.