switch from openrouter to HF inference providers

#1
by akhaliq HF Staff - opened
Files changed (2) hide show
  1. app.py +65 -109
  2. index.html +2 -2
app.py CHANGED
@@ -7,13 +7,16 @@ terminal from "/". The frontend uses Gradio's JS client to call /chat, so
7
  it picks up queuing, SSE streaming, and concurrency control for free.
8
 
9
  Reasoning is preserved across turns: the frontend re-sends prior assistant
10
- turns with their `reasoning_details`, so Laguna S 2.1 continues reasoning
11
- from where it left off on the previous call.
 
 
 
 
12
  """
13
 
14
  import json
15
  import os
16
- import time
17
  from typing import Iterator
18
 
19
  from openai import OpenAI
@@ -22,19 +25,24 @@ from gradio import Server
22
  from fastapi.responses import HTMLResponse
23
 
24
  # ---------------------------------------------------------------------------
25
- # Backend: OpenAI-compatible client pointed at OpenRouter.
26
  # ---------------------------------------------------------------------------
27
- API_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("API_KEY") or ""
28
- MODEL = os.environ.get("MODEL", "poolside/laguna-s-2.1:free")
 
 
 
 
29
 
30
  client = OpenAI(
31
- base_url="https://openrouter.ai/api/v1",
32
- api_key=API_KEY or "missing-key",
 
33
  )
34
 
35
  # ---------------------------------------------------------------------------
36
- # ANSI palette for the terminal look. The frontend keeps this table so it
37
- # can render lines verbatim (no markdown parsing).
38
  # ---------------------------------------------------------------------------
39
  RESET = "\033[0m"
40
  BOLD = "\033[1m"
@@ -43,48 +51,9 @@ ITALIC = "\033[3m"
43
  GREEN = "\033[32m"
44
  CYAN = "\033[36m"
45
  YELLOW = "\033[33m"
46
- MAGENTA = "\033[35m"
47
  RED = "\033[31m"
48
  GREY = "\033[90m"
49
  BOLD_GREEN = BOLD + GREEN
50
- BOLD_CYAN = BOLD + CYAN
51
-
52
-
53
- def _extract_reasoning(reasoning_details) -> str:
54
- """Read text out of OpenRouter reasoning_details (list/dict/str, nested)."""
55
- if not reasoning_details:
56
- return ""
57
- chunks: list[str] = []
58
-
59
- def push(val):
60
- if isinstance(val, str):
61
- chunks.append(val)
62
- elif isinstance(val, list):
63
- for v in val:
64
- push(v)
65
- elif isinstance(val, dict):
66
- for k in ("summary", "text", "content", "reasoning"):
67
- if k in val:
68
- push(val[k])
69
-
70
- if isinstance(reasoning_details, list):
71
- for p in reasoning_details:
72
- push(p)
73
- elif isinstance(reasoning_details, dict):
74
- push(reasoning_details)
75
- elif isinstance(reasoning_details, str):
76
- chunks.append(reasoning_details)
77
-
78
- return "\n".join(c for c in chunks if c).strip()
79
-
80
-
81
- def _one_line(text: str) -> str:
82
- return (text or "").replace("\n", " ").strip()
83
-
84
-
85
- def _emit(payload: dict) -> dict:
86
- """Strip non-JSON-safe bits before yielding (we only stream ASCII)."""
87
- return {"event": payload.get("event", "delta"), "text": payload.get("text", "")}
88
 
89
 
90
  def _coerce_messages(messages):
@@ -96,8 +65,7 @@ def _coerce_messages(messages):
96
  return []
97
  if isinstance(messages, str):
98
  try:
99
- import json as _json
100
- decoded = _json.loads(messages)
101
  return decoded if isinstance(decoded, list) else []
102
  except Exception:
103
  return []
@@ -118,22 +86,24 @@ def _coerce_bool(value, default=True):
118
  return bool(value) if value is not None else default
119
 
120
 
 
 
 
 
121
  # ---------------------------------------------------------------------------
122
  # /chat endpoint. With gradio.Server, a generator-typed function streams via
123
- # SSE. We yield small text events (think: print-style lines the terminal
124
- # renders as they arrive) and finish with a `done` event carrying the new
125
- # assistant message β€” the frontend appends that to its in-memory history
126
- # along with the original `reasoning_details`.
127
  # ---------------------------------------------------------------------------
128
  app = Server()
129
 
130
 
131
  @app.api()
132
  def chat(messages: list, reasoning: bool = True) -> Iterator[dict]:
133
- """Reasoning-aware chat endpoint. Takes `messages` (list of
134
- role/content/reasoning_details dicts) and an optional `reasoning` bool.
135
- Streams `{"event":"delta","text":...}` lines, then a final `done` event
136
- with the new assistant message so the frontend can persist it."""
137
  messages = _coerce_messages(messages)
138
  reasoning = _coerce_bool(reasoning, default=True)
139
 
@@ -142,77 +112,63 @@ def chat(messages: list, reasoning: bool = True) -> Iterator[dict]:
142
  )
143
  user_text = (last_user or {}).get("content", "")
144
 
145
- yield _emit({
146
- "event": "delta",
147
- "text": f"{GREY}$ {RESET}{GREEN}user{RESET} {DIM}Β»{RESET} {_one_line(user_text)}\n",
148
  })
149
-
150
- yield _emit({
151
- "event": "delta",
152
  "text": (
153
- f"{CYAN}laguna{RESET}{GREY}@{RESET}{YELLOW}openrouter{RESET}"
154
  f"{GREY}:{RESET}{DIM}~{RESET} {GREY}{'thinking…' if reasoning else 'responding…'}{RESET}\n"
155
  ),
156
  })
157
 
 
 
 
158
  try:
159
  response = client.chat.completions.create(
160
  model=MODEL,
161
  messages=messages,
162
- extra_body={"reasoning": {"enabled": reasoning}},
163
  )
164
- message = response.choices[0].message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  except Exception as e:
166
- yield _emit({
167
- "event": "delta",
168
  "text": f"\n{BOLD}{RED}error{RESET} {DIM}Β»{RESET} {e}\n",
169
  })
170
- yield {"event": "done", "text": "", "assistant": None}
171
- return
172
-
173
- reasoning_text = _extract_reasoning(getattr(message, "reasoning_details", None))
174
 
175
- if reasoning_text:
176
- yield _emit({
177
- "event": "delta",
178
- "text": f"{DIM}{ITALIC}β”Œβ”€ reasoning ───────────────────────{RESET}\n",
179
  })
180
- for line in reasoning_text.splitlines() or [""]:
181
- prefix = f"{DIM}{ITALIC}β”‚ {RESET}" if line else f"{DIM}β”‚{RESET} "
182
- yield _emit({"event": "delta", "text": prefix + line + "\n"})
183
- time.sleep(0.012)
184
- yield _emit({
185
- "event": "delta",
186
- "text": f"{DIM}{ITALIC}└───────────────────────────────────{RESET}\n",
187
- })
188
-
189
- answer = (message.content or "").strip()
190
- yield _emit({
191
- "event": "delta",
192
- "text": f"{BOLD_GREEN}β”Œβ”€ laguna ──────────────────────────{RESET}\n",
193
- })
194
- for line in answer.splitlines() or [""]:
195
- prefix = f"{BOLD_GREEN}β”‚ {RESET}" if line else f"{GREEN}β”‚{RESET} "
196
- yield _emit({"event": "delta", "text": prefix + line + "\n"})
197
- time.sleep(0.012)
198
- yield _emit({
199
- "event": "delta",
200
- "text": f"{BOLD_GREEN}└───────────────────────────────────{RESET}\n",
201
- })
202
- yield _emit({"event": "delta", "text": f"{GREY}── done ──{RESET}\n"})
203
-
204
- # The new assistant message β€” keep reasoning_details so the JS client
205
- # can re-send it on the next turn, letting Laguna continue its reasoning.
206
- rd = getattr(message, "reasoning_details", None)
207
- rd_json = json.loads(json.dumps(rd, default=str)) if rd else None
208
 
209
  yield {
210
  "event": "done",
211
  "text": "",
212
  "assistant": {
213
  "role": "assistant",
214
- "content": message.content or "",
215
- "reasoning_details": rd_json,
216
  },
217
  }
218
 
@@ -234,4 +190,4 @@ async def healthz():
234
 
235
 
236
  if __name__ == "__main__":
237
- app.launch(show_error=True)
 
7
  it picks up queuing, SSE streaming, and concurrency control for free.
8
 
9
  Reasoning is preserved across turns: the frontend re-sends prior assistant
10
+ turns with their content, so Laguna S 2.1 continues the conversation.
11
+
12
+ Backend talks to Hugging Face's inference router
13
+ (https://router.huggingface.co/v1) using a provider-routed model id
14
+ (`poolside/Laguna-S-2.1:<provider>`). HF_TOKEN is set as a Space secret;
15
+ the X-HF-Bill-To header ensures the request is billed to the org.
16
  """
