Geraldine commited on
Commit
6ed606f
·
verified ·
1 Parent(s): 967ad05

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +482 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,484 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
 
 
 
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
1
+ import os
2
+ import json
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ import requests
7
  import streamlit as st
8
+ from openai import OpenAI
9
+
10
+
11
+ # ----------------------------
12
+ # Helpers
13
+ # ----------------------------
14
+ def get_headers(api_key: str) -> Dict[str, str]:
15
+ return {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
16
+
17
+
18
+ def pretty(obj: Any) -> str:
19
+ return json.dumps(obj, ensure_ascii=False, indent=2)
20
+
21
+ def make_client(base_url: str, api_key: str) -> OpenAI:
22
+ return OpenAI(base_url=base_url, api_key=api_key)
23
+
24
+
25
+ def http_get_json(url: str, headers: Dict[str, str], timeout: int = 30) -> Dict[str, Any]:
26
+ r = requests.get(url, headers=headers, timeout=timeout)
27
+ r.raise_for_status()
28
+ return r.json()
29
+
30
+
31
+ def http_post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int = 60) -> Dict[str, Any]:
32
+ r = requests.post(url, headers=headers, json=payload, timeout=timeout)
33
+ r.raise_for_status()
34
+ return r.json()
35
+
36
+
37
+ def provider_defaults() -> Dict[str, Dict[str, str]]:
38
+ return {
39
+ "OpenAI": {
40
+ "base_url": "https://api.openai.com/v1",
41
+ "env_key": os.environ.get("OPENAI_API_KEY"),
42
+ "notes": "Supporte Responses API (web_search, image) + Chat/Embeddings/Models.",
43
+ },
44
+ "Groq": {
45
+ "base_url": "https://api.groq.com/openai/v1",
46
+ "env_key": os.environ.get("GROQ_API_KEY"),
47
+ "notes": "OpenAI-compatible pour Chat/Models/Embeddings (selon offre). Pas de web_search OpenAI.",
48
+ },
49
+ "Ollama": {
50
+ "base_url": "https://ollama.com/v1",
51
+ "env_key": os.environ.get("OLLAMA_API_KEY"),
52
+ "notes": "OpenAI-compatible local. Models/Chat ok selon config. Embeddings selon modèles dispo.",
53
+ },
54
+ "Albert (Etalab)": {
55
+ "base_url": "https://albert.api.etalab.gouv.fr/v1",
56
+ "env_key": os.environ.get("ALBERT_API_KEY"),
57
+ "notes": "OpenAI-compatible (selon endpoints activés). Pas de web_search OpenAI.",
58
+ },
59
+ }
60
+
61
+
62
+ def is_openai_provider(name: str, base_url: str) -> bool:
63
+ return name.lower().startswith("openai") or "api.openai.com" in base_url
64
+
65
+
66
+ def extract_model_ids(models_payload: Any) -> List[str]:
67
+ """
68
+ Normalise la sortie /models (ou SDK models.list) en liste d'IDs.
69
+ """
70
+ if models_payload is None:
71
+ return []
72
+ if isinstance(models_payload, dict) and isinstance(models_payload.get("data"), list):
73
+ ids = []
74
+ for m in models_payload["data"]:
75
+ if isinstance(m, dict) and "id" in m:
76
+ ids.append(m["id"])
77
+ return sorted(list(dict.fromkeys(ids)))
78
+ # parfois payload déjà sous forme list
79
+ if isinstance(models_payload, list):
80
+ ids = []
81
+ for m in models_payload:
82
+ if isinstance(m, dict) and "id" in m:
83
+ ids.append(m["id"])
84
+ return sorted(list(dict.fromkeys(ids)))
85
+ return []
86
+
87
+
88
+ def pick_default(ids: List[str], preferred: List[str]) -> int:
89
+ """
90
+ Renvoie l'index d'un modèle préféré s'il existe, sinon 0.
91
+ """
92
+ lower = {m.lower(): i for i, m in enumerate(ids)}
93
+ for p in preferred:
94
+ if p.lower() in lower:
95
+ return lower[p.lower()]
96
+ return 0
97
+
98
+
99
+ # ----------------------------
100
+ # Streamlit UI
101
+ # ----------------------------
102
+ st.set_page_config(page_title="LLM API Playground (pédagogique)", layout="wide")
103
+ st.title("Mini-app de requêtes API sur LLMs")
104
+ st.caption("Choisir un provider")
105
+
106
+ with st.sidebar:
107
+ st.header("Configuration")
108
+ defaults = provider_defaults()
109
+ provider_name = st.selectbox("Fournisseur", list(defaults.keys()), index=0)
110
+
111
+ base_url = st.text_input("Base URL", value=defaults[provider_name]["base_url"])
112
+ env_key = defaults[provider_name]["env_key"]
113
+
114
+ st.write(f"Variable d’environnement attendue : `{env_key}`")
115
+ api_key = st.text_input(
116
+ "API key (optionnel si déjà dans l'env)",
117
+ value="",
118
+ type="password",
119
+ help=f"Laisse vide si tu as déjà exporté {env_key} dans ton environnement.",
120
+ )
121
+ if not api_key:
122
+ api_key = safe_get_env(env_key, "")
123
+
124
+ st.markdown("---")
125
+ st.info(defaults[provider_name]["notes"])
126
+
127
+ st.markdown("---")
128
+ show_raw = st.toggle("Afficher requête/réponse brutes", value=True)
129
+ timeout_s = st.slider("Timeout HTTP (s)", 10, 120, 45, 5)
130
+
131
+ if not api_key and provider_name != "Ollama":
132
+ st.warning(f"Pas de clé détectée. Renseigne l’API key dans la sidebar ou exporte `{env_key}`.")
133
+
134
+ client = make_client(base_url=base_url, api_key=api_key if api_key else "NO_KEY")
135
+
136
+
137
+ # ----------------------------
138
+ # Load models once per provider/base_url/api_key (cache)
139
+ # ----------------------------
140
+ @st.cache_data(show_spinner=False, ttl=300)
141
+ def load_models_cached(base_url: str, api_key: str, timeout_s: int) -> Tuple[List[str], Optional[Dict[str, Any]], Optional[str]]:
142
+ """
143
+ Retourne (ids, raw_payload, error_msg).
144
+ On tente d'abord via HTTP GET /models (le plus universel).
145
+ """
146
+ try:
147
+ models_url = f"{base_url}/models"
148
+ raw = http_get_json(models_url, headers=get_headers(api_key), timeout=timeout_s)
149
+ ids = extract_model_ids(raw)
150
+ if not ids:
151
+ return [], raw, "Réponse /models reçue, mais aucun `id` exploitable n'a été trouvé."
152
+ return ids, raw, None
153
+ except Exception as e:
154
+ return [], None, str(e)
155
+
156
+
157
+ with st.spinner("Chargement des modèles du provider…"):
158
+ model_ids, models_raw, models_err = load_models_cached(base_url, api_key, timeout_s)
159
+
160
+ if models_err:
161
+ st.sidebar.warning(f"Impossible de charger /models : {models_err}")
162
+ elif model_ids:
163
+ st.sidebar.success(f"Modèles chargés : {len(model_ids)}")
164
+ else:
165
+ st.sidebar.warning("Aucun modèle détecté (structure inattendue).")
166
+
167
+ if show_raw and models_raw:
168
+ with st.expander("Debug: réponse brute /models"):
169
+ st.code(pretty(models_raw), language="json")
170
+
171
+
172
+ def model_selector(
173
+ label: str,
174
+ ids: List[str],
175
+ preferred: List[str],
176
+ key: str,
177
+ fallback_value: str,
178
+ ) -> str:
179
+ """
180
+ Retourne un model_id :
181
+ - si ids dispo: selectbox
182
+ - sinon: text_input fallback
183
+ """
184
+ if ids:
185
+ idx = pick_default(ids, preferred)
186
+ return st.selectbox(label, ids, index=idx, key=key)
187
+ return st.text_input(label, value=fallback_value, key=key + "_fallback")
188
+
189
+
190
+ tabs = st.tabs(
191
+ [
192
+ "1) Lister les modèles",
193
+ "2) Embeddings",
194
+ "3) Chat completion",
195
+ "4) Extraction JSON",
196
+ "5) OpenAI web_search",
197
+ "6) OpenAI image → description",
198
+ ]
199
+ )
200
+
201
+ # ----------------------------
202
+ # 1) Models
203
+ # ----------------------------
204
+ with tabs[0]:
205
+ st.subheader("1) Lister les modèles disponibles (`GET /models`)")
206
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
207
+
208
+ with colA:
209
+ if st.button("🔄 Recharger /models", type="primary"):
210
+ load_models_cached.clear()
211
+ st.rerun()
212
+
213
+ st.write("IDs détectés :")
214
+ if model_ids:
215
+ st.dataframe({"model_id": model_ids})
216
+ else:
217
+ st.info("Pas de liste exploitable. (Voir la réponse brute dans la sidebar si activée.)")
218
+
219
+ with colB:
220
+ st.write("À retenir")
221
+ st.markdown(
222
+ "- Le dropdown des autres onglets dépend de cette liste.\n"
223
+ "- Si `/models` est bloqué par un provider, l’app retombe sur un champ texte."
224
+ )
225
+
226
+ # ----------------------------
227
+ # 2) Embeddings
228
+ # ----------------------------
229
+ with tabs[1]:
230
+ st.subheader("2) Embeddings (`POST /embeddings`)")
231
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
232
+
233
+ with colA:
234
+ emb_model = model_selector(
235
+ "Modèle d'embeddings",
236
+ model_ids,
237
+ preferred=["text-embedding-3-small", "text-embedding-ada-002"],
238
+ key="emb_model",
239
+ fallback_value="text-embedding-3-small",
240
+ )
241
+ text = st.text_area("Texte à embedder", value="This is a test", height=120)
242
+
243
+ if st.button("🧬 Calculer embeddings", type="primary"):
244
+ try:
245
+ emb_url = f"{base_url}/embeddings"
246
+ payload = {"model": emb_model, "input": text}
247
+ t0 = time.time()
248
+ resp = http_post_json(emb_url, headers=get_headers(api_key), payload=payload, timeout=timeout_s)
249
+ dt = time.time() - t0
250
+
251
+ st.success(f"OK — {dt:.2f}s")
252
+ try:
253
+ emb = resp["data"][0]["embedding"]
254
+ st.write(f"Dimension : **{len(emb)}**")
255
+ except Exception:
256
+ st.info("Impossible d'extraire `data[0].embedding` (structure différente).")
257
+
258
+ usage = resp.get("usage", {})
259
+ if usage:
260
+ st.write(f"Usage : `{pretty(usage)}`")
261
+
262
+ if show_raw:
263
+ st.code(pretty(resp), language="json")
264
+ except Exception as e:
265
+ st.error(f"Erreur: {e}")
266
+
267
+ #with colB:
268
+ #st.write("À retenir")
269
+ #st.markdown("- Le modèle sélectionné vient de `/models` quand c’est possible.\n- Sinon, saisis l’ID à la main.")
270
+
271
+ # ----------------------------
272
+ # 3) Chat completion
273
+ # ----------------------------
274
+ with tabs[2]:
275
+ st.subheader("3) Chat completion (`POST /chat/completions`)")
276
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
277
+
278
+ with colA:
279
+ chat_model = model_selector(
280
+ "Modèle (chat)",
281
+ model_ids,
282
+ preferred=["gpt-4o-mini", "gpt-4.1-mini", "gpt-4", "llama", "mixtral"],
283
+ key="chat_model",
284
+ fallback_value="gpt-4o-mini",
285
+ )
286
+ prompt = st.text_area(
287
+ "Prompt utilisateur",
288
+ value="Explique la différence entre modèles de langage encodeur et modèle de langage décodeur.",
289
+ height=140,
290
+ )
291
+ max_tokens = st.slider("max_completion_tokens", 32, 512, 200, 16)
292
+ temperature = st.slider("temperature", 0.0, 1.5, 0.3, 0.1)
293
+
294
+ if st.button("💬 Générer", type="primary"):
295
+ try:
296
+ completion_url = f"{base_url}/chat/completions"
297
+ payload = {
298
+ "model": chat_model,
299
+ "messages": [{"role": "user", "content": prompt}],
300
+ "max_completion_tokens": max_tokens,
301
+ "temperature": temperature,
302
+ "stream": False,
303
+ }
304
+ t0 = time.time()
305
+ resp = http_post_json(completion_url, headers=get_headers(api_key), payload=payload, timeout=timeout_s)
306
+ dt = time.time() - t0
307
+
308
+ st.success(f"OK — {dt:.2f}s")
309
+ try:
310
+ content = resp["choices"][0]["message"]["content"]
311
+ st.markdown("### Réponse")
312
+ st.write(content)
313
+ except Exception:
314
+ st.info("Structure de réponse inattendue. Regarde la réponse brute.")
315
+ if show_raw:
316
+ st.code(pretty(resp), language="json")
317
+ except Exception as e:
318
+ st.error(f"Erreur: {e}")
319
+
320
+ #with colB:
321
+ # st.write("À retenir")
322
+ # st.markdown("- Même endpoint, providers différents.\n- Le dropdown aide à éviter les IDs de modèles invalides.")
323
+
324
+ # ----------------------------
325
+ # 4) JSON extraction
326
+ # ----------------------------
327
+ with tabs[3]:
328
+ st.subheader("4) Extraction structurée en JSON (`response_format`)")
329
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
330
+
331
+ with colA:
332
+ json_model = model_selector(
333
+ "Modèle (extraction)",
334
+ model_ids,
335
+ preferred=["gpt-4o-mini", "gpt-4.1-mini"],
336
+ key="json_model",
337
+ fallback_value="gpt-4o-mini",
338
+ )
339
+ system = st.text_input("System prompt", value="You are a data extractor")
340
+ text = st.text_area(
341
+ "Texte à analyser",
342
+ value="Le 12 janvier 2023, Marie Curie a rencontré Albert Einstein à Paris.",
343
+ height=120,
344
+ )
345
+ temp = st.slider("temperature (extraction)", 0.0, 1.0, 0.1, 0.05)
346
+
347
+ if st.button("🧾 Extraire en JSON", type="primary"):
348
+ try:
349
+ resp = client.chat.completions.create(
350
+ model=json_model,
351
+ messages=[
352
+ {"role": "system", "content": system},
353
+ {
354
+ "role": "user",
355
+ "content": f"Extract the places, persons and dates from the following text and respond in JSON format: {text}",
356
+ },
357
+ ],
358
+ temperature=temp,
359
+ response_format={"type": "json_object"},
360
+ stream=False,
361
+ )
362
+ content = resp.choices[0].message.content
363
+ st.markdown("### JSON retourné")
364
+ try:
365
+ st.json(json.loads(content))
366
+ except Exception:
367
+ st.code(content, language="json")
368
+
369
+ if show_raw:
370
+ raw = resp.model_dump() if hasattr(resp, "model_dump") else resp
371
+ st.code(pretty(raw), language="json")
372
+ except Exception as e:
373
+ st.error(f"Erreur: {e}")
374
+ st.info("Certains providers/modèles ne supportent pas `response_format`.")
375
+
376
+ #with colB:
377
+ # st.write("À retenir")
378
+ # st.markdown("- Dropdown = modèle valide (quand `/models` répond).\n- `response_format` peut rester non supporté selon provider.")
379
+
380
+ # ----------------------------
381
+ # 5) OpenAI web_search (Responses API)
382
+ # ----------------------------
383
+ with tabs[4]:
384
+ st.subheader("5) OpenAI — Tool `web_search` via Responses API")
385
+ if not is_openai_provider(provider_name, base_url):
386
+ st.warning("Cet onglet est conçu pour OpenAI (Responses API + tool web_search).")
387
+ else:
388
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
389
+ with colA:
390
+ resp_model = model_selector(
391
+ "Modèle (Responses)",
392
+ model_ids,
393
+ preferred=["gpt-5", "gpt-4.1", "gpt-4.1-mini"],
394
+ key="resp_model",
395
+ fallback_value="gpt-5",
396
+ )
397
+ reasoning = st.selectbox("reasoning.effort", ["minimal", "low", "medium", "high"], index=3)
398
+ verbosity = st.selectbox("text.verbosity", ["low", "medium", "high"], index=1)
399
+ prompt = st.text_area(
400
+ "Prompt",
401
+ value=(
402
+ "Voici quels métadonnées bibliographiques :\n"
403
+ "- titre : L'enfant et la rivière\n"
404
+ "- auteur : Henri Bosco\n"
405
+ "- date : 2015\n\n"
406
+ "Fais une recherche dans le catalogue CCFr et trouve le nombre de localisations Sudoc."
407
+ ),
408
+ height=180,
409
+ )
410
+ if st.button("Lancer web_search", type="primary"):
411
+ try:
412
+ resp = client.responses.create(
413
+ model=resp_model,
414
+ input=prompt,
415
+ tools=[{"type": "web_search"}],
416
+ reasoning={"effort": reasoning},
417
+ text={"verbosity": verbosity},
418
+ stream=False,
419
+ )
420
+ st.markdown("### Réponse")
421
+ st.write(resp.output_text if getattr(resp, "output_text", None) else "(pas de output_text)")
422
+ if show_raw:
423
+ raw = resp.model_dump() if hasattr(resp, "model_dump") else resp
424
+ st.code(pretty(raw), language="json")
425
+ except Exception as e:
426
+ st.error(f"Erreur: {e}")
427
+
428
+ with colB:
429
+ st.write("À retenir")
430
+ st.markdown("- Ici le modèle **appelle un outil**.\n- Ce tab reste OpenAI-only dans cette démo.")
431
+
432
+ # ----------------------------
433
+ # 6) OpenAI image description
434
+ # ----------------------------
435
+ with tabs[5]:
436
+ st.subheader("6) OpenAI — Décrire une image (Responses API, multimodal)")
437
+ if not is_openai_provider(provider_name, base_url):
438
+ st.warning("Cet onglet est conçu pour OpenAI (Responses API + input_image).")
439
+ else:
440
+ colA, colB = st.columns([1, 1], vertical_alignment="top")
441
+ with colA:
442
+ img_model = model_selector(
443
+ "Modèle (multimodal)",
444
+ model_ids,
445
+ preferred=["gpt-4.1-mini", "gpt-4o-mini"],
446
+ key="img_model",
447
+ fallback_value="gpt-4.1-mini",
448
+ )
449
+ image_url = st.text_input(
450
+ "Image URL",
451
+ value="https://github.com/gegedenice/divers-files/raw/ca7c12ae2955a804b8a050c0f9ce77e2c0ef3aad/aude_edouard.jpg",
452
+ )
453
+ instruction = st.text_area("Instruction", value="Décris précisément cette image. Réponds en français.", height=120)
454
+
455
+ st.image(image_url, caption="Aperçu (si accessible)", use_container_width=True)
456
+
457
+ if st.button("Décrire", type="primary"):
458
+ try:
459
+ resp = client.responses.create(
460
+ model=img_model,
461
+ input=[
462
+ {
463
+ "role": "user",
464
+ "content": [
465
+ {"type": "input_text", "text": instruction},
466
+ {"type": "input_image", "image_url": image_url},
467
+ ],
468
+ }
469
+ ],
470
+ )
471
+ st.markdown("### Description")
472
+ st.write(resp.output_text)
473
+ if show_raw:
474
+ raw = resp.model_dump() if hasattr(resp, "model_dump") else resp
475
+ st.code(pretty(raw), language="json")
476
+ except Exception as e:
477
+ st.error(f"Erreur: {e}")
478
+
479
+ #with colB:
480
+ # st.write("À retenir")
481
+ # st.markdown("- Multimodal OpenAI.\n- Dropdown = modèle valide quand la liste est accessible.")
482
 
483
+ st.markdown("---")
484
+ st.caption("Astuce : si `/models` ne marche pas chez un provider, la fallback text_input permet quand même de tester.")