File size: 17,701 Bytes
cdef1a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83add3c
 
cdef1a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83add3c
 
cdef1a0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
"""Participant-turn resilience helpers.

This module owns the logic for what happens after an AI participant's
LLM call fails. The orchestrator delegates each AI turn to
`run_resilient_turn`, which then decides whether to:

  - return the original failure unchanged (when "Prioritize model
    choice" is on; the user explicitly picked this model, so we don't
    swap it out behind their back — they get the existing 2-attempt
    HTTP retry from openai_compat and that's it),
  - in Phase 1 only, retire the original participant and bring in an
    alternate from the catalog,
  - swap the backing LLM behind the participant's persona prompt by
    walking the per-session substitution chain (gpt-5.4 →
    gemini-2.5-flash → other externals → Neon "vanilla" models), or
  - retry the same backing LLM once when the error looks transient
    (5xx / 429 / timeout) in Phase 2+.

All of the post-failure behaviour is gated on
`settings.speed_priority`. The reasoning: under "Prioritize model
choice" the user has explicitly chosen a model and expects exactly
that model to speak (or the conversation to note the failure and
continue without lying about which model produced what). Under
"Prioritize conversation speed" the user has signalled they care
about the conversation flowing more than about which specific model
spoke — so we aggressively work around failures.

The orchestrator's existing `participant_error` SSE + auto-disable
threshold still apply as a final backstop: if every tier in this
module has been exhausted and the participant *still* can't speak,
the orchestrator emits `participant_error` and (in Phases 1/2)
auto-disables the participant after N consecutive failures.
"""
from __future__ import annotations

import json
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Awaitable, Callable

from app.config import settings

if TYPE_CHECKING:
    # Avoid an import cycle: orchestrator imports this module, and
    # this module only needs the types for annotations.
    from app.services.models import Participant, Phase, Session

LOG = logging.getLogger(__name__)


# Substitution chain: head-of-list models we always try first because
# they're known to be widely-available and have generous context
# windows. The remainder of the chain is filled in dynamically from
# settings.providers at session-start.
PREFERRED_SUBSTITUTE_MODEL_IDS: list[str] = [
    "gpt-5.4",
    "gemini-2.5-flash",
]


# ---------------------------------------------------------------------------
# Result type returned to the orchestrator
# ---------------------------------------------------------------------------

@dataclass
class ResilientTurnResult:
    """Outcome of `run_resilient_turn` from the orchestrator's POV.

    `speaker` is the Participant whose name + role_prompt produced the
    response. Normally it's the same instance the orchestrator passed
    in — but in Phase 1 we may swap an alternate participant in,
    in which case `speaker` is the alternate. The orchestrator should
    attribute the resulting message to `speaker`, not the original.

    `sse_events` is a list of pre-encoded SSE chunks the caller should
    yield *before* emitting the actual `message` event (roster
    updates, substitution notices, etc.). They're returned rather than
    yielded directly so the resilience layer doesn't have to be an
    async generator — keeps the caller simple.
    """
    speaker: "Participant"
    text: str
    elapsed: float
    ok: bool
    error_kind: str = ""
    sse_events: list[str] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Substitution chain construction (called from api/chat.py at session start)
# ---------------------------------------------------------------------------