17
 
18
  import json
19
  import os
 
20
  from typing import Iterator
21
 
22
  from openai import OpenAI
 
25
  from fastapi.responses import HTMLResponse
26
 
27
  # ---------------------------------------------------------------------------
28
+ # Backend: OpenAI-compatible client pointed at HF inference router.
29
  # ---------------------------------------------------------------------------
30
+ HF_TOKEN = os.environ.get("HF_TOKEN") or ""
31
+ INFERENCE_BASE_URL = os.environ.get(
32
+ "INFERENCE_BASE_URL", "https://router.huggingface.co/v1"
33
+ )
34
+ # Provider-routed model id, e.g. "poolside/Laguna-S-2.1:featherless-ai".
35
+ MODEL = os.environ.get("MODEL", "poolside/Laguna-S-2.1:featherless-ai")
36
 
37
  client = OpenAI(
38
+ api_key=HF_TOKEN or "missing-key",
39
+ base_url=INFERENCE_BASE_URL,
40
+ default_headers={"X-HF-Bill-To": "poolside"},
41
  )
42
 
43
  # ---------------------------------------------------------------------------
44
+ # ANSI palette for the terminal look. The Chatbot renders content verbatim
45
+ # (render_markdown=False), so we emit raw escape codes and style the container.
46
  # ---------------------------------------------------------------------------
