aiforge-esprit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiforge_esprit-0.1.0/PKG-INFO +96 -0
- aiforge_esprit-0.1.0/README.md +84 -0
- aiforge_esprit-0.1.0/aiforge/__init__.py +4 -0
- aiforge_esprit-0.1.0/aiforge/client.py +302 -0
- aiforge_esprit-0.1.0/aiforge_esprit.egg-info/PKG-INFO +96 -0
- aiforge_esprit-0.1.0/aiforge_esprit.egg-info/SOURCES.txt +9 -0
- aiforge_esprit-0.1.0/aiforge_esprit.egg-info/dependency_links.txt +1 -0
- aiforge_esprit-0.1.0/aiforge_esprit.egg-info/requires.txt +2 -0
- aiforge_esprit-0.1.0/aiforge_esprit.egg-info/top_level.txt +1 -0
- aiforge_esprit-0.1.0/pyproject.toml +20 -0
- aiforge_esprit-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiforge-esprit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK Python officiel d'AI Forge ESPRIT — un enrobage fin du SDK OpenAI.
|
|
5
|
+
Author: Direction IA - ESPRIT
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aiforge.esprit.tn
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
|
|
13
|
+
# aiforge
|
|
14
|
+
|
|
15
|
+
*The official Python SDK for AI Forge ESPRIT — a thin wrapper around the OpenAI SDK.*
|
|
16
|
+
|
|
17
|
+
`aiforge` enrobe le SDK OpenAI officiel : il ne réimplémente jamais le HTTP, il
|
|
18
|
+
ajoute juste quatre macros et la facturation en TND.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install aiforge-esprit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Démarrage rapide
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from aiforge import AIForge
|
|
30
|
+
|
|
31
|
+
af = AIForge() # clé depuis AIFORGE_API_KEY
|
|
32
|
+
print(af.chat("Explique les transformers en 2 phrases."))
|
|
33
|
+
print(af.code("Écris un quicksort en Python"))
|
|
34
|
+
print(af.balance())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
La clé vient de l'argument `api_key=` ou de la variable `AIFORGE_API_KEY`.
|
|
38
|
+
L'URL vient de `base_url=` ou de `AIFORGE_BASE_URL` (défaut :
|
|
39
|
+
`https://aiforge.esprit.tn/api/v1`).
|
|
40
|
+
|
|
41
|
+
## Les trois niveaux de contrôle du modèle
|
|
42
|
+
|
|
43
|
+
| Niveau | Exemple | Coût de routage |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| **Nom exact** | `chat(..., model="Qwen3.6-27B")` | aucun |
|
|
46
|
+
| **Alias statique** | `chat(..., model="coder")` | aucun |
|
|
47
|
+
| **`auto`** (défaut) | `chat(...)` | un appel classifieur |
|
|
48
|
+
|
|
49
|
+
En `auto`, un classifieur choisit le spécialiste selon la demande (et route vers
|
|
50
|
+
le modèle vision si une image est présente). Le champ `model` de la réponse porte
|
|
51
|
+
toujours le **modèle réellement utilisé**.
|
|
52
|
+
|
|
53
|
+
### Alias
|
|
54
|
+
|
|
55
|
+
| Alias | Modèle |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `fast` | SmolLM3-3B |
|
|
58
|
+
| `smart` | Qwen3.6-35B-A3B |
|
|
59
|
+
| `coder` | Qwen3.6-27B |
|
|
60
|
+
| `vision` | Gemma-3-4B-IT |
|
|
61
|
+
|
|
62
|
+
## Les 4 macros
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
af.chat(prompt, model="auto", system=None, max_tokens=2000, stream=False) # -> str (ou générateur si stream=True)
|
|
66
|
+
af.code(prompt) # chat model="coder", max_tokens=3000
|
|
67
|
+
af.extract(text, schema) # schema = classe pydantic ou dict json-schema -> instance validée
|
|
68
|
+
af.vision(image, prompt) # image = chemin local, bytes ou URL http(s)
|
|
69
|
+
af.balance() # -> {"balanceTND", "monthlyBudgetTND", "currency"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pour `extract`, le schéma garantit la forme ; le system prompt intégré impose la
|
|
73
|
+
concision (valeurs seules, sans phrases) ; pour des extractions complexes,
|
|
74
|
+
préférez `model="smart"`.
|
|
75
|
+
|
|
76
|
+
Avec `AIForge(verbose=True)`, chaque appel imprime une ligne :
|
|
77
|
+
`-> {modèle final} · {prompt}+{completion} tokens · {coût} TND`.
|
|
78
|
+
|
|
79
|
+
## Modèles à raisonnement (smart, coder)
|
|
80
|
+
|
|
81
|
+
`Qwen3.6-27B` et `Qwen3.6-35B-A3B` **réfléchissent avant de répondre** : la
|
|
82
|
+
réflexion arrive dans le champ `reasoning` de la réponse (nom vLLM 0.25). Un
|
|
83
|
+
`max_tokens` trop bas peut être entièrement consommé par la réflexion — le SDK
|
|
84
|
+
lève alors une erreur claire. **Utilisez `max_tokens >= 1500` pour `smart` et
|
|
85
|
+
`coder`.**
|
|
86
|
+
|
|
87
|
+
## Note de facturation
|
|
88
|
+
|
|
89
|
+
En mode `auto`, le coût du **classifieur** est imputé côté serveur (une ligne
|
|
90
|
+
d'usage distincte) mais **n'apparaît pas dans la réponse** — la ligne `verbose`
|
|
91
|
+
n'affiche donc que le coût du modèle final. Consultez la page Usage pour le
|
|
92
|
+
détail complet.
|
|
93
|
+
|
|
94
|
+
## Licence
|
|
95
|
+
|
|
96
|
+
MIT — Direction IA - ESPRIT.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# aiforge
|
|
2
|
+
|
|
3
|
+
*The official Python SDK for AI Forge ESPRIT — a thin wrapper around the OpenAI SDK.*
|
|
4
|
+
|
|
5
|
+
`aiforge` enrobe le SDK OpenAI officiel : il ne réimplémente jamais le HTTP, il
|
|
6
|
+
ajoute juste quatre macros et la facturation en TND.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install aiforge-esprit
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Démarrage rapide
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from aiforge import AIForge
|
|
18
|
+
|
|
19
|
+
af = AIForge() # clé depuis AIFORGE_API_KEY
|
|
20
|
+
print(af.chat("Explique les transformers en 2 phrases."))
|
|
21
|
+
print(af.code("Écris un quicksort en Python"))
|
|
22
|
+
print(af.balance())
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
La clé vient de l'argument `api_key=` ou de la variable `AIFORGE_API_KEY`.
|
|
26
|
+
L'URL vient de `base_url=` ou de `AIFORGE_BASE_URL` (défaut :
|
|
27
|
+
`https://aiforge.esprit.tn/api/v1`).
|
|
28
|
+
|
|
29
|
+
## Les trois niveaux de contrôle du modèle
|
|
30
|
+
|
|
31
|
+
| Niveau | Exemple | Coût de routage |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| **Nom exact** | `chat(..., model="Qwen3.6-27B")` | aucun |
|
|
34
|
+
| **Alias statique** | `chat(..., model="coder")` | aucun |
|
|
35
|
+
| **`auto`** (défaut) | `chat(...)` | un appel classifieur |
|
|
36
|
+
|
|
37
|
+
En `auto`, un classifieur choisit le spécialiste selon la demande (et route vers
|
|
38
|
+
le modèle vision si une image est présente). Le champ `model` de la réponse porte
|
|
39
|
+
toujours le **modèle réellement utilisé**.
|
|
40
|
+
|
|
41
|
+
### Alias
|
|
42
|
+
|
|
43
|
+
| Alias | Modèle |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `fast` | SmolLM3-3B |
|
|
46
|
+
| `smart` | Qwen3.6-35B-A3B |
|
|
47
|
+
| `coder` | Qwen3.6-27B |
|
|
48
|
+
| `vision` | Gemma-3-4B-IT |
|
|
49
|
+
|
|
50
|
+
## Les 4 macros
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
af.chat(prompt, model="auto", system=None, max_tokens=2000, stream=False) # -> str (ou générateur si stream=True)
|
|
54
|
+
af.code(prompt) # chat model="coder", max_tokens=3000
|
|
55
|
+
af.extract(text, schema) # schema = classe pydantic ou dict json-schema -> instance validée
|
|
56
|
+
af.vision(image, prompt) # image = chemin local, bytes ou URL http(s)
|
|
57
|
+
af.balance() # -> {"balanceTND", "monthlyBudgetTND", "currency"}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Pour `extract`, le schéma garantit la forme ; le system prompt intégré impose la
|
|
61
|
+
concision (valeurs seules, sans phrases) ; pour des extractions complexes,
|
|
62
|
+
préférez `model="smart"`.
|
|
63
|
+
|
|
64
|
+
Avec `AIForge(verbose=True)`, chaque appel imprime une ligne :
|
|
65
|
+
`-> {modèle final} · {prompt}+{completion} tokens · {coût} TND`.
|
|
66
|
+
|
|
67
|
+
## Modèles à raisonnement (smart, coder)
|
|
68
|
+
|
|
69
|
+
`Qwen3.6-27B` et `Qwen3.6-35B-A3B` **réfléchissent avant de répondre** : la
|
|
70
|
+
réflexion arrive dans le champ `reasoning` de la réponse (nom vLLM 0.25). Un
|
|
71
|
+
`max_tokens` trop bas peut être entièrement consommé par la réflexion — le SDK
|
|
72
|
+
lève alors une erreur claire. **Utilisez `max_tokens >= 1500` pour `smart` et
|
|
73
|
+
`coder`.**
|
|
74
|
+
|
|
75
|
+
## Note de facturation
|
|
76
|
+
|
|
77
|
+
En mode `auto`, le coût du **classifieur** est imputé côté serveur (une ligne
|
|
78
|
+
d'usage distincte) mais **n'apparaît pas dans la réponse** — la ligne `verbose`
|
|
79
|
+
n'affiche donc que le coût du modèle final. Consultez la page Usage pour le
|
|
80
|
+
détail complet.
|
|
81
|
+
|
|
82
|
+
## Licence
|
|
83
|
+
|
|
84
|
+
MIT — Direction IA - ESPRIT.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""AIForge — a thin wrapper around the official OpenAI SDK. HTTP is never
|
|
2
|
+
reimplemented: chat/completions go through `openai`, and the two auxiliary GETs
|
|
3
|
+
(balance, tarifs) reuse httpx, which openai already depends on."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, Dict, Iterator, Optional, Type, Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import openai
|
|
14
|
+
from pydantic import BaseModel, ValidationError
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://aiforge.esprit.tn/api/v1"
|
|
17
|
+
|
|
18
|
+
# Modèles virtuels côté serveur : "auto" (routeur dynamique) + alias statiques.
|
|
19
|
+
AUTO_MODEL = "auto"
|
|
20
|
+
ALIASES = {"fast", "smart", "coder", "vision"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AIForgeError(Exception):
|
|
24
|
+
"""Erreur AIForge, message en français prêt à afficher."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AIForge:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
verbose: bool = False,
|
|
33
|
+
timeout: float = 120,
|
|
34
|
+
) -> None:
|
|
35
|
+
api_key = api_key or os.environ.get("AIFORGE_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise AIForgeError(
|
|
38
|
+
"Clé API manquante. Passez api_key=... ou définissez la variable "
|
|
39
|
+
"d'environnement AIFORGE_API_KEY (votre clé « tf-... »)."
|
|
40
|
+
)
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.base_url = (base_url or os.environ.get("AIFORGE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
43
|
+
self.verbose = verbose
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.client = openai.OpenAI(base_url=self.base_url, api_key=self.api_key, timeout=timeout)
|
|
46
|
+
# Cache paresseux des tarifs {nom_modèle: (inputPriceTND, outputPriceTND)}.
|
|
47
|
+
self._prices: Optional[Dict[str, tuple]] = None
|
|
48
|
+
|
|
49
|
+
# ── Tarifs (cache paresseux via /api/public/models) ──────────────────────
|
|
50
|
+
|
|
51
|
+
def _prices_url(self) -> Optional[str]:
|
|
52
|
+
if "/api/v1" in self.base_url:
|
|
53
|
+
return self.base_url.split("/api/v1")[0] + "/api/public/models"
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def _load_prices(self) -> Dict[str, tuple]:
|
|
57
|
+
if self._prices is not None:
|
|
58
|
+
return self._prices
|
|
59
|
+
self._prices = {}
|
|
60
|
+
url = self._prices_url()
|
|
61
|
+
if not url:
|
|
62
|
+
return self._prices
|
|
63
|
+
try:
|
|
64
|
+
r = httpx.get(url, timeout=self.timeout)
|
|
65
|
+
r.raise_for_status()
|
|
66
|
+
for m in r.json().get("models", []):
|
|
67
|
+
if "inputPriceTND" in m and "outputPriceTND" in m:
|
|
68
|
+
self._prices[m["name"]] = (m["inputPriceTND"], m["outputPriceTND"])
|
|
69
|
+
except Exception:
|
|
70
|
+
# Tarifs indisponibles → coûts affichés en "?" plutôt qu'inventés.
|
|
71
|
+
pass
|
|
72
|
+
return self._prices
|
|
73
|
+
|
|
74
|
+
def _cost_tnd(self, model: str, prompt_tokens: int, completion_tokens: int) -> Optional[float]:
|
|
75
|
+
prices = self._load_prices()
|
|
76
|
+
if model not in prices:
|
|
77
|
+
return None
|
|
78
|
+
pin, pout = prices[model]
|
|
79
|
+
return prompt_tokens / 1_000_000 * pin + completion_tokens / 1_000_000 * pout
|
|
80
|
+
|
|
81
|
+
def _log(self, requested: str, final_model: str, usage: Any) -> None:
|
|
82
|
+
prompt = getattr(usage, "prompt_tokens", 0) if usage else 0
|
|
83
|
+
completion = getattr(usage, "completion_tokens", 0) if usage else 0
|
|
84
|
+
label = f"{requested} -> {final_model}" if (requested == AUTO_MODEL or requested in ALIASES) else final_model
|
|
85
|
+
cost = self._cost_tnd(final_model, prompt, completion)
|
|
86
|
+
cost_str = f"{cost:.3f} TND" if cost is not None else "? TND"
|
|
87
|
+
print(f"-> {label} · {prompt}+{completion} tokens · {cost_str}")
|
|
88
|
+
|
|
89
|
+
# ── Traduction des erreurs ───────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _translate_error(e: Exception) -> AIForgeError:
|
|
93
|
+
status = getattr(e, "status_code", None)
|
|
94
|
+
if status == 401:
|
|
95
|
+
return AIForgeError("Clé API invalide ou manquante (401).")
|
|
96
|
+
if status == 402:
|
|
97
|
+
return AIForgeError("Budget épuisé (402). Il est renouvelé le 1er du mois.")
|
|
98
|
+
if status == 403:
|
|
99
|
+
return AIForgeError("Modèle non autorisé pour votre organisation (403).")
|
|
100
|
+
if isinstance(e, openai.APIConnectionError):
|
|
101
|
+
return AIForgeError(f"Connexion impossible à AIForge : {e}")
|
|
102
|
+
return AIForgeError(str(e))
|
|
103
|
+
|
|
104
|
+
# ── Macros ────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def chat(
|
|
107
|
+
self,
|
|
108
|
+
prompt: str,
|
|
109
|
+
model: str = "auto",
|
|
110
|
+
system: Optional[str] = None,
|
|
111
|
+
max_tokens: int = 2000,
|
|
112
|
+
temperature: Optional[float] = None,
|
|
113
|
+
stream: bool = False,
|
|
114
|
+
**kwargs: Any,
|
|
115
|
+
) -> Union[str, Iterator[str]]:
|
|
116
|
+
messages = []
|
|
117
|
+
if system:
|
|
118
|
+
messages.append({"role": "system", "content": system})
|
|
119
|
+
messages.append({"role": "user", "content": prompt})
|
|
120
|
+
|
|
121
|
+
params: Dict[str, Any] = {"model": model, "messages": messages, "max_tokens": max_tokens}
|
|
122
|
+
if temperature is not None:
|
|
123
|
+
params["temperature"] = temperature
|
|
124
|
+
params.update(kwargs)
|
|
125
|
+
|
|
126
|
+
if stream:
|
|
127
|
+
return self._chat_stream(params, model)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
resp = self.client.chat.completions.create(**params)
|
|
131
|
+
except openai.APIStatusError as e:
|
|
132
|
+
raise self._translate_error(e)
|
|
133
|
+
except openai.APIConnectionError as e:
|
|
134
|
+
raise self._translate_error(e)
|
|
135
|
+
|
|
136
|
+
choice = resp.choices[0]
|
|
137
|
+
content = choice.message.content
|
|
138
|
+
# Piège reasoning : la réflexion a tout consommé, aucune réponse produite.
|
|
139
|
+
if choice.finish_reason == "length" and not content:
|
|
140
|
+
raise AIForgeError(
|
|
141
|
+
"max_tokens épuisé par la réflexion du modèle, sans réponse produite. "
|
|
142
|
+
"Augmentez max_tokens (>= 1500 pour smart/coder — ces modèles "
|
|
143
|
+
"réfléchissent avant de répondre)."
|
|
144
|
+
)
|
|
145
|
+
if self.verbose:
|
|
146
|
+
self._log(model, resp.model, resp.usage)
|
|
147
|
+
return content or ""
|
|
148
|
+
|
|
149
|
+
def _chat_stream(self, params: Dict[str, Any], requested: str) -> Iterator[str]:
|
|
150
|
+
params = dict(params, stream=True)
|
|
151
|
+
params.setdefault("stream_options", {"include_usage": True})
|
|
152
|
+
try:
|
|
153
|
+
stream = self.client.chat.completions.create(**params)
|
|
154
|
+
except openai.APIStatusError as e:
|
|
155
|
+
raise self._translate_error(e)
|
|
156
|
+
except openai.APIConnectionError as e:
|
|
157
|
+
raise self._translate_error(e)
|
|
158
|
+
|
|
159
|
+
usage = None
|
|
160
|
+
final_model = requested
|
|
161
|
+
for chunk in stream:
|
|
162
|
+
if getattr(chunk, "usage", None):
|
|
163
|
+
usage = chunk.usage
|
|
164
|
+
if getattr(chunk, "model", None):
|
|
165
|
+
final_model = chunk.model
|
|
166
|
+
if not chunk.choices:
|
|
167
|
+
continue
|
|
168
|
+
delta = chunk.choices[0].delta
|
|
169
|
+
content = getattr(delta, "content", None)
|
|
170
|
+
if content: # deltas reasoning (content None) filtrés
|
|
171
|
+
yield content
|
|
172
|
+
if self.verbose:
|
|
173
|
+
self._log(requested, final_model, usage)
|
|
174
|
+
|
|
175
|
+
def code(self, prompt: str, **kwargs: Any) -> Union[str, Iterator[str]]:
|
|
176
|
+
kwargs.setdefault("model", "coder")
|
|
177
|
+
kwargs.setdefault("max_tokens", 3000)
|
|
178
|
+
return self.chat(prompt, **kwargs)
|
|
179
|
+
|
|
180
|
+
def extract(
|
|
181
|
+
self,
|
|
182
|
+
text: str,
|
|
183
|
+
schema: Union[Type[BaseModel], Dict[str, Any]],
|
|
184
|
+
model: str = "fast",
|
|
185
|
+
max_tokens: int = 2000,
|
|
186
|
+
**kwargs: Any,
|
|
187
|
+
) -> Union[BaseModel, Dict[str, Any]]:
|
|
188
|
+
is_pydantic = isinstance(schema, type) and issubclass(schema, BaseModel)
|
|
189
|
+
if is_pydantic:
|
|
190
|
+
schema_dict = schema.model_json_schema()
|
|
191
|
+
name = schema.__name__
|
|
192
|
+
else:
|
|
193
|
+
schema_dict = schema
|
|
194
|
+
name = "extraction"
|
|
195
|
+
|
|
196
|
+
response_format = {
|
|
197
|
+
"type": "json_schema",
|
|
198
|
+
"json_schema": {"name": name, "schema": schema_dict},
|
|
199
|
+
}
|
|
200
|
+
# Le schéma contraint la structure, pas la concision : un system ferme
|
|
201
|
+
# impose des valeurs seules (sans lui, SmolLM remplit les champs de prose).
|
|
202
|
+
messages = []
|
|
203
|
+
caller_system = kwargs.pop("system", None)
|
|
204
|
+
if caller_system: # le system de l'appelant précède le nôtre
|
|
205
|
+
messages.append({"role": "system", "content": caller_system})
|
|
206
|
+
messages.append({
|
|
207
|
+
"role": "system",
|
|
208
|
+
"content": (
|
|
209
|
+
"Tu es un extracteur de données. Remplis chaque champ du schéma avec la "
|
|
210
|
+
"valeur exacte extraite, la plus courte possible. Aucune explication, "
|
|
211
|
+
"aucune phrase, aucun commentaire - uniquement les valeurs."
|
|
212
|
+
),
|
|
213
|
+
})
|
|
214
|
+
messages.append({"role": "user", "content": text})
|
|
215
|
+
try:
|
|
216
|
+
resp = self.client.chat.completions.create(
|
|
217
|
+
model=model,
|
|
218
|
+
messages=messages,
|
|
219
|
+
max_tokens=max_tokens,
|
|
220
|
+
response_format=response_format,
|
|
221
|
+
**kwargs,
|
|
222
|
+
)
|
|
223
|
+
except openai.APIStatusError as e:
|
|
224
|
+
raise self._translate_error(e)
|
|
225
|
+
except openai.APIConnectionError as e:
|
|
226
|
+
raise self._translate_error(e)
|
|
227
|
+
|
|
228
|
+
choice = resp.choices[0]
|
|
229
|
+
content = choice.message.content
|
|
230
|
+
if choice.finish_reason == "length" and not content:
|
|
231
|
+
raise AIForgeError(
|
|
232
|
+
"max_tokens épuisé avant de produire le JSON. Augmentez max_tokens."
|
|
233
|
+
)
|
|
234
|
+
if self.verbose:
|
|
235
|
+
self._log(model, resp.model, resp.usage)
|
|
236
|
+
|
|
237
|
+
if is_pydantic:
|
|
238
|
+
try:
|
|
239
|
+
return schema.model_validate_json(content or "")
|
|
240
|
+
except ValidationError as e:
|
|
241
|
+
raise AIForgeError(f"Le JSON renvoyé ne respecte pas le schéma : {e}")
|
|
242
|
+
try:
|
|
243
|
+
return json.loads(content or "")
|
|
244
|
+
except json.JSONDecodeError as e:
|
|
245
|
+
raise AIForgeError(f"Réponse non-JSON du modèle : {e}")
|
|
246
|
+
|
|
247
|
+
def vision(
|
|
248
|
+
self,
|
|
249
|
+
image: Union[str, bytes],
|
|
250
|
+
prompt: str,
|
|
251
|
+
model: str = "vision",
|
|
252
|
+
max_tokens: int = 2000,
|
|
253
|
+
) -> str:
|
|
254
|
+
url = self._image_to_url(image)
|
|
255
|
+
content = [
|
|
256
|
+
{"type": "text", "text": prompt},
|
|
257
|
+
{"type": "image_url", "image_url": {"url": url}},
|
|
258
|
+
]
|
|
259
|
+
try:
|
|
260
|
+
resp = self.client.chat.completions.create(
|
|
261
|
+
model=model,
|
|
262
|
+
messages=[{"role": "user", "content": content}],
|
|
263
|
+
max_tokens=max_tokens,
|
|
264
|
+
)
|
|
265
|
+
except openai.APIStatusError as e:
|
|
266
|
+
raise self._translate_error(e)
|
|
267
|
+
except openai.APIConnectionError as e:
|
|
268
|
+
raise self._translate_error(e)
|
|
269
|
+
|
|
270
|
+
choice = resp.choices[0]
|
|
271
|
+
out = choice.message.content
|
|
272
|
+
if choice.finish_reason == "length" and not out:
|
|
273
|
+
raise AIForgeError("max_tokens épuisé avant de produire une réponse. Augmentez max_tokens.")
|
|
274
|
+
if self.verbose:
|
|
275
|
+
self._log(model, resp.model, resp.usage)
|
|
276
|
+
return out or ""
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _image_to_url(image: Union[str, bytes]) -> str:
|
|
280
|
+
if isinstance(image, str) and (image.startswith("http://") or image.startswith("https://")):
|
|
281
|
+
return image
|
|
282
|
+
if isinstance(image, bytes):
|
|
283
|
+
data = image
|
|
284
|
+
mime = "image/jpeg"
|
|
285
|
+
else: # chemin local
|
|
286
|
+
with open(image, "rb") as f:
|
|
287
|
+
data = f.read()
|
|
288
|
+
mime = mimetypes.guess_type(image)[0] or "image/jpeg"
|
|
289
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
290
|
+
return f"data:{mime};base64,{b64}"
|
|
291
|
+
|
|
292
|
+
def balance(self) -> Dict[str, Any]:
|
|
293
|
+
url = self.base_url + "/balance"
|
|
294
|
+
try:
|
|
295
|
+
r = httpx.get(url, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=self.timeout)
|
|
296
|
+
except httpx.HTTPError as e:
|
|
297
|
+
raise AIForgeError(f"Connexion impossible à AIForge : {e}")
|
|
298
|
+
if r.status_code == 401:
|
|
299
|
+
raise AIForgeError("Clé API invalide ou manquante (401).")
|
|
300
|
+
if r.status_code >= 400:
|
|
301
|
+
raise AIForgeError(f"Erreur solde ({r.status_code}) : {r.text}")
|
|
302
|
+
return r.json()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiforge-esprit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK Python officiel d'AI Forge ESPRIT — un enrobage fin du SDK OpenAI.
|
|
5
|
+
Author: Direction IA - ESPRIT
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aiforge.esprit.tn
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
|
|
13
|
+
# aiforge
|
|
14
|
+
|
|
15
|
+
*The official Python SDK for AI Forge ESPRIT — a thin wrapper around the OpenAI SDK.*
|
|
16
|
+
|
|
17
|
+
`aiforge` enrobe le SDK OpenAI officiel : il ne réimplémente jamais le HTTP, il
|
|
18
|
+
ajoute juste quatre macros et la facturation en TND.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install aiforge-esprit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Démarrage rapide
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from aiforge import AIForge
|
|
30
|
+
|
|
31
|
+
af = AIForge() # clé depuis AIFORGE_API_KEY
|
|
32
|
+
print(af.chat("Explique les transformers en 2 phrases."))
|
|
33
|
+
print(af.code("Écris un quicksort en Python"))
|
|
34
|
+
print(af.balance())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
La clé vient de l'argument `api_key=` ou de la variable `AIFORGE_API_KEY`.
|
|
38
|
+
L'URL vient de `base_url=` ou de `AIFORGE_BASE_URL` (défaut :
|
|
39
|
+
`https://aiforge.esprit.tn/api/v1`).
|
|
40
|
+
|
|
41
|
+
## Les trois niveaux de contrôle du modèle
|
|
42
|
+
|
|
43
|
+
| Niveau | Exemple | Coût de routage |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| **Nom exact** | `chat(..., model="Qwen3.6-27B")` | aucun |
|
|
46
|
+
| **Alias statique** | `chat(..., model="coder")` | aucun |
|
|
47
|
+
| **`auto`** (défaut) | `chat(...)` | un appel classifieur |
|
|
48
|
+
|
|
49
|
+
En `auto`, un classifieur choisit le spécialiste selon la demande (et route vers
|
|
50
|
+
le modèle vision si une image est présente). Le champ `model` de la réponse porte
|
|
51
|
+
toujours le **modèle réellement utilisé**.
|
|
52
|
+
|
|
53
|
+
### Alias
|
|
54
|
+
|
|
55
|
+
| Alias | Modèle |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `fast` | SmolLM3-3B |
|
|
58
|
+
| `smart` | Qwen3.6-35B-A3B |
|
|
59
|
+
| `coder` | Qwen3.6-27B |
|
|
60
|
+
| `vision` | Gemma-3-4B-IT |
|
|
61
|
+
|
|
62
|
+
## Les 4 macros
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
af.chat(prompt, model="auto", system=None, max_tokens=2000, stream=False) # -> str (ou générateur si stream=True)
|
|
66
|
+
af.code(prompt) # chat model="coder", max_tokens=3000
|
|
67
|
+
af.extract(text, schema) # schema = classe pydantic ou dict json-schema -> instance validée
|
|
68
|
+
af.vision(image, prompt) # image = chemin local, bytes ou URL http(s)
|
|
69
|
+
af.balance() # -> {"balanceTND", "monthlyBudgetTND", "currency"}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pour `extract`, le schéma garantit la forme ; le system prompt intégré impose la
|
|
73
|
+
concision (valeurs seules, sans phrases) ; pour des extractions complexes,
|
|
74
|
+
préférez `model="smart"`.
|
|
75
|
+
|
|
76
|
+
Avec `AIForge(verbose=True)`, chaque appel imprime une ligne :
|
|
77
|
+
`-> {modèle final} · {prompt}+{completion} tokens · {coût} TND`.
|
|
78
|
+
|
|
79
|
+
## Modèles à raisonnement (smart, coder)
|
|
80
|
+
|
|
81
|
+
`Qwen3.6-27B` et `Qwen3.6-35B-A3B` **réfléchissent avant de répondre** : la
|
|
82
|
+
réflexion arrive dans le champ `reasoning` de la réponse (nom vLLM 0.25). Un
|
|
83
|
+
`max_tokens` trop bas peut être entièrement consommé par la réflexion — le SDK
|
|
84
|
+
lève alors une erreur claire. **Utilisez `max_tokens >= 1500` pour `smart` et
|
|
85
|
+
`coder`.**
|
|
86
|
+
|
|
87
|
+
## Note de facturation
|
|
88
|
+
|
|
89
|
+
En mode `auto`, le coût du **classifieur** est imputé côté serveur (une ligne
|
|
90
|
+
d'usage distincte) mais **n'apparaît pas dans la réponse** — la ligne `verbose`
|
|
91
|
+
n'affiche donc que le coût du modèle final. Consultez la page Usage pour le
|
|
92
|
+
détail complet.
|
|
93
|
+
|
|
94
|
+
## Licence
|
|
95
|
+
|
|
96
|
+
MIT — Direction IA - ESPRIT.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
aiforge/__init__.py
|
|
4
|
+
aiforge/client.py
|
|
5
|
+
aiforge_esprit.egg-info/PKG-INFO
|
|
6
|
+
aiforge_esprit.egg-info/SOURCES.txt
|
|
7
|
+
aiforge_esprit.egg-info/dependency_links.txt
|
|
8
|
+
aiforge_esprit.egg-info/requires.txt
|
|
9
|
+
aiforge_esprit.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiforge
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aiforge-esprit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK Python officiel d'AI Forge ESPRIT — un enrobage fin du SDK OpenAI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Direction IA - ESPRIT" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["openai>=1.40", "pydantic>=2"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://aiforge.esprit.tn"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["."]
|
|
20
|
+
include = ["aiforge*"]
|