File size: 9,801 Bytes
9d874c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
AI API failure states: one Gradio app, three tabs.

  1. Rate limits          429 -> Retry-After / exponential backoff -> success
  2. Stalled streams      silence detection, keep the partial answer
  3. Refusal vs. error    a declined request is not a broken request

Default mode is Simulated: free, no API key, every failure on demand.
"Real API" mode streams from OpenAI or Anthropic through the same code.
"""

import html
import os
import time

import gradio as gr
import spaces

from core import Event, stream_with_retries
from providers import RealClient, SimulatedClient


@spaces.GPU
def _warmup():
    """No-op. Exists only so Hugging Face's ZeroGPU startup check finds an
    @spaces.GPU function. This app never actually needs a GPU."""
    return None


COLORS = {"ok": "#5fb87a", "wait": "#e0a336", "error": "#d9695f", "refusal": "#8b909c"}
DEFAULT_PROMPT = "Explain how a TCP handshake works in three sentences."


# ---------------------------------------------------------------- helpers
def panel(kind: str, title: str, body: str = "", code: str = "") -> str:
    c = COLORS[kind]
    chip = (f'<span style="margin-left:auto;font-family:monospace;font-size:0.75rem;'
            f'opacity:.7">{html.escape(code)}</span>') if code else ""
    body_html = (f'<div style="margin-top:6px;font-size:0.9rem;opacity:.9">'
                 f'{html.escape(body)}</div>') if body else ""
    return (f'<div style="border:1px solid {c}55;background:{c}1a;border-radius:8px;'
            f'padding:12px 14px;"><div style="display:flex;align-items:center;gap:8px;'
            f'font-weight:600"><span style="width:8px;height:8px;border-radius:50%;'
            f'background:{c}"></span>{html.escape(title)}{chip}</div>{body_html}</div>')


def make_client(mode, provider, key, scenario):
    """Return a client with .stream(prompt). Raises ValueError with a
    human-readable message if real mode is missing a key."""
    if mode.startswith("Simulated"):
        return SimulatedClient(scenario)
    key = (key or "").strip() or os.environ.get(
        "OPENAI_API_KEY" if provider == "openai" else "ANTHROPIC_API_KEY", "")
    if not key:
        raise ValueError(f"Real API mode needs a {provider} API key.")
    return RealClient(provider, key)


class Log:
    def __init__(self):
        self.t0, self.lines = time.time(), []

    def add(self, line):
        self.lines.append(f"+{time.time() - self.t0:4.1f}s  {line}")
        return "\n".join(self.lines)

    def text(self):
        return "\n".join(self.lines)


# ------------------------------------------------------------ tab 1: 429s
def run_rate_limit(mode, provider, key, prompt):
    try:
        client = make_client(mode, provider, key, "rate_limit")
    except ValueError as e:
        yield "", panel("error", "Missing API key", str(e)), ""
        return
    log, text = Log(), ""
    for ev in stream_with_retries(lambda: client.stream(prompt), max_retries=3):
        text = ev.text or text
        if ev.kind == "start":
            yield text, panel("wait", f"Sending request (attempt {ev.attempt})"), log.add(f"attempt {ev.attempt}: request sent")
        elif ev.kind == "chunk":
            yield text, panel("wait", "Streaming\u2026"), log.text()
        elif ev.kind == "retry_wait" and ev.message:
            yield text, panel("wait", f"Rate limited. Retrying in {ev.seconds}s", ev.message, "429"), log.add(f"[429] {ev.message}")
        elif ev.kind == "retry_wait":
            yield text, panel("wait", f"Rate limited. Retrying in {ev.seconds}s", "Your request is queued; nothing was lost.", "429"), log.text()
        elif ev.kind == "done":
            yield text, panel("ok", "Done", f"Succeeded on attempt {ev.attempt}.", "200"), log.add("[200] stream completed")
        else:  # error
            yield text, panel("error", "Request failed", ev.message, str(ev.status_code or "")), log.add(f"[error] {ev.message}")


# ------------------------------------------------------- tab 2: stalled stream
def run_stall(mode, provider, key, prompt, stall_seconds, force_ok=False):
    hide = gr.update(visible=False)
    try:
        client = make_client(mode, provider, key, "ok" if force_ok else "stall")
    except ValueError as e:
        yield "", panel("error", "Missing API key", str(e)), "", hide, hide
        return
    log, text = Log(), ""
    for ev in stream_with_retries(lambda: client.stream(prompt), stall_seconds=stall_seconds):
        text = ev.text or text
        if ev.kind == "start":
            yield "", panel("wait", "Streaming\u2026"), log.add("request sent"), hide, hide
        elif ev.kind == "chunk":
            yield text, panel("wait", "Streaming\u2026"), log.text(), hide, hide
        elif ev.kind == "done":
            yield text, panel("ok", "Stream completed", code="200"), log.add("[200] stream completed"), hide, hide
        elif ev.kind == "stalled":
            n = len(text.split())
            yield (text,
                   panel("error", f"Response stalled after {n} words",
                         f"{ev.message}. The partial answer is kept. Retry, or keep what you have."),
                   log.add(f"[stall] {ev.message} (kept {n} words)"),
                   gr.update(visible=True), gr.update(visible=True))
        else:
            yield text, panel("error", "Request failed", ev.message), log.add(f"[error] {ev.message}"), hide, hide


def keep_partial(text):
    hide = gr.update(visible=False)
    return panel("refusal", "Kept partial answer", "Marked as incomplete so nobody mistakes it for a full answer."), hide, hide


# --------------------------------------------------- tab 3: refusal vs error
def run_refusal(mode, provider, key, scenario, prompt):
    sim = {"Normal answer": "ok", "Refusal (200, model declined)": "refusal",
           "Server error (500)": "server_error"}[scenario]
    try:
        client = make_client(mode, provider, key, sim)
    except ValueError as e:
        yield "", panel("error", "Missing API key", str(e))
        return
    text = ""
    for ev in stream_with_retries(lambda: client.stream(prompt)):
        text = ev.text or text
        if ev.kind == "chunk":
            yield text, panel("wait", "Streaming\u2026")
        elif ev.kind == "done":
            yield text, panel("ok", "Response", code="200")
        elif ev.kind == "refused":
            yield text, panel("refusal", "The model declined this request", ev.message + " Rewording the same ask won't help; try a different task.", "200 \u00b7 refusal")
        elif ev.kind == "error":
            yield text, panel("error", "Something went wrong on our end", ev.message + " This is a service problem, not a judgment about your prompt. Trying again is reasonable.", str(ev.status_code or "error"))


# ------------------------------------------------------------------- UI
with gr.Blocks(title="AI API failure states") as demo:
    gr.Markdown("## AI API failure states\nThree failure modes, one shared retry/stream layer. "
                "**Simulated** mode is free and needs no key; **Real API** streams from a live provider.")
    with gr.Row():
        mode = gr.Radio(["Simulated (free, no key)", "Real API"], value="Simulated (free, no key)", label="Mode")
        provider = gr.Dropdown(["openai", "anthropic"], value="openai", label="Provider (Real API only)")
        key = gr.Textbox(label="API key (Real API only)", type="password",
                         placeholder="Falls back to the Space secret if left empty")
    prompt = gr.Textbox(label="Prompt", value=DEFAULT_PROMPT, lines=2)

    with gr.Tabs():
        with gr.Tab("1 \u00b7 Rate limits (429)"):
            gr.Markdown("Two simulated 429s: the first has a `Retry-After`, the second doesn't (exponential backoff + jitter). Then it succeeds.")
            b1 = gr.Button("Send", variant="primary")
            o1, s1, l1 = gr.Textbox(label="Response", lines=5), gr.HTML(), gr.Textbox(label="Request log", lines=5, interactive=False)
            b1.click(run_rate_limit, [mode, provider, key, prompt], [o1, s1, l1])

        with gr.Tab("2 \u00b7 Stalled stream"):
            gr.Markdown("The stream goes silent mid-sentence. A watchdog notices, the partial answer is kept, and the user chooses.")
            stall_s = gr.Slider(1, 10, value=3, step=1, label="Silence threshold (seconds)")
            b2 = gr.Button("Send", variant="primary")
            o2, s2, l2 = gr.Textbox(label="Response", lines=5), gr.HTML(), gr.Textbox(label="Request log", lines=4, interactive=False)
            with gr.Row():
                retry2 = gr.Button("Retry request", visible=False)
                keep2 = gr.Button("Keep partial answer", visible=False)
            outs2 = [o2, s2, l2, retry2, keep2]
            b2.click(run_stall, [mode, provider, key, prompt, stall_s], outs2)
            retry2.click(lambda *a: run_stall(*a, force_ok=True), [mode, provider, key, prompt, stall_s], outs2)
            keep2.click(keep_partial, [o2], [s2, retry2, keep2])

        with gr.Tab("3 \u00b7 Refusal vs. error"):
            gr.Markdown("A refusal is a *successful* response that says no. A 500 is a failure. They need different copy and different next steps. "
                        "In Real API mode the scenario picker is ignored, and refusals are detected from the provider's structured signals.")
            scen = gr.Radio(["Normal answer", "Refusal (200, model declined)", "Server error (500)"],
                            value="Refusal (200, model declined)", label="Scenario (Simulated only)")
            b3 = gr.Button("Send", variant="primary")
            o3, s3 = gr.Textbox(label="Response", lines=3), gr.HTML()
            b3.click(run_refusal, [mode, provider, key, scen, prompt], [o3, s3])

if __name__ == "__main__":
    demo.queue().launch()