47
  RESET = "\033[0m"
48
  BOLD = "\033[1m"
 
51
  GREEN = "\033[32m"
52
  CYAN = "\033[36m"
53
  YELLOW = "\033[33m"
 
54
  RED = "\033[31m"
55
  GREY = "\033[90m"
56
  BOLD_GREEN = BOLD + GREEN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
 
59
  def _coerce_messages(messages):
 
65
  return []
66
  if isinstance(messages, str):
67
  try:
68
+ decoded = json.loads(messages)
 
69
  return decoded if isinstance(decoded, list) else []
70
  except Exception:
71
  return []
 
86
  return bool(value) if value is not None else default
87
 
88
 
89
+ def _delta(payload: dict) -> dict:
90
+ return {"event": "delta", "text": payload.get("text", "")}
91
+
92
+
93
  # ---------------------------------------------------------------------------
94
  # /chat endpoint. With gradio.Server, a generator-typed function streams via
95
+ # SSE. We yield per-token deltas as they arrive from the poolside API, then
96
+ # a final `done` event with the accumulated content so the frontend can
97
+ # persist it for the next turn.
 
98
  # ---------------------------------------------------------------------------
99
  app = Server()
100
 
101
 
102
  @app.api()
103
  def chat(messages: list, reasoning: bool = True) -> Iterator[dict]:
104
+ """Streaming chat endpoint. Takes `messages` (list of role/content dicts)
105
+ and an optional `reasoning` bool. Streams `{"event":"delta","text":...}`
106
+ per token, then a final `done` event with the full assistant content."""
 
107
  messages = _coerce_messages(messages)
108
  reasoning = _coerce_bool(reasoning, default=True)
109
 
 
112
  )
113
  user_text = (last_user or {}).get("content", "")
114
 
115
+ yield _delta({
116
+ "text": f"{GREY}$ {RESET}{GREEN}user{RESET} {DIM}Β»{RESET} {user_text.replace(chr(10), ' ').strip()}\n",
 
117
  })
