Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import time | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import requests | |
| import streamlit as st | |
| from openai import OpenAI | |
| # ---------------------------- | |
| # Helpers | |
| # ---------------------------- | |
| def get_headers(api_key: str) -> Dict[str, str]: | |
| return {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} | |
| def pretty(obj: Any) -> str: | |
| return json.dumps(obj, ensure_ascii=False, indent=2) | |
| def safe_get_env(name: str, default: str = "") -> str: | |
| v = os.getenv(name) | |
| return v if v is not None else default | |
| def make_client(base_url: str, api_key: str) -> OpenAI: | |
| return OpenAI(base_url=base_url, api_key=api_key) | |
| def http_get_json(url: str, headers: Dict[str, str], timeout: int = 30) -> Dict[str, Any]: | |
| r = requests.get(url, headers=headers, timeout=timeout) | |
| r.raise_for_status() | |
| return r.json() | |
| def http_post_json(url: str, headers: Dict[str, str], payload: Dict[str, Any], timeout: int = 60) -> Dict[str, Any]: | |
| r = requests.post(url, headers=headers, json=payload, timeout=timeout) | |
| r.raise_for_status() | |
| return r.json() | |
| def provider_defaults() -> Dict[str, Dict[str, str]]: | |
| return { | |
| "OpenAI": { | |
| "base_url": "https://api.openai.com/v1", | |
| "env_key": "OPENAI_API_KEY", | |
| "notes": "Supporte Responses API (web_search, image) + Chat/Embeddings/Models.", | |
| }, | |
| "Groq": { | |
| "base_url": "https://api.groq.com/openai/v1", | |
| "env_key": "GROQ_API_KEY", | |
| "notes": "OpenAI-compatible pour Chat/Models/Embeddings (selon offre). Pas de web_search OpenAI.", | |
| }, | |
| "Ollama": { | |
| "base_url": "https://ollama.com/v1", | |
| "env_key": "OLLAMA_API_KEY", | |
| "notes": "OpenAI-compatible local. Models/Chat ok selon config. Embeddings selon modèles dispo.", | |
| }, | |
| "Albert (Etalab)": { | |
| "base_url": "https://albert.api.etalab.gouv.fr/v1", | |
| "env_key": "ALBERT_API_KEY", | |
| "notes": "OpenAI-compatible (selon endpoints activés). Pas de web_search OpenAI.", | |
| }, | |
| } | |
| def is_openai_provider(name: str, base_url: str) -> bool: | |
| return name.lower().startswith("openai") or "api.openai.com" in base_url | |
| def extract_model_ids(models_payload: Any) -> List[str]: | |
| """ | |
| Normalise la sortie /models (ou SDK models.list) en liste d'IDs. | |
| """ | |
| if models_payload is None: | |
| return [] | |
| if isinstance(models_payload, dict) and isinstance(models_payload.get("data"), list): | |
| ids = [] | |
| for m in models_payload["data"]: | |
| if isinstance(m, dict) and "id" in m: | |
| ids.append(m["id"]) | |
| return sorted(list(dict.fromkeys(ids))) | |
| # parfois payload déjà sous forme list | |
| if isinstance(models_payload, list): | |
| ids = [] | |
| for m in models_payload: | |
| if isinstance(m, dict) and "id" in m: | |
| ids.append(m["id"]) | |
| return sorted(list(dict.fromkeys(ids))) | |
| return [] | |
| def pick_default(ids: List[str], preferred: List[str]) -> int: | |
| """ | |
| Renvoie l'index d'un modèle préféré s'il existe, sinon 0. | |
| """ | |
| lower = {m.lower(): i for i, m in enumerate(ids)} | |
| for p in preferred: | |
| if p.lower() in lower: | |
| return lower[p.lower()] | |
| return 0 | |
| # ---------------------------- | |
| # Streamlit UI | |
| # ---------------------------- | |
| st.set_page_config(page_title="LLM API Playground (pédagogique)", layout="wide") | |
| st.title("🧪 Mini-app de requêtes API sur LLMs") | |
| st.caption("Choisir un provider → on charge `/models` → chaque onglet propose un menu déroulant des modèles.") | |
| with st.sidebar: | |
| st.header("⚙️ Configuration") | |
| defaults = provider_defaults() | |
| provider_name = st.selectbox("Fournisseur", list(defaults.keys()), index=0) | |
| base_url = st.text_input("Base URL", value=defaults[provider_name]["base_url"]) | |
| env_key = defaults[provider_name]["env_key"] | |
| st.write(f"Variable d’environnement attendue : `{env_key}`") | |
| api_key = st.text_input( | |
| "API key (optionnel si déjà dans l'env)", | |
| value="", | |
| type="password", | |
| help=f"Laisse vide si tu as déjà exporté {env_key} dans ton environnement.", | |
| ) | |
| if not api_key: | |
| api_key = safe_get_env(env_key, "") | |
| st.markdown("---") | |
| st.info(defaults[provider_name]["notes"]) | |
| st.markdown("---") | |
| show_raw = st.toggle("Afficher requête/réponse brutes", value=True) | |
| timeout_s = st.slider("Timeout HTTP (s)", 10, 120, 45, 5) | |
| if not api_key and provider_name != "Ollama": | |
| st.warning(f"Pas de clé détectée. Renseigne l’API key dans la sidebar ou exporte `{env_key}`.") | |
| client = make_client(base_url=base_url, api_key=api_key if api_key else "NO_KEY") | |
| # ---------------------------- | |
| # Load models once per provider/base_url/api_key (cache) | |
| # ---------------------------- | |
| def load_models_cached(base_url: str, api_key: str, timeout_s: int) -> Tuple[List[str], Optional[Dict[str, Any]], Optional[str]]: | |
| """ | |
| Retourne (ids, raw_payload, error_msg). | |
| On tente d'abord via HTTP GET /models (le plus universel). | |
| """ | |
| try: | |
| models_url = f"{base_url}/models" | |
| raw = http_get_json(models_url, headers=get_headers(api_key), timeout=timeout_s) | |
| ids = extract_model_ids(raw) | |
| if not ids: | |
| return [], raw, "Réponse /models reçue, mais aucun `id` exploitable n'a été trouvé." | |
| return ids, raw, None | |
| except Exception as e: | |
| return [], None, str(e) | |
| with st.spinner("Chargement des modèles du provider…"): | |
| model_ids, models_raw, models_err = load_models_cached(base_url, api_key, timeout_s) | |
| if models_err: | |
| st.sidebar.warning(f"Impossible de charger /models : {models_err}") | |
| elif model_ids: | |
| st.sidebar.success(f"Modèles chargés : {len(model_ids)}") | |
| else: | |
| st.sidebar.warning("Aucun modèle détecté (structure inattendue).") | |
| if show_raw and models_raw: | |
| with st.expander("Debug: réponse brute /models"): | |
| st.code(pretty(models_raw), language="json") | |
| def model_selector( | |
| label: str, | |
| ids: List[str], | |
| preferred: List[str], | |
| key: str, | |
| fallback_value: str, | |
| ) -> str: | |
| """ | |
| Retourne un model_id : | |
| - si ids dispo: selectbox | |
| - sinon: text_input fallback | |
| """ | |
| if ids: | |
| idx = pick_default(ids, preferred) | |
| return st.selectbox(label, ids, index=idx, key=key) | |
| return st.text_input(label, value=fallback_value, key=key + "_fallback") | |
| tabs = st.tabs( | |
| [ | |
| "1) Lister les modèles", | |
| "2) Embeddings", | |
| "3) Chat completion", | |
| "4) Extraction JSON", | |
| "5) OpenAI web_search", | |
| "6) OpenAI image → description", | |
| ] | |
| ) | |
| # ---------------------------- | |
| # 1) Models | |
| # ---------------------------- | |
| with tabs[0]: | |
| st.subheader("1) Lister les modèles disponibles (`GET /models`)") | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| if st.button("🔄 Recharger /models", type="primary"): | |
| load_models_cached.clear() | |
| st.rerun() | |
| st.write("IDs détectés :") | |
| if model_ids: | |
| st.dataframe({"model_id": model_ids}) | |
| else: | |
| st.info("Pas de liste exploitable. (Voir la réponse brute dans la sidebar si activée.)") | |
| with colB: | |
| st.write("À retenir") | |
| st.markdown( | |
| "- Le dropdown des autres onglets dépend de cette liste.\n" | |
| "- Si `/models` est bloqué par un provider, l’app retombe sur un champ texte." | |
| ) | |
| # ---------------------------- | |
| # 2) Embeddings | |
| # ---------------------------- | |
| with tabs[1]: | |
| st.subheader("2) Embeddings (`POST /embeddings`)") | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| emb_model = model_selector( | |
| "Modèle d'embeddings", | |
| model_ids, | |
| preferred=["text-embedding-3-small", "text-embedding-ada-002"], | |
| key="emb_model", | |
| fallback_value="text-embedding-3-small", | |
| ) | |
| text = st.text_area("Texte à embedder", value="This is a test", height=120) | |
| if st.button("🧬 Calculer embeddings", type="primary"): | |
| try: | |
| emb_url = f"{base_url}/embeddings" | |
| payload = {"model": emb_model, "input": text} | |
| t0 = time.time() | |
| resp = http_post_json(emb_url, headers=get_headers(api_key), payload=payload, timeout=timeout_s) | |
| dt = time.time() - t0 | |
| st.success(f"OK — {dt:.2f}s") | |
| try: | |
| emb = resp["data"][0]["embedding"] | |
| st.write(f"Dimension : **{len(emb)}**") | |
| except Exception: | |
| st.info("Impossible d'extraire `data[0].embedding` (structure différente).") | |
| usage = resp.get("usage", {}) | |
| if usage: | |
| st.write(f"Usage : `{pretty(usage)}`") | |
| if show_raw: | |
| st.code(pretty(resp), language="json") | |
| except Exception as e: | |
| st.error(f"Erreur: {e}") | |
| # ---------------------------- | |
| # 3) Chat completion | |
| # ---------------------------- | |
| with tabs[2]: | |
| st.subheader("3) Chat completion (`POST /chat/completions`)") | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| chat_model = model_selector( | |
| "Modèle (chat)", | |
| model_ids, | |
| preferred=["gpt-4o-mini", "gpt-4.1-mini", "gpt-4", "llama", "mixtral"], | |
| key="chat_model", | |
| fallback_value="gpt-4o-mini", | |
| ) | |
| prompt = st.text_area( | |
| "Prompt utilisateur", | |
| value="Explique la différence entre modèles de langage encodeur et modèle de langage décodeur.", | |
| height=140, | |
| ) | |
| max_tokens = st.slider("max_completion_tokens", 32, 512, 200, 16) | |
| temperature = st.slider("temperature", 0.0, 1.5, 0.3, 0.1) | |
| if st.button("💬 Générer", type="primary"): | |
| try: | |
| completion_url = f"{base_url}/chat/completions" | |
| payload = { | |
| "model": chat_model, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "max_completion_tokens": max_tokens, | |
| "temperature": temperature, | |
| "stream": False, | |
| } | |
| t0 = time.time() | |
| resp = http_post_json(completion_url, headers=get_headers(api_key), payload=payload, timeout=timeout_s) | |
| dt = time.time() - t0 | |
| st.success(f"OK — {dt:.2f}s") | |
| try: | |
| content = resp["choices"][0]["message"]["content"] | |
| st.markdown("### Réponse") | |
| st.write(content) | |
| except Exception: | |
| st.info("Structure de réponse inattendue. Regarde la réponse brute.") | |
| if show_raw: | |
| st.code(pretty(resp), language="json") | |
| except Exception as e: | |
| st.error(f"Erreur: {e}") | |
| # ---------------------------- | |
| # 4) JSON extraction | |
| # ---------------------------- | |
| with tabs[3]: | |
| st.subheader("4) Extraction structurée en JSON (`response_format`)") | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| json_model = model_selector( | |
| "Modèle (extraction)", | |
| model_ids, | |
| preferred=["gpt-4o-mini", "gpt-4.1-mini"], | |
| key="json_model", | |
| fallback_value="gpt-4o-mini", | |
| ) | |
| system = st.text_input("System prompt", value="You are a data extractor") | |
| text = st.text_area( | |
| "Texte à analyser", | |
| value="Le 12 janvier 2023, Marie Curie a rencontré Albert Einstein à Paris.", | |
| height=120, | |
| ) | |
| temp = st.slider("temperature (extraction)", 0.0, 1.0, 0.1, 0.05) | |
| if st.button("🧾 Extraire en JSON", type="primary"): | |
| try: | |
| resp = client.chat.completions.create( | |
| model=json_model, | |
| messages=[ | |
| {"role": "system", "content": system}, | |
| { | |
| "role": "user", | |
| "content": f"Extract the places, persons and dates from the following text and respond in JSON format: {text}", | |
| }, | |
| ], | |
| temperature=temp, | |
| response_format={"type": "json_object"}, | |
| stream=False, | |
| ) | |
| content = resp.choices[0].message.content | |
| st.markdown("### JSON retourné") | |
| try: | |
| st.json(json.loads(content)) | |
| except Exception: | |
| st.code(content, language="json") | |
| if show_raw: | |
| raw = resp.model_dump() if hasattr(resp, "model_dump") else resp | |
| st.code(pretty(raw), language="json") | |
| except Exception as e: | |
| st.error(f"Erreur: {e}") | |
| st.info("Certains providers/modèles ne supportent pas `response_format`.") | |
| # ---------------------------- | |
| # 5) OpenAI web_search (Responses API) | |
| # ---------------------------- | |
| with tabs[4]: | |
| st.subheader("5) OpenAI — Tool `web_search` via Responses API") | |
| if not is_openai_provider(provider_name, base_url): | |
| st.warning("Cet onglet est conçu pour OpenAI (Responses API + tool web_search).") | |
| else: | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| resp_model = model_selector( | |
| "Modèle (Responses)", | |
| model_ids, | |
| preferred=["gpt-5", "gpt-4.1", "gpt-4.1-mini"], | |
| key="resp_model", | |
| fallback_value="gpt-5", | |
| ) | |
| reasoning = st.selectbox("reasoning.effort", ["minimal", "low", "medium", "high"], index=3) | |
| verbosity = st.selectbox("text.verbosity", ["low", "medium", "high"], index=1) | |
| prompt = st.text_area( | |
| "Prompt", | |
| value=( | |
| "Trouve le ppn de l'autorité personne Albert Camus dans le référentiel français Idref." | |
| ), | |
| height=180, | |
| ) | |
| if st.button("Lancer web_search", type="primary"): | |
| try: | |
| resp = client.responses.create( | |
| model=resp_model, | |
| input=prompt, | |
| tools=[{"type": "web_search"}], | |
| reasoning={"effort": reasoning}, | |
| text={"verbosity": verbosity}, | |
| stream=False, | |
| ) | |
| st.markdown("### Réponse") | |
| st.write(resp.output_text if getattr(resp, "output_text", None) else "(pas de output_text)") | |
| if show_raw: | |
| raw = resp.model_dump() if hasattr(resp, "model_dump") else resp | |
| st.code(pretty(raw), language="json") | |
| except Exception as e: | |
| st.error(f"Erreur: {e}") | |
| with colB: | |
| st.write("À retenir") | |
| st.markdown("- Ici le modèle **appelle un outil**.\n- Ce tab reste OpenAI-only dans cette démo.") | |
| # ---------------------------- | |
| # 6) OpenAI image description | |
| # ---------------------------- | |
| with tabs[5]: | |
| st.subheader("6) OpenAI — Décrire une image (Responses API, multimodal)") | |
| if not is_openai_provider(provider_name, base_url): | |
| st.warning("Cet onglet est conçu pour OpenAI (Responses API + input_image).") | |
| else: | |
| colA, colB = st.columns([1, 1], vertical_alignment="top") | |
| with colA: | |
| img_model = model_selector( | |
| "Modèle (multimodal)", | |
| model_ids, | |
| preferred=["gpt-4.1-mini", "gpt-4o-mini"], | |
| key="img_model", | |
| fallback_value="gpt-4.1-mini", | |
| ) | |
| image_url = st.text_input( | |
| "Image URL", | |
| value="https://github.com/gegedenice/divers-files/raw/ca7c12ae2955a804b8a050c0f9ce77e2c0ef3aad/aude_edouard.jpg", | |
| ) | |
| instruction = st.text_area("Instruction", value="Décris précisément cette image. Réponds en français.", height=120) | |
| st.image(image_url, caption="Aperçu (si accessible)", use_container_width=True) | |
| if st.button("Décrire", type="primary"): | |
| try: | |
| resp = client.responses.create( | |
| model=img_model, | |
| input=[ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "input_text", "text": instruction}, | |
| {"type": "input_image", "image_url": image_url}, | |
| ], | |
| } | |
| ], | |
| ) | |
| st.markdown("### Description") | |
| st.write(resp.output_text) | |
| if show_raw: | |
| raw = resp.model_dump() if hasattr(resp, "model_dump") else resp | |
| st.code(pretty(raw), language="json") | |
| except Exception as e: | |
| st.error(f"Erreur: {e}") | |
| st.markdown("---") | |
| st.caption("Astuce : si `/models` ne marche pas chez un provider, la fallback text_input permet quand même de tester.") |