def build_substitution_chain(
    neon_model_ids: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Resolve each model id we'd want to fall back to into a routing
    dict the same shape as `settings.resolve_model()` returns.

    Order:
      1. PREFERRED_SUBSTITUTE_MODEL_IDS (gpt-5.4, gemini-2.5-flash)
      2. Every other non-Neon model registered in settings.providers,
         in provider order (deterministic).
      3. Neon "vanilla" models (no persona overlay) as a last resort —
         the caller supplies HANA model ids (e.g. "BrainForge/Engineer@2026.03.18")
         which we expand into `neon:{id}:vanilla` participant ids.

    Duplicates are dropped, so each model id appears at most once.
    Models that fail to resolve (e.g. the API key isn't configured)
    are silently skipped.
    """
    chain: list[dict[str, Any]] = []
    seen: set[str] = set()

    def add(model_id: str) -> None:
        if not model_id or model_id in seen:
            return
        resolved = settings.resolve_model(model_id)
        if not resolved:
            return
        seen.add(model_id)
        chain.append(resolved)

    for mid in PREFERRED_SUBSTITUTE_MODEL_IDS:
        add(mid)

    for prov in settings.providers:
        for m in prov.get("models", []):
            mid = m.get("id")
            if not mid:
                continue
            resolved = settings.resolve_model(mid)
            if resolved and not resolved.get("is_neon"):
                add(mid)

    for hana_mid in neon_model_ids or []:
        add(f"neon:{hana_mid}:vanilla")

    return chain


# ---------------------------------------------------------------------------
# Core wrapper: run_resilient_turn
# ---------------------------------------------------------------------------

# The orchestrator passes in `call_participant` so this module doesn't
# have to import from orchestrator.py (avoids the cycle).  Signature:
#   async def call_participant(session, participant, user_prompt, label,
#                              max_tokens) -> tuple[str, float, bool, str]
CallParticipantFn = Callable[..., Awaitable[tuple[str, float, bool, str]]]


def _swap_participant_model_in_place(
    participant: "Participant",
    resolved: dict[str, Any],
) -> None:
    """Rewrite a Participant's backing-LLM fields with a resolved
    substitute. Persona identity (participant_id, name, role_prompt,
    kind) is left untouched, so transcript continuity is preserved —
    only the speaker behind the persona changes.

    Records the previous model_id on `substituted_from_model_id` so
    api_log entries from before/after the swap can be cross-referenced.
    """
    if not participant.substituted_from_model_id:
        participant.substituted_from_model_id = participant.model_id
    participant.model_id = resolved.get("model_id", participant.model_id)
    participant.base_url = resolved.get("base_url", "")
    participant.api_key = resolved.get("api_key", "")
    participant.display_name = resolved.get("display_name", resolved.get("model_id", ""))
    participant.is_neon = resolved.get("is_neon", False)
    participant.hana_model_id = resolved.get("hana_model_id", "")
    participant.persona_name = resolved.get("persona_name", "")
    participant.neon_direct_vllm = resolved.get("neon_direct_vllm", False)
    participant.vllm_base_url = resolved.get("vllm_base_url", "")
    participant.vllm_api_key = resolved.get("vllm_api_key", "")


def _sse(event: str, data: dict[str, Any]) -> str:
    """Mirrors orchestrator._sse so we don't have to import it."""
    return f"event: {event}\ndata: {json.dumps(data)}\n\n"


async def _try_substitution_chain(
    *,
    session: "Session",
    participant: "Participant",
    user_prompt: str,
    label: str,
    max_tokens: int,
    call_participant: CallParticipantFn,
) -> tuple[str, float, bool, str, list[str]]:
    """Walk session.substitution_chain trying each backing LLM in turn.

    On the first success: mutate `participant` in place to point at the
    successful substitute (so subsequent rounds also use it), emit a
    `participant_substituted` SSE so the frontend can surface the
    change, and return the result.

    On exhaustion: return the last failure tuple unchanged.
    """
    events: list[str] = []
    last_text = ""
    last_elapsed = 0.0
    last_kind = ""

    for resolved in session.substitution_chain:
        candidate_id = resolved.get("model_id", "")
        if not candidate_id or candidate_id == participant.model_id:
            continue

        original_model_id = participant.model_id
        _swap_participant_model_in_place(participant, resolved)
        LOG.info(
            "Resilience: substituting backing LLM for %s (%s) — trying %s",
            participant.name, participant.participant_id, candidate_id,
        )

        text, elapsed, ok, kind = await call_participant(
            session=session,
            participant=participant,
            user_prompt=user_prompt,
            label=f"{label}:sub:{candidate_id}",
            max_tokens=max_tokens,
        )
        last_text, last_elapsed, last_kind = text, elapsed, kind

        if ok and text.strip():
            events.append(_sse("participant_substituted", {
                "participant_id": participant.participant_id,
                "name": participant.name,
                "from_model_id": original_model_id,
                "to_model_id": participant.model_id,
                "to_model_display": participant.display_name,
                "phase": session.phase.value,
            }))
            return text, elapsed, True, "", events

    # Exhausted the chain. Caller will fall through to participant_error.
    return last_text, last_elapsed, False, last_kind, events


async def _try_alternate_for_phase1(
    *,
    session: "Session",
    original: "Participant",
    user_prompt: str,
    label: str,
    max_tokens: int,
    call_participant: CallParticipantFn,
) -> tuple["Participant | None", str, float, bool, str, list[str]]:
    """Phase 1 only: try alternate participants from the candidate pool.

    Pops alternates one at a time off session.candidate_pool (so they
    aren't tried twice across the chat), running the same initial-
    opinion prompt against each in turn. On the first success the
    alternate REPLACES `original` in session.participants (preserving
    roster order), and a `participant_replaced` SSE is emitted so the
    sidebar can re-render.

    Returns (alternate, text, elapsed, ok, error_kind, sse_events).
    If the pool is exhausted without a success, returns
    (None, "", 0.0, False, "...", events).
    """
    events: list[str] = []
    last_text = ""
    last_elapsed = 0.0
    last_kind = ""

    while session.candidate_pool:
        alt = session.candidate_pool.pop(0)
        if alt.kind == "human":
            continue
        LOG.info(
            "Resilience: trying alternate participant %s (%s) in place of %s",
            alt.name, alt.participant_id, original.name,
        )

        text, elapsed, ok, kind = await call_participant(
            session=session,
            participant=alt,
            user_prompt=user_prompt,
            label=f"{label}:alt:{alt.participant_id}",
            max_tokens=max_tokens,
        )
        last_text, last_elapsed, last_kind = text, elapsed, kind

        if ok and text.strip():
            # Replace original in the roster at its original index so
            # turn order is preserved for the rest of the chat.
            try:
                idx = session.participants.index(original)
            except ValueError:
                idx = len(session.participants)
                session.participants.append(alt)
            else:
                session.participants[idx] = alt

            events.append(_sse("participant_replaced", {
                "original_participant_id": original.participant_id,
                "original_name": original.name,
                "new_participant_id": alt.participant_id,
                "new_name": alt.name,
                "new_model_id": alt.model_id,
                "new_model_display": alt.display_name,
                "new_kind": alt.kind,
                "phase": session.phase.value,
                "roster": [
                    {
                        "participant_id": p.participant_id,
                        "name": p.name,
                        "kind": p.kind,
                        "model_id": p.model_id,
                        "model_display": p.display_name,
                    }
                    for p in session.participants
                ],
            }))
            return alt, text, elapsed, True, "", events

    return None, last_text, last_elapsed, False, last_kind, events


async def run_resilient_turn(
    *,
    session: "Session",
    participant: "Participant",
    user_prompt: str,
    label: str,
    max_tokens: int,
    call_participant: CallParticipantFn,
    stream_events: list[str] | None = None,
    stream_message_id: str | None = None,
) -> ResilientTurnResult:
    """Phase-aware wrapper around a single participant LLM turn.

    Always invokes `call_participant` once with the original
    participant + model. If that succeeds (or speed-priority is off),
    returns immediately — the existing 2-attempt HTTP retry in
    `openai_compat` already serves as the "second try" for the
    Prioritize-Model-Choice mode.

    Otherwise, under speed-priority:
      - Phase 1 (initial opinions): try an alternate participant from
        the catalog; if that also fails, walk the LLM substitution
        chain on the *original* persona.
      - Phase 2+ : if the error looks transient (5xx/429/timeout)
        retry the same model once; if it still fails, or the error
        was permanent, walk the substitution chain.
    """
    # Lazy phase import to avoid a circular dependency at module load.
    from app.services.models import Phase  # noqa: WPS433

    t0 = time.time()  # noqa: F841 — kept around in case future telemetry needs it
    text, elapsed, ok, error_kind = await call_participant(
        session=session,
        participant=participant,
        user_prompt=user_prompt,
        label=label,
        max_tokens=max_tokens,
        stream_events=stream_events,
        stream_message_id=stream_message_id,
    )

    if ok and text.strip():
        return ResilientTurnResult(
            speaker=participant, text=text, elapsed=elapsed,
            ok=True, error_kind="", sse_events=[],
        )

    if not settings.speed_priority:
        # "Prioritize model choice" — caller will emit participant_error
        # and the conversation continues as today. The 2-attempt HTTP
        # retry in openai_compat already satisfied "always a second try"
        # per the resilience spec.
        return ResilientTurnResult(
            speaker=participant, text=text, elapsed=elapsed,
            ok=False, error_kind=error_kind, sse_events=[],
        )

    events: list[str] = []

    if session.phase == Phase.INITIAL_OPINIONS:
        # Tier 2: alternate participant from catalog pool.
        alt, alt_text, alt_elapsed, alt_ok, alt_kind, alt_events = await _try_alternate_for_phase1(
            session=session,
            original=participant,
            user_prompt=user_prompt,
            label=label,
            max_tokens=max_tokens,
            call_participant=call_participant,
        )
        events.extend(alt_events)
        if alt_ok and alt is not None:
            return ResilientTurnResult(
                speaker=alt, text=alt_text, elapsed=alt_elapsed,
                ok=True, error_kind="", sse_events=events,
            )

        # Tier 3: substitute the LLM behind the *original* persona.
        sub_text, sub_elapsed, sub_ok, sub_kind, sub_events = await _try_substitution_chain(
            session=session,
            participant=participant,
            user_prompt=user_prompt,
            label=label,
            max_tokens=max_tokens,
            call_participant=call_participant,
        )
        events.extend(sub_events)
        return ResilientTurnResult(
            speaker=participant, text=sub_text, elapsed=sub_elapsed,
            ok=sub_ok and bool(sub_text.strip()),
            error_kind="" if sub_ok else (sub_kind or alt_kind or error_kind),
            sse_events=events,
        )

    # Phase 2+ : retry-or-substitute fork.
    if error_kind == "transient":
        LOG.info(
            "Resilience: transient failure on %s for %s — retrying same model",
            participant.model_id, participant.name,
        )
        retry_text, retry_elapsed, retry_ok, retry_kind = await call_participant(
            session=session,
            participant=participant,
            user_prompt=user_prompt,
            label=f"{label}:retry",
            max_tokens=max_tokens,
        )
        if retry_ok and retry_text.strip():
            return ResilientTurnResult(
                speaker=participant, text=retry_text, elapsed=retry_elapsed,
                ok=True, error_kind="", sse_events=events,
            )
        text, elapsed, error_kind = retry_text, retry_elapsed, retry_kind

    # Either the error was permanent, or the same-model retry also failed.
    sub_text, sub_elapsed, sub_ok, sub_kind, sub_events = await _try_substitution_chain(
        session=session,
        participant=participant,
        user_prompt=user_prompt,
        label=label,
        max_tokens=max_tokens,
        call_participant=call_participant,
    )
    events.extend(sub_events)
    return ResilientTurnResult(
        speaker=participant, text=sub_text, elapsed=sub_elapsed,
        ok=sub_ok and bool(sub_text.strip()),
        error_kind="" if sub_ok else (sub_kind or error_kind),
        sse_events=events,
    )