118
+ yield _delta({
 
 
119
  "text": (
120
+ f"{CYAN}laguna{RESET}{GREY}@{RESET}{YELLOW}huggingface{RESET}"
121
  f"{GREY}:{RESET}{DIM}~{RESET} {GREY}{'thinking…' if reasoning else 'responding…'}{RESET}\n"
122
  ),
123
  })
124
 
125
+ full_text_parts: list[str] = []
126
+ in_answer = False
127
+
128
  try:
129
  response = client.chat.completions.create(
130
  model=MODEL,
131
  messages=messages,
132
+ stream=True,
133
  )
134
+
135
+ for chunk in response:
136
+ if not chunk.choices:
137
+ continue
138
+ choice = chunk.choices[0]
139
+ delta = getattr(choice, "delta", None)
140
+ if delta is None:
141
+ continue
142
+ piece = getattr(delta, "content", None)
143
+ if piece is None:
144
+ continue
145
+
146
+ if not in_answer:
147
+ # First content chunk β€” open the answer box.
148
+ yield _delta({
149
+ "text": f"{BOLD_GREEN}β”Œβ”€ laguna ──────────────────────────{RESET}\n",
150
+ })
151
+ in_answer = True
152
+
153
+ full_text_parts.append(piece)
154
+ yield _delta({"text": piece})
155
  except Exception as e:
156
+ yield _delta({
 
157
  "text": f"\n{BOLD}{RED}error{RESET} {DIM}Β»{RESET} {e}\n",
158
  })
 
 
 
 
159
 
160
+ if in_answer:
161
+ yield _delta({
162
+ "text": f"\n{BOLD_GREEN}└───────────────────────────────────{RESET}\n",
 
163
  })
164
+ yield _delta({"text": f"{GREY}── done ──{RESET}\n"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  yield {
167
  "event": "done",
168
  "text": "",
169
  "assistant": {
170
  "role": "assistant",
171
+ "content": "".join(full_text_parts),
 
172
  },
173
  }
174
 
 
190
 
191
 
192
  if __name__ == "__main__":
193
+ app.launch(show_error=True)
index.html CHANGED
@@ -344,7 +344,7 @@
344
  </div>
345
 
346
  <div class="footline">
347
- <span>model: <span class="grad" style="font-weight:600">poolside/laguna-s-2.1:free</span> Β· via openrouter Β· lossless chain-of-thought across turns via <code>reasoning_details</code></span>
348
  <span>built with gradio <code>Server</code> + @gradio/client</span>
349
  </div>
350
  </div>
@@ -371,7 +371,7 @@
371
 
372
  const banner = [
373
  `${ANSI.green}● ${ANSI.reset}Connected to agent server: ${ANSI.bold}Poolside v2.1${ANSI.reset}`,
374
- `${ANSI.dim} session id: robust-lake-${Math.floor(Math.random()*9000+1000)} Β· model poolside/laguna-s-2.1:free Β· openrouter${ANSI.reset}`,
375
  "",
376
  `${ANSI.cyan}>${ANSI.reset} type a question below. the model ${ANSI.bold}reasons before it answers${ANSI.reset}, and remembers its thoughts on the next turn.`,
377
  "",
 
344
  </div>
345
 
346
  <div class="footline">
347
+ <span>model: <span class="grad" style="font-weight:600">poolside/Laguna-S-2.1:featherless-ai</span> Β· via <code>router.huggingface.co/v1</code> Β· lossless chain-of-thought across turns via <code>reasoning_details</code></span>
348
  <span>built with gradio <code>Server</code> + @gradio/client</span>
349
  </div>
350
  </div>
 
371
 
372
  const banner = [
373
  `${ANSI.green}● ${ANSI.reset}Connected to agent server: ${ANSI.bold}Poolside v2.1${ANSI.reset}`,
374
+ `${ANSI.dim} session id: robust-lake-${Math.floor(Math.random()*9000+1000)} Β· model poolside/Laguna-S-2.1:featherless-ai Β· huggingface router${ANSI.reset}`,
375
  "",
376
  `${ANSI.cyan}>${ANSI.reset} type a question below. the model ${ANSI.bold}reasons before it answers${ANSI.reset}, and remembers its thoughts on the next turn.`,
377
  "",