mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-08-29 10:08:58 +02:00
Client auto retry for empty, rate-limited and missing-reasoning responses (#103)
* feat: per-client auto retry for empty, rate-limited and missing-reasoning responses (#102) * fix: scope auto-retry alert close to the emitting client * fix: make auto-retry Abort effective between attempts in scene-free generations * fix: auto-retry alert was invisible for fast retry sequences - enforce minimum display time, keep content through fade-out, offset below status snackbar * fix: reset auto-retry alert display window on client content handoff * fix: address auto-retry review round 3 - client-owned abort latch, reasoning retry extraction, clamped retry config, countdown and layout polish * fix: discard stale auto-retry abort latch at sequence entry, disable Abort during linger * fix: explicit dialog choice discards stale auto-retry abort latch * fix: key auto-retry abort and alert by per-sequence generation id - same-client concurrency (FOCAL streaks, background chats) is normal operation, not experimental * test: update direct _generate_with_error_handling callers for generation id param * docs: concurrent requests are used beyond visual prompt generation * fix: don't consume an active scene's interrupt flag in retry checks, ignore late aborts for finished sequences * refactor: unify the three auto-retry policy blocks into _auto_retry_or_prompt * docs: add Mistral to concurrent requests supported clients
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- "Help Agent: A new Help agent provides an interactive help chat that answers questions about Talemate itself — settings, agents, clients, the world editor and more — grounded in the bundled documentation, which it can search and read before answering. Opened via the help icon in the app bar, it works with or without a loaded scene, supports multiple persistent multi-turn chats, and runs in the background so it never blocks the main Talemate loop. A per-chat Scene Aware toggle lets a conversation see (or stay unaware of) the currently loaded scene, and each question carries a small snapshot of what you are looking at in the interface so contextual questions like 'what does this setting do?' can be answered. The most recent answer can be regenerated via a button right on the message, matching the director chat. Beyond answering questions, the help agent can read your actual configuration — any agent's settings, application settings, and a read-only view of your clients (API keys are never exposed) — and, when asked, change agent settings (globally or as a per-scene override) and application settings directly from the chat, recording exactly what changed. It cannot change scene content — scene changes remain the director chat's job."
|
||||
- "Scene Backdrop: Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Set it via 'Set as scene backdrop' on any illustration's image menu, the 'Set backdrop' button in the Visual Library, or enable 'Auto Backdrop' per visual type (Settings → Appearance → Message Visuals) to have newly generated Scene Backgrounds ('Visualize Scene (Background)') and/or Scene Illustrations ('Visualize Moment') promoted automatically. An 'Immersive' quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable — and a small marker icon shows which message's image is the current backdrop (click it for the image menu, which stays reachable via an Illustration chip on the message hover toolbar)."
|
||||
- "Visual Prompt Finalization: The Visualizer agent gained a Prompt Finalization settings tab defining post-processing actions (exact, fuzzy or regex match and replace, or an AI instruction) that rewrite image prompts right before they are sent to the image generation backend. Actions can target positive and/or negative prompts, be restricted to specific visual types, and be overridden per scene. Characters can define their own actions under World Editor → Characters → Visuals → Prompt Finalization, which run after the agent's. Reusable action sets are managed as a new 'Visual prompt finalizer' template type, including a shipped Ideogram JSON preset that converts the positive prompt into an Ideogram 4.0 structured JSON prompt. Prompt-only generation output is finalized as well, and a new FinalizePrompt node exposes the step to custom node graphs."
|
||||
- "Client Auto Retry: Clients can now automatically retry on response issues before you are notified — separate 0–5 retry sliders for empty responses, API rate limiting (HTTP 429, with progressively longer waits between attempts), and reasoning models that skip their reasoning tokens. A notification shows the retry progress with an abort option, and when retries run out (or a slider is at 0, the default) the usual generation error dialog appears."
|
||||
- "New Node Graph Events: Node graphs can now hook into more of this release's features. `agent.visual.prompt_finalize.before` / `.after` fire around visual prompt finalization (mutable prompts, and the `.before` finalizer list can be modified — they fire even with the agent setting disabled, so a graph can act as its own finalizer). `agent.creator.dialogue_examples.before` / `.after` fire around character example dialogue generation (inject instructions, or rewrite the generated examples). `agent.help.chat.before` / `.after` fire around help chat responses. Scene asset mutations gained `asset_deleted`, `scene.backdrop_changed`, `scene.cover_image_changed` and `character.cover_image_changed` alongside the existing `asset_saved`. All are documented in the node editor's Events reference."
|
||||
- "Simplified Character Card Import: The character card import dialog gained an AI Generation section with individual toggles for each generation step — content context, description rewrite, attribute extraction, dialogue instructions, example dialogue, and story intent — plus Full/Minimal preset buttons to flip them all at once. Disabled steps fall back to the card's original data (the description and example dialogue import verbatim), so a minimal import needs no text generation at all and completes in a fraction of the time."
|
||||
improvements:
|
||||
|
||||
16
docs/user-guide/clients/auto-retry.md
Normal file
16
docs/user-guide/clients/auto-retry.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Auto Retry
|
||||
|
||||
By default, when a generation runs into a response issue — an empty response, an API rate limit, or a reasoning model that skipped its reasoning tokens — Talemate notifies you immediately with a dialog offering to retry, ignore, or cancel the generation.
|
||||
|
||||
You can instead configure a client to quietly retry a number of times on its own before you are notified. Each response issue has its own slider (0 to 5 retries, default 0 = notify immediately):
|
||||
|
||||
- **Empty Response** — the model returned an empty response. Retries fire immediately.
|
||||
- **Rate Limited** — the API responded with HTTP 429. Retries wait progressively longer between attempts (2s, 4s, 8s, 16s, capped at 30s). Other API errors are not affected and still notify you immediately.
|
||||
- **Missing Reasoning** — a reasoning model's response did not contain the expected reasoning pattern. Retries fire immediately. See [Reasoning](reasoning.md).
|
||||
|
||||
The **Empty Response** and **Rate Limited** sliders are on the **Advanced** tab of the [client configuration](client-configuration.md) dialog. The **Missing Reasoning** slider is on the **Reasoning** tab, next to the **Pattern Not Found Behavior** setting (it only appears when that setting is **Fail** — with **Ignore**, a missing pattern is never treated as an error).
|
||||
|
||||
While automatic retries are running, a notification at the top of the screen shows which client is retrying, why, and the attempt count. You can abort the generation from there at any time. If all automatic retries are exhausted, the usual generation error dialog appears.
|
||||
|
||||
!!! note "Rate limit responses vs. the Rate Limit slider"
|
||||
The **Rate Limited** retry slider reacts to the *API* telling Talemate to slow down (HTTP 429). This is separate from the [Rate Limit](rate-limiting.md) slider, which is Talemate's own client-side cap on requests per minute.
|
||||
@@ -19,7 +19,7 @@ The tabs that appear depend on the client type. The core set is:
|
||||
|---|---|
|
||||
| **General** | Client type, name, API URL / key, model, context length, prompt template (for local clients). |
|
||||
| **Coercion** | Prefill text used to enforce compliance. Only shown for clients that can be coerced. |
|
||||
| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, and [Rate Limit](rate-limiting.md). |
|
||||
| **Advanced** | Inference Presets, Structured Data Format, [Section Format](section-format.md), [Response Length Enforcement](response-length.md), Prompt Caching, [Rate Limit](rate-limiting.md), and [Auto Retry](auto-retry.md). |
|
||||
| **Reasoning** | [Reasoning model support](reasoning.md) settings. |
|
||||
| **System Prompts** | Per-client [system prompt overrides](../app-settings/system-prompts.md). |
|
||||
|
||||
@@ -37,6 +37,7 @@ The Advanced tab contains settings that you usually only need to touch once per
|
||||
- **Response Length Enforcement** — how the response length is communicated to the model. See [Response Length Enforcement](response-length.md).
|
||||
- **Optimize for Prompt Caching** — moves volatile context after the scene history to improve cache hit rates. See [Volatile Context Placement](../prompts/volatile-context-placement.md).
|
||||
- **Rate Limit** — caps requests per minute. See [Rate Limiting](rate-limiting.md).
|
||||
- **Auto Retry** — automatic retries on empty or rate-limited responses before you are notified. See [Auto Retry](auto-retry.md).
|
||||
|
||||
From the General tab you can also jump straight to Advanced with the :material-cog-outline: **Advanced Options** button underneath the basic fields.
|
||||
|
||||
|
||||
@@ -4,9 +4,16 @@ Concurrent requests is an experimental feature that allows certain LLM clients t
|
||||
|
||||
## What It Does
|
||||
|
||||
When enabled, operations that require multiple LLM queries (such as generating image prompts) will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations.
|
||||
When enabled, operations that require multiple LLM queries will execute those queries in parallel instead of sequentially. This can significantly reduce the total time needed for these batch operations.
|
||||
|
||||
**Currently, this feature is only used for visual prompt generation** (creating prompts for image generation). It is not applied to regular conversation or narration tasks.
|
||||
Operations that take advantage of this include:
|
||||
|
||||
- Visual/image prompt generation
|
||||
- Function-calling batches, where an AI response requests several tool calls at once (for example director chat query actions or help agent documentation lookups)
|
||||
- Multi-query world state updates
|
||||
- World state snapshots, which only run as true background tasks when the client can handle a concurrent request
|
||||
|
||||
Regular conversation and narration tasks are single generations and are unaffected.
|
||||
|
||||
## Supported Clients
|
||||
|
||||
@@ -15,6 +22,7 @@ Concurrent requests are available for the following hosted API clients:
|
||||
- [Anthropic](/talemate/user-guide/clients/types/anthropic/)
|
||||
- [OpenAI](/talemate/user-guide/clients/types/openai/)
|
||||
- [Google Gemini](/talemate/user-guide/clients/types/google/)
|
||||
- [MistralAI](/talemate/user-guide/clients/types/mistral/)
|
||||
- [OpenRouter](/talemate/user-guide/clients/types/openrouter/)
|
||||
- [Pi Bridge](/talemate/user-guide/clients/types/pi-bridge/) — each concurrent request runs its own pi instance
|
||||
|
||||
@@ -41,8 +49,8 @@ You can also enable this feature through the client's settings dialog under the
|
||||
|
||||
Consider enabling concurrent requests if:
|
||||
|
||||
- You frequently use the visual/image generation features
|
||||
- You want to reduce wait times during image prompt generation
|
||||
- You frequently use the visual/image generation features, the director or help chats, or tracked world states
|
||||
- You want to reduce wait times during batch operations
|
||||
- You are not experiencing rate limit issues with the API
|
||||
|
||||
You can safely leave this disabled if:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
You can rate limit a client to N requests per minute. The slider is on the **Advanced** tab of the [client configuration](client-configuration.md) dialog.
|
||||
|
||||
This is Talemate's own client-side cap. If instead the *API* is rate limiting you (HTTP 429 responses), see the **Rate Limited** slider under [Auto Retry](auto-retry.md).
|
||||
|
||||

|
||||
|
||||
Once the limit is hit you will get a popup notification.
|
||||
|
||||
@@ -66,6 +66,8 @@ When the configured reasoning pattern is not found in a response, you can contro
|
||||
- **Fail** (default) - Raises an error, causing the request to fail. Use this when you expect the model to always include reasoning tokens and want to be alerted if it doesn't.
|
||||
- **Ignore** - Returns the response as-is without stripping anything. Use this when the model may sometimes respond without reasoning tokens (e.g., for simple queries).
|
||||
|
||||
When set to **Fail**, the **Auto Retry** slider next to it lets the client automatically retry a number of times before you are notified. See [Auto Retry](auto-retry.md).
|
||||
|
||||
## Forcing Reasoning Off for Specific Actions
|
||||
|
||||
The **Enable Reasoning** checkbox is a global setting for a client — when it's on, every prompt that client handles uses reasoning. Sometimes that isn't what you want. A reasoning model might do an excellent job writing dialogue but waste time (and tokens) "thinking" before simple, mechanical tasks like summarization or world-state updates.
|
||||
|
||||
@@ -381,16 +381,23 @@
|
||||
panel to create a new scene. Covers the import dialog, character detection/manual selection, options for character book
|
||||
entries, alternate greetings, shared context setup, writing style template, player character setup, and troubleshooting
|
||||
failed analysis.
|
||||
- path: user-guide/clients/auto-retry.md
|
||||
title: Auto Retry
|
||||
summary: 'Per-client sliders (0-5, default 0 = notify immediately) for automatically retrying response issues before the
|
||||
generation error dialog appears: empty responses, API rate limiting (HTTP 429, with growing backoff between attempts),
|
||||
and reasoning models that skip their reasoning tokens. Covers the retry notification with its abort option and where each
|
||||
slider lives (Advanced tab; Reasoning tab for missing reasoning).'
|
||||
- path: user-guide/clients/client-configuration.md
|
||||
title: Client Configuration
|
||||
summary: 'Tour of the per-client settings dialog and its tabs (General, Coercion, Advanced, Reasoning, System Prompts):
|
||||
where to find Inference Presets, Structured Data Format, Section Format, Response Length Enforcement, Prompt Caching,
|
||||
and Rate Limit, plus the Simple View toggle for quick setup.'
|
||||
Rate Limit, and Auto Retry, plus the Simple View toggle for quick setup.'
|
||||
- path: user-guide/clients/concurrent-requests.md
|
||||
title: Concurrent Requests (Experimental)
|
||||
summary: Experimental feature that lets certain clients (Anthropic, OpenAI, Google, OpenRouter, llama.cpp) run multiple
|
||||
LLM requests in parallel — currently used only for visual/image prompt generation. Covers how to enable it via the client-list
|
||||
toggle or Concurrency tab and rate-limit caveats.
|
||||
summary: Experimental feature that lets certain clients (Anthropic, OpenAI, Google, MistralAI, OpenRouter, Pi Bridge, llama.cpp)
|
||||
run multiple LLM requests in parallel — used by visual/image prompt generation, function-calling batches (director chat query
|
||||
actions, help agent doc lookups), multi-query world state updates, and background world state snapshots. Covers how to
|
||||
enable it via the client-list toggle or Concurrency tab and rate-limit caveats.
|
||||
- path: user-guide/clients/endpoint-override.md
|
||||
title: Endpoint Override
|
||||
summary: 'How to point a remote client at a custom API endpoint such as a LiteLLM proxy gateway: the Endpoint Override tab,
|
||||
|
||||
@@ -110,6 +110,9 @@ HTTP_ERROR_MESSAGES = {
|
||||
|
||||
EMPTY_RESPONSE_MESSAGE = "The model returned an empty response. This can happen due to content filtering, server issues, or a reasoning budget that is too low."
|
||||
|
||||
# cap (seconds) for the exponential backoff between automatic rate-limit retries
|
||||
AUTO_RETRY_MAX_BACKOFF = 30
|
||||
|
||||
|
||||
def get_error_message(status_code: int | None) -> str:
|
||||
"""Get a human-friendly error message for an HTTP status code."""
|
||||
@@ -128,6 +131,14 @@ _generation_error_futures: dict[str, asyncio.Future] = {}
|
||||
|
||||
GenerationErrorAction = Literal["retry", "cancel", "ignore"]
|
||||
|
||||
AutoRetryIssue = Literal["empty_response", "rate_limit", "missing_reasoning"]
|
||||
|
||||
AUTO_RETRY_ISSUE_LABELS: dict[AutoRetryIssue, str] = {
|
||||
"empty_response": "Empty response",
|
||||
"rate_limit": "Rate limited",
|
||||
"missing_reasoning": "Missing reasoning tokens",
|
||||
}
|
||||
|
||||
|
||||
def resolve_generation_error(request_id: str, action: GenerationErrorAction):
|
||||
"""Called from the websocket handler when the user responds to a generation error dialog."""
|
||||
@@ -149,6 +160,9 @@ def resolve_all_generation_errors(action: GenerationErrorAction):
|
||||
|
||||
class CommonDefaults(pydantic.BaseModel):
|
||||
rate_limit: int | None = None
|
||||
retry_empty_response: int = 0
|
||||
retry_rate_limit: int = 0
|
||||
retry_missing_reasoning: int = 0
|
||||
data_format: Literal["yaml", "json"] | None = None
|
||||
section_format: Literal["markdown", "xml"] | None = None
|
||||
preset_group: str | None = None
|
||||
@@ -333,6 +347,8 @@ class ClientBase:
|
||||
self.remote_model_name = None
|
||||
self.auto_determine_prompt_template_attempt = None
|
||||
self._status_failures = 0
|
||||
self._auto_retry_aborts: set[str] = set()
|
||||
self._auto_retry_live_ids: set[str] = set()
|
||||
self.log = structlog.get_logger(f"client.{self.client_type}")
|
||||
|
||||
def __str__(self):
|
||||
@@ -384,6 +400,18 @@ class ClientBase:
|
||||
def rate_limit(self) -> int | None:
|
||||
return self.client_config.rate_limit
|
||||
|
||||
@property
|
||||
def retry_empty_response(self) -> int:
|
||||
return self.client_config.retry_empty_response
|
||||
|
||||
@property
|
||||
def retry_rate_limit(self) -> int:
|
||||
return self.client_config.retry_rate_limit
|
||||
|
||||
@property
|
||||
def retry_missing_reasoning(self) -> int:
|
||||
return self.client_config.retry_missing_reasoning
|
||||
|
||||
@property
|
||||
def data_format(self) -> Literal["yaml", "json"]:
|
||||
return self.client_config.data_format
|
||||
@@ -1080,6 +1108,9 @@ class ClientBase:
|
||||
"can_be_coerced": self.can_be_coerced,
|
||||
"preset_group": self.preset_group or "",
|
||||
"rate_limit": self.rate_limit,
|
||||
"retry_empty_response": self.retry_empty_response,
|
||||
"retry_rate_limit": self.retry_rate_limit,
|
||||
"retry_missing_reasoning": self.retry_missing_reasoning,
|
||||
"data_format": self.data_format,
|
||||
"section_format": self.section_format,
|
||||
"manual_model_choices": getattr(self.Meta(), "manual_model_choices", []),
|
||||
@@ -1337,7 +1368,10 @@ class ClientBase:
|
||||
pass
|
||||
|
||||
async def _prompt_generation_error(
|
||||
self, error_message: str, status_code: int | None = None
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int | None = None,
|
||||
generation_id: str | None = None,
|
||||
) -> GenerationErrorAction:
|
||||
"""
|
||||
Emit a generation error to the frontend and wait for the user's choice.
|
||||
@@ -1358,67 +1392,273 @@ class ClientBase:
|
||||
"model": self.model_name,
|
||||
"status_code": status_code,
|
||||
"error_message": error_message,
|
||||
"generation_id": generation_id,
|
||||
},
|
||||
)
|
||||
|
||||
return await future
|
||||
action = await future
|
||||
# an explicit dialog choice supersedes an abort clicked before
|
||||
# the dialog appeared - discard the stale latch so it can't
|
||||
# override a dialog-retry later in this sequence
|
||||
if generation_id:
|
||||
self._auto_retry_aborts.discard(generation_id)
|
||||
return action
|
||||
finally:
|
||||
_generation_error_futures.pop(request_id, None)
|
||||
|
||||
def _emit_auto_retry(
|
||||
self,
|
||||
issue: AutoRetryIssue,
|
||||
attempt: int,
|
||||
total: int,
|
||||
generation_id: str,
|
||||
wait: float = 0,
|
||||
):
|
||||
"""
|
||||
Notify the frontend that an automatic retry is in progress.
|
||||
"""
|
||||
message = AUTO_RETRY_ISSUE_LABELS[issue]
|
||||
emit(
|
||||
"auto_retry",
|
||||
message=message,
|
||||
websocket_passthrough=True,
|
||||
data={
|
||||
"client": self.name,
|
||||
"issue": issue,
|
||||
"message": message,
|
||||
"attempt": attempt,
|
||||
"total": total,
|
||||
"wait": wait,
|
||||
"generation_id": generation_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_auto_retry_done(self, generation_id: str):
|
||||
emit(
|
||||
"auto_retry_done",
|
||||
message="",
|
||||
websocket_passthrough=True,
|
||||
data={"client": self.name, "generation_id": generation_id},
|
||||
)
|
||||
|
||||
def request_auto_retry_abort(self, generation_id: str):
|
||||
"""
|
||||
Latches an abort click from the auto-retry notification, keyed by the
|
||||
retry sequence it was aimed at - concurrent generations on one client
|
||||
(FOCAL concurrent callbacks, background chats) must not consume each
|
||||
other's aborts. Kept on the client because scene.cancel_requested is
|
||||
unconditionally reset by any plugin-routed websocket action
|
||||
(Plugin.handle), which would silently drop an abort that lands
|
||||
mid-attempt or mid-backoff.
|
||||
"""
|
||||
if generation_id not in self._auto_retry_live_ids:
|
||||
# the sequence already ended - a late abort must not latch an
|
||||
# id nothing will ever observe or clean up
|
||||
return
|
||||
self._auto_retry_aborts.add(generation_id)
|
||||
|
||||
def _auto_retry_cancelled(self, generation_id: str) -> bool:
|
||||
"""
|
||||
Scene-free generations (help chat, background flows) run with
|
||||
requires_active_scene unset, so _poll_interrupt never observes an
|
||||
abort click - the auto-retry machinery checks between attempts
|
||||
instead. Consumes the abort latch like Scene.continue_actions() so a
|
||||
latched abort doesn't cancel a later generation's retries.
|
||||
"""
|
||||
cancelled = False
|
||||
if generation_id in self._auto_retry_aborts:
|
||||
self._auto_retry_aborts.discard(generation_id)
|
||||
cancelled = True
|
||||
scene = active_scene.get()
|
||||
if scene and scene.cancel_requested:
|
||||
# an active scene's flag is observed non-consumingly by every
|
||||
# concurrent generation's _poll_interrupt and reset by the
|
||||
# GenerationCancelled handlers - consuming it here would steal
|
||||
# the stop from them. Only the inactive placeholder scene
|
||||
# (scene-free flows) has no other reset path.
|
||||
if not scene.active:
|
||||
scene.cancel_requested = False
|
||||
cancelled = True
|
||||
return cancelled
|
||||
|
||||
async def _auto_retry_backoff_wait(self, delay: float, generation_id: str) -> bool:
|
||||
"""
|
||||
Sleep for `delay` seconds before an automatic rate-limit retry,
|
||||
aborting early when the generation is cancelled.
|
||||
|
||||
Scene-free generations (e.g. the help chat) run with an inactive
|
||||
placeholder scene in context, so an inactive scene only counts as a
|
||||
cancellation when the scene was active at the start of the wait.
|
||||
|
||||
Returns False if aborted.
|
||||
"""
|
||||
scene = active_scene.get()
|
||||
scene_was_active = bool(scene and scene.active)
|
||||
remaining = delay
|
||||
while remaining > 0:
|
||||
if self._auto_retry_cancelled(generation_id):
|
||||
return False
|
||||
if scene and scene_was_active and not scene.active:
|
||||
return False
|
||||
await asyncio.sleep(min(1, remaining))
|
||||
remaining -= 1
|
||||
return True
|
||||
|
||||
async def _auto_retry_or_prompt(
|
||||
self,
|
||||
issue: AutoRetryIssue,
|
||||
error_message: str,
|
||||
auto_retries: dict[str, int],
|
||||
limit: int,
|
||||
generation_id: str,
|
||||
status_code: int | None = None,
|
||||
) -> GenerationErrorAction:
|
||||
"""
|
||||
Decide how to proceed after a retryable response issue: auto-retry
|
||||
while the configured budget allows (rate-limit retries back off
|
||||
exponentially), then fall through to the user's retry/cancel/ignore
|
||||
dialog.
|
||||
|
||||
Returns "retry" or "ignore". Raises GenerationCancelled when the
|
||||
generation was aborted or the user cancelled.
|
||||
"""
|
||||
if auto_retries[issue] < limit:
|
||||
if self._auto_retry_cancelled(generation_id):
|
||||
raise GenerationCancelled("Generation cancelled")
|
||||
auto_retries[issue] += 1
|
||||
wait = (
|
||||
min(2 ** auto_retries[issue], AUTO_RETRY_MAX_BACKOFF)
|
||||
if issue == "rate_limit"
|
||||
else 0
|
||||
)
|
||||
self._emit_auto_retry(
|
||||
issue, auto_retries[issue], limit, generation_id, wait=wait
|
||||
)
|
||||
if wait and not await self._auto_retry_backoff_wait(wait, generation_id):
|
||||
raise GenerationCancelled("Generation cancelled")
|
||||
return "retry"
|
||||
|
||||
action = await self._prompt_generation_error(
|
||||
error_message, status_code=status_code, generation_id=generation_id
|
||||
)
|
||||
if action == "cancel":
|
||||
raise GenerationCancelled("Generation cancelled by user")
|
||||
return action
|
||||
|
||||
async def _generate_with_error_handling(
|
||||
self, finalized_prompt: str, prompt_param: dict, kind: str
|
||||
self, finalized_prompt: str, prompt_param: dict, kind: str, generation_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Wraps _cancelable_generate in a retry loop. On API errors or empty
|
||||
responses, prompts the user with retry/cancel/ignore options.
|
||||
|
||||
Rate limit (429) errors and empty responses are automatically retried
|
||||
up to the client's configured counts before the user is prompted.
|
||||
|
||||
Returns the generation response string.
|
||||
"""
|
||||
while True:
|
||||
self.new_request()
|
||||
auto_retries = {"rate_limit": 0, "empty_response": 0}
|
||||
try:
|
||||
while True:
|
||||
self.new_request()
|
||||
|
||||
try:
|
||||
response = await self._cancelable_generate(
|
||||
finalized_prompt, prompt_param, kind
|
||||
)
|
||||
except GenerationCancelled:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.log.error("generation error", e=traceback.format_exc())
|
||||
status_code = self._extract_status_code(e)
|
||||
# exceptions may carry a user-presentable message that is more
|
||||
# accurate than the generic per-status text (e.g. clients whose
|
||||
# transport reports errors without HTTP status codes)
|
||||
error_message = getattr(e, "user_message", None) or get_error_message(
|
||||
status_code
|
||||
)
|
||||
action = await self._prompt_generation_error(
|
||||
error_message, status_code=status_code
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
elif action == "cancel":
|
||||
raise GenerationCancelled("Generation cancelled by user")
|
||||
else:
|
||||
try:
|
||||
response = await self._cancelable_generate(
|
||||
finalized_prompt, prompt_param, kind
|
||||
)
|
||||
except GenerationCancelled:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.log.error("generation error", e=traceback.format_exc())
|
||||
status_code = self._extract_status_code(e)
|
||||
# exceptions may carry a user-presentable message that is more
|
||||
# accurate than the generic per-status text (e.g. clients whose
|
||||
# transport reports errors without HTTP status codes)
|
||||
error_message = getattr(
|
||||
e, "user_message", None
|
||||
) or get_error_message(status_code)
|
||||
action = await self._auto_retry_or_prompt(
|
||||
"rate_limit",
|
||||
error_message,
|
||||
auto_retries,
|
||||
self.retry_rate_limit if status_code == 429 else 0,
|
||||
generation_id,
|
||||
status_code=status_code,
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
# ignore - proceed with empty response
|
||||
return ""
|
||||
|
||||
if isinstance(response, GenerationCancelled):
|
||||
raise response
|
||||
if isinstance(response, GenerationCancelled):
|
||||
raise response
|
||||
|
||||
# Check for empty response
|
||||
if not response or not response.strip():
|
||||
self.log.warning("empty response from generation")
|
||||
action = await self._prompt_generation_error(
|
||||
EMPTY_RESPONSE_MESSAGE, status_code=None
|
||||
if not response or not response.strip():
|
||||
self.log.warning("empty response from generation")
|
||||
action = await self._auto_retry_or_prompt(
|
||||
"empty_response",
|
||||
EMPTY_RESPONSE_MESSAGE,
|
||||
auto_retries,
|
||||
self.retry_empty_response,
|
||||
generation_id,
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
# ignore - proceed with empty response
|
||||
|
||||
return response
|
||||
finally:
|
||||
if any(auto_retries.values()):
|
||||
self._emit_auto_retry_done(generation_id)
|
||||
|
||||
async def _generate_with_reasoning_handling(
|
||||
self, finalized_prompt: str, prompt_param: dict, kind: str
|
||||
) -> str:
|
||||
"""
|
||||
Wraps _generate_with_error_handling in a retry loop for responses
|
||||
missing the expected reasoning pattern. Automatically retries up to
|
||||
the client's configured count before prompting the user with
|
||||
retry/cancel/ignore options.
|
||||
|
||||
Stores the stripped reasoning on self._reasoning_response and returns
|
||||
the generation response string.
|
||||
"""
|
||||
# identifies this retry sequence in auto_retry emissions and abort
|
||||
# requests - concurrent generations on one client each get their own
|
||||
generation_id = str(uuid.uuid4())
|
||||
self._auto_retry_live_ids.add(generation_id)
|
||||
auto_retries = {"missing_reasoning": 0}
|
||||
try:
|
||||
while True:
|
||||
response = await self._generate_with_error_handling(
|
||||
finalized_prompt, prompt_param, kind, generation_id
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
elif action == "cancel":
|
||||
raise GenerationCancelled("Generation cancelled by user")
|
||||
# else: ignore - proceed with empty response
|
||||
|
||||
return response
|
||||
try:
|
||||
response, reasoning_response = self.strip_reasoning(response)
|
||||
except ReasoningResponseError as e:
|
||||
action = await self._auto_retry_or_prompt(
|
||||
"missing_reasoning",
|
||||
str(e),
|
||||
auto_retries,
|
||||
self.retry_missing_reasoning,
|
||||
generation_id,
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
# ignore - proceed with raw response
|
||||
reasoning_response = None
|
||||
|
||||
if reasoning_response:
|
||||
self._reasoning_response = reasoning_response
|
||||
return response
|
||||
finally:
|
||||
# an abort latched but never observed (e.g. the aborted attempt
|
||||
# succeeded) must not linger once its sequence is over
|
||||
self._auto_retry_live_ids.discard(generation_id)
|
||||
self._auto_retry_aborts.discard(generation_id)
|
||||
if auto_retries["missing_reasoning"]:
|
||||
self._emit_auto_retry_done(generation_id)
|
||||
|
||||
def _extract_status_code(self, exception: Exception) -> int | None:
|
||||
"""
|
||||
@@ -1732,28 +1972,9 @@ class ClientBase:
|
||||
"\n<|RESPONSE_LENGTH_INSTRUCTIONS|>", ""
|
||||
)
|
||||
|
||||
while True:
|
||||
response = await self._generate_with_error_handling(
|
||||
finalized_prompt, prompt_param, kind
|
||||
)
|
||||
|
||||
try:
|
||||
response, reasoning_response = self.strip_reasoning(response)
|
||||
except ReasoningResponseError as e:
|
||||
action = await self._prompt_generation_error(
|
||||
str(e), status_code=None
|
||||
)
|
||||
if action == "retry":
|
||||
continue
|
||||
elif action == "cancel":
|
||||
raise GenerationCancelled("Generation cancelled by user")
|
||||
else:
|
||||
# ignore - proceed with raw response
|
||||
reasoning_response = None
|
||||
|
||||
if reasoning_response:
|
||||
self._reasoning_response = reasoning_response
|
||||
break
|
||||
response = await self._generate_with_reasoning_handling(
|
||||
finalized_prompt, prompt_param, kind
|
||||
)
|
||||
|
||||
if coercion_prompt:
|
||||
response = self.process_response_for_indirect_coercion(
|
||||
|
||||
@@ -47,6 +47,12 @@ class Client(pydantic.BaseModel):
|
||||
# max requests per minute
|
||||
rate_limit: Union[int, None] = None
|
||||
|
||||
# automatic retries before the user is notified of a response issue
|
||||
# (0 = notify immediately)
|
||||
retry_empty_response: int = 0
|
||||
retry_rate_limit: int = 0
|
||||
retry_missing_reasoning: int = 0
|
||||
|
||||
# expected data structure format in responses
|
||||
data_format: Literal["json", "yaml"] | None = None
|
||||
|
||||
@@ -128,6 +134,16 @@ class Client(pydantic.BaseModel):
|
||||
return False
|
||||
return v
|
||||
|
||||
# clamp rather than reject so an out-of-range hand-edited config.yaml
|
||||
# doesn't fail to load - an unbounded value here means an unbounded
|
||||
# automatic retry loop against a (usually paid) API
|
||||
@pydantic.field_validator(
|
||||
"retry_empty_response", "retry_rate_limit", "retry_missing_reasoning"
|
||||
)
|
||||
@classmethod
|
||||
def clamp_retry_counts(cls, v: int) -> int:
|
||||
return max(0, min(5, v))
|
||||
|
||||
# Generic choice metadata for fields that should render as <v-select> in the
|
||||
# frontend. Keyed by field name; values use {"label": ..., "value": ...} format.
|
||||
FIELD_CHOICES: ClassVar[dict[str, list[dict[str, str]]]] = {
|
||||
|
||||
@@ -21,6 +21,8 @@ ReceiveInput = signal("receive_input")
|
||||
ClientStatus = signal("client_status")
|
||||
RateLimited = signal("rate_limited")
|
||||
RateLimitReset = signal("rate_limit_reset")
|
||||
AutoRetry = signal("auto_retry")
|
||||
AutoRetryDone = signal("auto_retry_done")
|
||||
GenerationError = signal("generation_error")
|
||||
GenerationErrorResponse = signal("generation_error_response")
|
||||
RequestClientStatus = signal("request_client_status")
|
||||
@@ -79,6 +81,8 @@ handlers = {
|
||||
"client_status": ClientStatus,
|
||||
"rate_limited": RateLimited,
|
||||
"rate_limit_reset": RateLimitReset,
|
||||
"auto_retry": AutoRetry,
|
||||
"auto_retry_done": AutoRetryDone,
|
||||
"generation_error": GenerationError,
|
||||
"generation_error_response": GenerationErrorResponse,
|
||||
"request_client_status": RequestClientStatus,
|
||||
|
||||
@@ -240,6 +240,21 @@ async def websocket_endpoint(websocket):
|
||||
if handler.scene.loading:
|
||||
scene_task.cancel()
|
||||
scene_task = None
|
||||
elif action_type == "auto_retry_abort":
|
||||
client_name = data.get("client")
|
||||
generation_id = data.get("generation_id")
|
||||
log.info(
|
||||
"auto_retry_abort",
|
||||
client=client_name,
|
||||
generation_id=generation_id,
|
||||
)
|
||||
if generation_id:
|
||||
try:
|
||||
instance.get_client(client_name).request_auto_retry_abort(
|
||||
generation_id
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
elif action_type == "request_app_config":
|
||||
log.info("request_app_config")
|
||||
|
||||
|
||||
@@ -289,6 +289,9 @@ const SAVE_ECHO_FIELDS = [
|
||||
'vision_enabled',
|
||||
'concurrent_inference_enabled',
|
||||
'rate_limit',
|
||||
'retry_empty_response',
|
||||
'retry_rate_limit',
|
||||
'retry_missing_reasoning',
|
||||
'data_format',
|
||||
'section_format',
|
||||
'double_coercion',
|
||||
@@ -652,6 +655,9 @@ export default {
|
||||
client.double_coercion = data.data.double_coercion;
|
||||
client.manual_model_choices = data.data.manual_model_choices;
|
||||
client.rate_limit = data.data.rate_limit;
|
||||
client.retry_empty_response = data.data.retry_empty_response;
|
||||
client.retry_rate_limit = data.data.retry_rate_limit;
|
||||
client.retry_missing_reasoning = data.data.retry_missing_reasoning;
|
||||
client.data_format = data.data.data_format;
|
||||
client.section_format = data.data.section_format;
|
||||
client.data = data.data;
|
||||
@@ -689,6 +695,9 @@ export default {
|
||||
double_coercion: data.data.double_coercion,
|
||||
manual_model_choices: data.data.manual_model_choices,
|
||||
rate_limit: data.data.rate_limit,
|
||||
retry_empty_response: data.data.retry_empty_response,
|
||||
retry_rate_limit: data.data.retry_rate_limit,
|
||||
retry_missing_reasoning: data.data.retry_missing_reasoning,
|
||||
data_format: data.data.data_format,
|
||||
section_format: data.data.section_format,
|
||||
data: data.data,
|
||||
|
||||
129
talemate_frontend/src/components/AutoRetryAlert.vue
Normal file
129
talemate_frontend/src/components/AutoRetryAlert.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<v-snackbar v-model="active" location="top" :timeout="-1" color="mutedbg" class="auto-retry-alert">
|
||||
<v-progress-circular indeterminate size="16" width="2" color="primary" class="mr-2"></v-progress-circular>
|
||||
<span class="text-primary">{{ client }}</span>: {{ message }} — retrying
|
||||
<span v-if="wait > 0">in {{ wait }}s </span>({{ attempt }}/{{ total }})
|
||||
<template v-slot:actions>
|
||||
<v-btn :disabled="aborting || closing" color="delete" variant="text" prepend-icon="mdi-cancel" @click="abort">Abort</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// fast retry sequences (an instant retry that immediately succeeds) can open
|
||||
// and close within the snackbar's own transition - hold it on screen long
|
||||
// enough to be readable
|
||||
const MIN_VISIBLE_MS = 2500;
|
||||
|
||||
export default {
|
||||
name: 'AutoRetryAlert',
|
||||
data() {
|
||||
return {
|
||||
active: false,
|
||||
client: null,
|
||||
generationId: null,
|
||||
message: '',
|
||||
attempt: 0,
|
||||
total: 0,
|
||||
wait: 0,
|
||||
aborting: false,
|
||||
closing: false,
|
||||
openedAt: 0,
|
||||
closeTimer: null,
|
||||
countdownTimer: null,
|
||||
}
|
||||
},
|
||||
inject: ['getWebsocket'],
|
||||
methods: {
|
||||
open(data) {
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer)
|
||||
this.closeTimer = null
|
||||
}
|
||||
// a handoff to another retry sequence's content starts a fresh
|
||||
// readability window
|
||||
if (!this.active || data.generation_id !== this.generationId) {
|
||||
this.openedAt = Date.now()
|
||||
}
|
||||
this.client = data.client
|
||||
this.generationId = data.generation_id
|
||||
this.message = data.message
|
||||
this.attempt = data.attempt
|
||||
this.total = data.total
|
||||
this.wait = data.wait || 0
|
||||
this.aborting = false
|
||||
this.closing = false
|
||||
this.active = true
|
||||
this.startCountdown()
|
||||
},
|
||||
startCountdown() {
|
||||
this.stopCountdown()
|
||||
if (this.wait <= 0) {
|
||||
return
|
||||
}
|
||||
this.countdownTimer = setInterval(() => {
|
||||
if (this.wait > 1) {
|
||||
this.wait -= 1
|
||||
} else {
|
||||
this.wait = 0
|
||||
this.stopCountdown()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
stopCountdown() {
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer)
|
||||
this.countdownTimer = null
|
||||
}
|
||||
},
|
||||
close(generationId = null, immediate = false) {
|
||||
// with concurrent generations, one sequence finishing must not
|
||||
// hide another sequence's live retry state
|
||||
if (generationId && this.generationId && generationId !== this.generationId) {
|
||||
return
|
||||
}
|
||||
this.aborting = false
|
||||
// the sequence is over - an abort during the readability linger
|
||||
// would have nothing to act on
|
||||
this.closing = true
|
||||
this.stopCountdown()
|
||||
// keep the content fields - clearing them would blank the text
|
||||
// while the snackbar is still fading out
|
||||
const remaining = this.openedAt + MIN_VISIBLE_MS - Date.now()
|
||||
if (immediate || remaining <= 0) {
|
||||
this.active = false
|
||||
return
|
||||
}
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer)
|
||||
}
|
||||
this.closeTimer = setTimeout(() => {
|
||||
this.active = false
|
||||
this.closeTimer = null
|
||||
}, remaining)
|
||||
},
|
||||
abort() {
|
||||
this.aborting = true
|
||||
// the interrupt cancels any in-flight generation; the dedicated
|
||||
// abort message latches on the displayed retry sequence so the
|
||||
// abort survives even when other websocket actions reset the
|
||||
// scene's cancel flag
|
||||
this.getWebsocket().send(JSON.stringify({ type: 'auto_retry_abort', client: this.client, generation_id: this.generationId }));
|
||||
this.getWebsocket().send(JSON.stringify({ type: 'interrupt' }));
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopCountdown()
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* sit below the top-center status snackbar so the two never overlap */
|
||||
.auto-retry-alert :deep(.v-snackbar__wrapper) {
|
||||
margin-top: 64px;
|
||||
}
|
||||
</style>
|
||||
@@ -243,7 +243,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="client.reason_enabled && client.requires_reasoning_pattern">
|
||||
<v-col cols="12">
|
||||
<v-col :cols="client.reason_failure_behavior === 'fail' ? 6 : 12">
|
||||
<v-select
|
||||
v-model="client.reason_failure_behavior"
|
||||
label="Pattern Not Found Behavior"
|
||||
@@ -255,6 +255,9 @@
|
||||
persistent-hint
|
||||
></v-select>
|
||||
</v-col>
|
||||
<v-col cols="6" v-if="client.reason_failure_behavior === 'fail'">
|
||||
<v-slider v-model="client.retry_missing_reasoning" label="Auto Retry" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the reasoning pattern is missing, before you are notified. (0 = notify immediately)" thumb-label="always"></v-slider>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="client.reason_enabled && client.requires_reasoning_pattern">
|
||||
<v-col cols="12">
|
||||
@@ -331,6 +334,18 @@
|
||||
<v-slider v-model="client.rate_limit" label="Rate Limit" :min="0" :max="100" :step="1" :persistent-hint="true" hint="Requests per minute. (0 = no limit)" thumb-label="always"></v-slider>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<!-- AUTO RETRY -->
|
||||
<v-alert icon="mdi-refresh" density="compact" color="grey-darken-1" variant="text">
|
||||
Automatic retries on response issues before you are notified. (0 = notify immediately)
|
||||
</v-alert>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<v-slider v-model="client.retry_empty_response" label="Empty Response" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the model returns an empty response." thumb-label="always"></v-slider>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-slider v-model="client.retry_rate_limit" label="Rate Limited" :min="0" :max="5" :step="1" :persistent-hint="true" hint="Automatic retries when the API reports it is rate limited (HTTP 429), waiting progressively longer between attempts." thumb-label="always"></v-slider>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
<!-- SYSTEM PROMPTS -->
|
||||
<v-window-item value="system_prompts">
|
||||
@@ -635,6 +650,9 @@ export default {
|
||||
this.client.max_token_length = defaults.max_token_length || 8192;
|
||||
this.client.double_coercion = defaults.double_coercion || null;
|
||||
this.client.rate_limit = defaults.rate_limit || null;
|
||||
this.client.retry_empty_response = defaults.retry_empty_response || 0;
|
||||
this.client.retry_rate_limit = defaults.retry_rate_limit || 0;
|
||||
this.client.retry_missing_reasoning = defaults.retry_missing_reasoning || 0;
|
||||
this.client.data_format = defaults.data_format || null;
|
||||
this.client.section_format = defaults.section_format || null;
|
||||
this.client.preset_group = defaults.preset_group || '';
|
||||
|
||||
@@ -393,6 +393,7 @@
|
||||
</v-app>
|
||||
<StatusNotification />
|
||||
<RateLimitAlert ref="rateLimitAlert" />
|
||||
<AutoRetryAlert ref="autoRetryAlert" />
|
||||
<GenerationErrorDialog ref="generationErrorDialog" />
|
||||
<SceneTimeline
|
||||
ref="sceneTimeline"
|
||||
@@ -425,6 +426,7 @@ import DebugTools from './DebugTools.vue';
|
||||
import AudioQueue from './AudioQueue.vue';
|
||||
import StatusNotification from './StatusNotification.vue';
|
||||
import RateLimitAlert from './RateLimitAlert.vue';
|
||||
import AutoRetryAlert from './AutoRetryAlert.vue';
|
||||
import GenerationErrorDialog from './GenerationErrorDialog.vue';
|
||||
import SceneTimeline from './SceneTimeline.vue';
|
||||
import VersionMismatchAlert from './VersionMismatchAlert.vue';
|
||||
@@ -474,6 +476,7 @@ export default {
|
||||
NodeEditor,
|
||||
DirectorConsole,
|
||||
RateLimitAlert,
|
||||
AutoRetryAlert,
|
||||
GenerationErrorDialog,
|
||||
SceneTimeline,
|
||||
VersionMismatchAlert,
|
||||
@@ -1116,7 +1119,19 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if(data.type === 'auto_retry') {
|
||||
this.$refs.autoRetryAlert.open(data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if(data.type === 'auto_retry_done') {
|
||||
this.$refs.autoRetryAlert.close(data.data.generation_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if(data.type === 'generation_error') {
|
||||
// retries exhausted - the dialog supersedes the auto-retry snackbar
|
||||
this.$refs.autoRetryAlert.close(data.data.generation_id, true);
|
||||
this.$refs.generationErrorDialog.open(data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
531
tests/test_client_auto_retry.py
Normal file
531
tests/test_client_auto_retry.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""Tests for issue #102 — per-client automatic retries on response issues.
|
||||
|
||||
Each client can be configured to automatically retry N times (0-5, default 0)
|
||||
per response issue — empty response, rate limit (429) and missing reasoning
|
||||
tokens — before the user is notified via the generation error dialog. 0 keeps
|
||||
the previous behavior of prompting the user immediately.
|
||||
|
||||
These drive the REAL ClientBase.send_prompt machinery with a scripted
|
||||
`generate`, with `_prompt_generation_error` mocked to observe (or rule out)
|
||||
dialog fall-through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import talemate.config.state as config_state
|
||||
from talemate.agents.context import ActiveAgent
|
||||
from talemate.client.base import (
|
||||
ClientBase,
|
||||
_generation_error_futures,
|
||||
resolve_generation_error,
|
||||
)
|
||||
from talemate.client.context import ClientContext
|
||||
from talemate.config.schema import Client as ClientConfig
|
||||
from talemate.context import ActiveScene
|
||||
from talemate.emit.signals import handlers as emit_handlers
|
||||
from talemate.exceptions import GenerationCancelled
|
||||
|
||||
from conftest import MockScene, bootstrap_scene
|
||||
|
||||
|
||||
class Scripted429(RuntimeError):
|
||||
status_code = 429
|
||||
|
||||
|
||||
class Scripted500(RuntimeError):
|
||||
status_code = 500
|
||||
|
||||
|
||||
class ScriptedClient(ClientBase):
|
||||
"""Real ClientBase; `generate` plays back a script of results.
|
||||
|
||||
Script entries are a string (the response), an exception instance to
|
||||
raise, or a callable invoked mid-attempt (returning the response) -
|
||||
the latter for side effects like an abort click landing while the
|
||||
attempt is in flight.
|
||||
"""
|
||||
|
||||
client_type = "stub"
|
||||
|
||||
@property
|
||||
def supported_parameters(self):
|
||||
return ["temperature", "max_tokens"]
|
||||
|
||||
def __init__(self, script: list, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.script = list(script)
|
||||
self.calls = 0
|
||||
|
||||
async def generate(self, prompt, parameters, kind):
|
||||
self.calls += 1
|
||||
await asyncio.sleep(0)
|
||||
result = self.script.pop(0)
|
||||
if callable(result):
|
||||
result = result()
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scripted_env():
|
||||
"""Yields a factory that registers a ScriptedClient with the given
|
||||
per-issue retry config and collects auto_retry emissions."""
|
||||
saved_clients = dict(config_state.CONFIG.clients)
|
||||
emissions = []
|
||||
|
||||
def on_auto_retry(emission):
|
||||
emissions.append(emission)
|
||||
|
||||
emit_handlers["auto_retry"].connect(on_auto_retry)
|
||||
emit_handlers["auto_retry_done"].connect(on_auto_retry)
|
||||
|
||||
def make_client(script: list, **config_kwargs) -> ScriptedClient:
|
||||
config_state.CONFIG.clients["scripted"] = ClientConfig(
|
||||
type="stub", name="scripted", **config_kwargs
|
||||
)
|
||||
return ScriptedClient(script, name="scripted")
|
||||
|
||||
scene = MockScene()
|
||||
agents = bootstrap_scene(scene)
|
||||
scene.active = True
|
||||
|
||||
def _agent_fn():
|
||||
pass
|
||||
|
||||
with ActiveScene(scene), ActiveAgent(agents["summarizer"], _agent_fn):
|
||||
yield make_client, emissions, scene
|
||||
|
||||
emit_handlers["auto_retry"].disconnect(on_auto_retry)
|
||||
emit_handlers["auto_retry_done"].disconnect(on_auto_retry)
|
||||
config_state.CONFIG.clients.clear()
|
||||
config_state.CONFIG.clients.update(saved_clients)
|
||||
|
||||
|
||||
def auto_retry_events(emissions, typ="auto_retry"):
|
||||
return [e for e in emissions if e.typ == typ]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_auto_retry_then_success(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(["", "", "ok"], retry_empty_response=2)
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok"
|
||||
assert client.calls == 3
|
||||
assert dialog.await_count == 0
|
||||
|
||||
retries = auto_retry_events(emissions)
|
||||
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 2), (2, 2)]
|
||||
assert all(e.data["issue"] == "empty_response" for e in retries)
|
||||
assert all(e.data["client"] == "scripted" for e in retries)
|
||||
# snackbar closed after the retry sequence resolved
|
||||
assert len(auto_retry_events(emissions, "auto_retry_done")) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_retries_exhausted_prompts_user(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(["", "", ""], retry_empty_response=2)
|
||||
|
||||
dialog = AsyncMock(return_value="ignore")
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == ""
|
||||
assert client.calls == 3
|
||||
assert dialog.await_count == 1
|
||||
assert len(auto_retry_events(emissions)) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_zero_prompts_user_immediately(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client([""])
|
||||
|
||||
dialog = AsyncMock(return_value="ignore")
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == ""
|
||||
assert client.calls == 1
|
||||
assert dialog.await_count == 1
|
||||
assert not auto_retry_events(emissions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_auto_retry_with_backoff(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client([Scripted429(), Scripted429(), "ok"], retry_rate_limit=3)
|
||||
|
||||
dialog = AsyncMock()
|
||||
backoff = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch.object(client, "_prompt_generation_error", dialog),
|
||||
patch.object(client, "_auto_retry_backoff_wait", backoff),
|
||||
):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok"
|
||||
assert client.calls == 3
|
||||
assert dialog.await_count == 0
|
||||
# exponential backoff: 2s then 4s
|
||||
assert [call.args[0] for call in backoff.await_args_list] == [2, 4]
|
||||
|
||||
retries = auto_retry_events(emissions)
|
||||
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 3), (2, 3)]
|
||||
assert all(e.data["issue"] == "rate_limit" for e in retries)
|
||||
assert [e.data["wait"] for e in retries] == [2, 4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_backoff_caps_at_max(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(
|
||||
[Scripted429()] * 5 + ["ok"],
|
||||
retry_rate_limit=5,
|
||||
)
|
||||
|
||||
backoff = AsyncMock(return_value=True)
|
||||
with patch.object(client, "_auto_retry_backoff_wait", backoff):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok"
|
||||
assert [call.args[0] for call in backoff.await_args_list] == [2, 4, 8, 16, 30]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_cancelled_during_backoff(scripted_env):
|
||||
make_client, _, _ = scripted_env
|
||||
client = make_client([Scripted429(), "ok"], retry_rate_limit=1)
|
||||
|
||||
backoff = AsyncMock(return_value=False)
|
||||
with patch.object(client, "_auto_retry_backoff_wait", backoff):
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_429_error_is_not_auto_retried(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client([Scripted500()], retry_rate_limit=5)
|
||||
|
||||
dialog = AsyncMock(return_value="ignore")
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == ""
|
||||
assert client.calls == 1
|
||||
assert dialog.await_count == 1
|
||||
assert not auto_retry_events(emissions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_reasoning_auto_retry_then_success(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(
|
||||
["no reasoning here", "<think>hmm</think>the answer"],
|
||||
retry_missing_reasoning=1,
|
||||
reason_enabled=True,
|
||||
reason_response_pattern=r"<think>.*?</think>",
|
||||
)
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "the answer"
|
||||
assert client.calls == 2
|
||||
assert dialog.await_count == 0
|
||||
|
||||
retries = auto_retry_events(emissions)
|
||||
assert [(e.data["attempt"], e.data["total"]) for e in retries] == [(1, 1)]
|
||||
assert retries[0].data["issue"] == "missing_reasoning"
|
||||
assert len(auto_retry_events(emissions, "auto_retry_done")) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_reasoning_retries_exhausted_prompts_user(scripted_env):
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(
|
||||
["no reasoning", "still no reasoning"],
|
||||
retry_missing_reasoning=1,
|
||||
reason_enabled=True,
|
||||
reason_response_pattern=r"<think>.*?</think>",
|
||||
)
|
||||
|
||||
dialog = AsyncMock(return_value="ignore")
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "still no reasoning"
|
||||
assert client.calls == 2
|
||||
assert dialog.await_count == 1
|
||||
assert len(auto_retry_events(emissions)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_between_immediate_retries_scene_free(scripted_env):
|
||||
"""An abort click during scene-free auto-retries (inactive placeholder
|
||||
scene, _poll_interrupt blind) must cancel between attempts and consume
|
||||
the flag so later generations retry normally."""
|
||||
make_client, _, scene = scripted_env
|
||||
client = make_client(["", "", "ok"], retry_empty_response=2)
|
||||
|
||||
scene.active = False
|
||||
scene.cancel_requested = True
|
||||
dialog = AsyncMock()
|
||||
with (
|
||||
ClientContext(requires_active_scene=False),
|
||||
patch.object(client, "_prompt_generation_error", dialog),
|
||||
):
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert client.calls == 1
|
||||
assert dialog.await_count == 0
|
||||
assert scene.cancel_requested is False
|
||||
|
||||
# flag was consumed - the next generation's retries proceed normally
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
assert response == "ok"
|
||||
scene.active = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_between_reasoning_retries_scene_free(scripted_env):
|
||||
make_client, _, scene = scripted_env
|
||||
client = make_client(
|
||||
["no reasoning here"],
|
||||
retry_missing_reasoning=1,
|
||||
reason_enabled=True,
|
||||
reason_response_pattern=r"<think>.*?</think>",
|
||||
)
|
||||
|
||||
scene.active = False
|
||||
scene.cancel_requested = True
|
||||
dialog = AsyncMock()
|
||||
with (
|
||||
ClientContext(requires_active_scene=False),
|
||||
patch.object(client, "_prompt_generation_error", dialog),
|
||||
):
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert client.calls == 1
|
||||
assert dialog.await_count == 0
|
||||
assert scene.cancel_requested is False
|
||||
scene.active = True
|
||||
|
||||
|
||||
def latest_generation_id(emissions):
|
||||
return auto_retry_events(emissions)[-1].data["generation_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_latch_survives_scene_flag_reset(scripted_env):
|
||||
"""Plugin.handle() unconditionally resets scene.cancel_requested on any
|
||||
plugin-routed websocket action - the client-owned abort latch must
|
||||
survive that wipe and still cancel between retries. The abort lands
|
||||
mid-attempt keyed to the sequence's generation id, as it does in
|
||||
reality (the Abort button only exists once a retry has been shown)."""
|
||||
make_client, emissions, scene = scripted_env
|
||||
client = make_client([], retry_empty_response=2)
|
||||
|
||||
def abort_mid_attempt():
|
||||
client.request_auto_retry_abort(latest_generation_id(emissions))
|
||||
scene.cancel_requested = False # simulates the Plugin.handle wipe
|
||||
return ""
|
||||
|
||||
client.script.extend(["", abort_mid_attempt, "ok"])
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert client.calls == 2
|
||||
assert dialog.await_count == 0
|
||||
assert client._auto_retry_aborts == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_abort_scoped_to_its_sequence(scripted_env):
|
||||
"""An abort keyed to a different sequence's generation id must not
|
||||
cancel this sequence's retries (concurrent generations on one client)."""
|
||||
make_client, _, _ = scripted_env
|
||||
client = make_client([], retry_empty_response=1)
|
||||
|
||||
def foreign_abort():
|
||||
# the concurrent sequence is live on the same client
|
||||
client._auto_retry_live_ids.add("another-sequence")
|
||||
client.request_auto_retry_abort("another-sequence")
|
||||
return ""
|
||||
|
||||
client.script.extend([foreign_abort, "ok"])
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok"
|
||||
assert client.calls == 2
|
||||
assert dialog.await_count == 0
|
||||
# the foreign abort was neither consumed nor cleaned up by this sequence
|
||||
assert client._auto_retry_aborts == {"another-sequence"}
|
||||
client._auto_retry_aborts.clear()
|
||||
client._auto_retry_live_ids.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_abort_for_finished_sequence_is_ignored(scripted_env):
|
||||
"""An abort processed after its sequence's cleanup must not latch an
|
||||
orphan id that nothing will ever observe or clean up."""
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client(["", "ok"], retry_empty_response=1)
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok"
|
||||
client.request_auto_retry_abort(latest_generation_id(emissions))
|
||||
assert client._auto_retry_aborts == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scene_interrupt_not_consumed_on_active_scene(scripted_env):
|
||||
"""An active scene's cancel_requested is observed non-consumingly by
|
||||
every concurrent generation's _poll_interrupt and reset by the
|
||||
GenerationCancelled handlers - the retry check must not steal it. Only
|
||||
the inactive placeholder (scene-free flows, no other reset path) is
|
||||
consumed."""
|
||||
make_client, _, scene = scripted_env
|
||||
client = make_client([])
|
||||
|
||||
scene.cancel_requested = True
|
||||
assert client._auto_retry_cancelled("gid") is True
|
||||
assert scene.cancel_requested is True
|
||||
|
||||
scene.active = False
|
||||
assert client._auto_retry_cancelled("gid") is True
|
||||
assert scene.cancel_requested is False
|
||||
scene.active = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unobserved_abort_latch_does_not_cancel_later_generation(scripted_env):
|
||||
"""An abort latched during an attempt that then succeeds is never
|
||||
observed by a retry check - it must not leak into a later generation's
|
||||
retry sequence."""
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client([], retry_empty_response=1)
|
||||
|
||||
def abort_but_succeed():
|
||||
client.request_auto_retry_abort(latest_generation_id(emissions))
|
||||
return "ok"
|
||||
|
||||
client.script.extend(["", abort_but_succeed, "", "ok2"])
|
||||
|
||||
dialog = AsyncMock()
|
||||
with patch.object(client, "_prompt_generation_error", dialog):
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
assert response == "ok"
|
||||
# the unobserved abort was cleaned up when its sequence ended
|
||||
assert client._auto_retry_aborts == set()
|
||||
|
||||
response = await client.send_prompt("hello", kind="analyze_freeform")
|
||||
|
||||
assert response == "ok2"
|
||||
assert client.calls == 4
|
||||
assert dialog.await_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_retry_supersedes_stale_abort(scripted_env):
|
||||
"""An abort latched mid-attempt goes unobserved when the attempt fails
|
||||
with a non-429 (dialog path). The user's explicit dialog retry is newer
|
||||
intent - it must discard the stale latch, not be cancelled by it."""
|
||||
make_client, emissions, _ = scripted_env
|
||||
client = make_client([], retry_empty_response=2)
|
||||
|
||||
def abort_then_fail():
|
||||
client.request_auto_retry_abort(latest_generation_id(emissions))
|
||||
return Scripted500()
|
||||
|
||||
client.script.extend(["", abort_then_fail, "", "ok"])
|
||||
|
||||
task = asyncio.create_task(client.send_prompt("hello", kind="analyze_freeform"))
|
||||
for _ in range(200):
|
||||
if _generation_error_futures:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert _generation_error_futures
|
||||
resolve_generation_error(next(iter(_generation_error_futures)), "retry")
|
||||
|
||||
response = await asyncio.wait_for(task, timeout=10)
|
||||
|
||||
# dialog retry ran attempt 3 (empty), whose auto-retry proceeded to
|
||||
# attempt 4 instead of consuming the stale abort
|
||||
assert response == "ok"
|
||||
assert client.calls == 4
|
||||
assert client._auto_retry_aborts == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backoff_wait_aborts_on_scene_cancel(scripted_env):
|
||||
make_client, _, scene = scripted_env
|
||||
client = make_client([])
|
||||
|
||||
scene.cancel_requested = True
|
||||
assert await client._auto_retry_backoff_wait(10, "gid") is False
|
||||
|
||||
scene.cancel_requested = False
|
||||
assert await client._auto_retry_backoff_wait(0.1, "gid") is True
|
||||
|
||||
# scene-free generations (help chat) carry an inactive placeholder scene
|
||||
# in context - that alone must not abort the wait
|
||||
scene.active = False
|
||||
assert await client._auto_retry_backoff_wait(0.1, "gid") is True
|
||||
scene.active = True
|
||||
|
||||
|
||||
def test_config_clamps_retry_counts():
|
||||
config = ClientConfig(
|
||||
type="stub",
|
||||
name="clamped",
|
||||
retry_empty_response=9999,
|
||||
retry_rate_limit=-3,
|
||||
retry_missing_reasoning=5,
|
||||
)
|
||||
assert config.retry_empty_response == 5
|
||||
assert config.retry_rate_limit == 0
|
||||
assert config.retry_missing_reasoning == 5
|
||||
|
||||
|
||||
def test_config_round_trip():
|
||||
config = ClientConfig(
|
||||
type="stub",
|
||||
name="roundtrip",
|
||||
retry_empty_response=3,
|
||||
retry_rate_limit=2,
|
||||
retry_missing_reasoning=1,
|
||||
)
|
||||
restored = ClientConfig(**config.model_dump())
|
||||
assert restored.retry_empty_response == 3
|
||||
assert restored.retry_rate_limit == 2
|
||||
assert restored.retry_missing_reasoning == 1
|
||||
|
||||
defaults = ClientConfig(type="stub", name="defaults")
|
||||
assert defaults.retry_empty_response == 0
|
||||
assert defaults.retry_rate_limit == 0
|
||||
assert defaults.retry_missing_reasoning == 0
|
||||
@@ -1365,7 +1365,9 @@ class TestGenerateWithErrorHandling:
|
||||
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
out = await client._generate_with_error_handling("p", {}, "conversation")
|
||||
out = await client._generate_with_error_handling(
|
||||
"p", {}, "conversation", "gid"
|
||||
)
|
||||
assert out == "great"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1386,14 +1388,18 @@ class TestGenerateWithErrorHandling:
|
||||
# Patch the user-prompt helper to return "retry" then "ignore" if needed.
|
||||
responses = iter(["retry"])
|
||||
|
||||
async def fake_prompt(self, error_message, status_code=None):
|
||||
async def fake_prompt(
|
||||
self, error_message, status_code=None, generation_id=None
|
||||
):
|
||||
return next(responses)
|
||||
|
||||
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
|
||||
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
out = await client._generate_with_error_handling("p", {}, "conversation")
|
||||
out = await client._generate_with_error_handling(
|
||||
"p", {}, "conversation", "gid"
|
||||
)
|
||||
assert out == "second-time"
|
||||
assert attempts["n"] == 2
|
||||
|
||||
@@ -1409,14 +1415,18 @@ class TestGenerateWithErrorHandling:
|
||||
|
||||
client = _AlwaysFails(name="gc3")
|
||||
|
||||
async def fake_prompt(self, error_message, status_code=None):
|
||||
async def fake_prompt(
|
||||
self, error_message, status_code=None, generation_id=None
|
||||
):
|
||||
return "ignore"
|
||||
|
||||
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
|
||||
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
out = await client._generate_with_error_handling("p", {}, "conversation")
|
||||
out = await client._generate_with_error_handling(
|
||||
"p", {}, "conversation", "gid"
|
||||
)
|
||||
assert out == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1433,7 +1443,9 @@ class TestGenerateWithErrorHandling:
|
||||
|
||||
client = _EmptyThenGood(name="gc4")
|
||||
|
||||
async def fake_prompt(self, error_message, status_code=None):
|
||||
async def fake_prompt(
|
||||
self, error_message, status_code=None, generation_id=None
|
||||
):
|
||||
assert error_message == EMPTY_RESPONSE_MESSAGE
|
||||
return "retry"
|
||||
|
||||
@@ -1441,7 +1453,9 @@ class TestGenerateWithErrorHandling:
|
||||
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
out = await client._generate_with_error_handling("p", {}, "conversation")
|
||||
out = await client._generate_with_error_handling(
|
||||
"p", {}, "conversation", "gid"
|
||||
)
|
||||
assert out == "good"
|
||||
assert attempts["n"] == 2
|
||||
|
||||
@@ -1457,7 +1471,9 @@ class TestGenerateWithErrorHandling:
|
||||
|
||||
client = _Fails(name="gc5")
|
||||
|
||||
async def fake_prompt(self, error_message, status_code=None):
|
||||
async def fake_prompt(
|
||||
self, error_message, status_code=None, generation_id=None
|
||||
):
|
||||
return "cancel"
|
||||
|
||||
monkeypatch.setattr(ClientBase, "_prompt_generation_error", fake_prompt)
|
||||
@@ -1465,7 +1481,9 @@ class TestGenerateWithErrorHandling:
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client._generate_with_error_handling("p", {}, "conversation")
|
||||
await client._generate_with_error_handling(
|
||||
"p", {}, "conversation", "gid"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -213,7 +213,7 @@ async def test_generation_error_dialog_shows_pi_message(client, spawner, monkeyp
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_dialog(message, status_code=None):
|
||||
async def fake_dialog(message, status_code=None, generation_id=None):
|
||||
captured["message"] = message
|
||||
captured["status_code"] = status_code
|
||||
return "cancel"
|
||||
@@ -223,7 +223,7 @@ async def test_generation_error_dialog_shows_pi_message(client, spawner, monkeyp
|
||||
with ClientContext(requires_active_scene=False):
|
||||
set_client_context_attribute("requires_active_scene", False)
|
||||
with pytest.raises(GenerationCancelled):
|
||||
await client._generate_with_error_handling("hi", {}, "conversation")
|
||||
await client._generate_with_error_handling("hi", {}, "conversation", "gid")
|
||||
|
||||
assert "No API key found for openrouter" in captured["message"]
|
||||
assert captured["status_code"] is None
|
||||
|
||||
Reference in New Issue
Block a user