learnstat 0.2.0__py3-none-any.whl
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.
- learnstat/__init__.py +80 -0
- learnstat/advisor.py +502 -0
- learnstat/cli.py +338 -0
- learnstat/commands.py +317 -0
- learnstat/config.py +223 -0
- learnstat/ipython_ext.py +104 -0
- learnstat/knowledge.py +233 -0
- learnstat/llm.py +280 -0
- learnstat/memory.py +193 -0
- learnstat/profile.py +344 -0
- learnstat/providers.py +78 -0
- learnstat/recovery.py +1021 -0
- learnstat/safety.py +100 -0
- learnstat/session.py +307 -0
- learnstat/setup.py +308 -0
- learnstat/suggestions.py +64 -0
- learnstat-0.2.0.dist-info/METADATA +199 -0
- learnstat-0.2.0.dist-info/RECORD +22 -0
- learnstat-0.2.0.dist-info/WHEEL +5 -0
- learnstat-0.2.0.dist-info/entry_points.txt +2 -0
- learnstat-0.2.0.dist-info/licenses/LICENSE +21 -0
- learnstat-0.2.0.dist-info/top_level.txt +1 -0
learnstat/profile.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Rule-based dataset profiling.
|
|
2
|
+
|
|
3
|
+
Everything here works without AI. The profile is the analytical context that
|
|
4
|
+
the advisor, the recovery engine and the optional AI backend all share.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
# Column-name hints used to infer what a column probably means. These are
|
|
20
|
+
# guesses and are always labelled as such in the Markdown memory.
|
|
21
|
+
_MEANING_HINTS: list[tuple[str, str]] = [
|
|
22
|
+
(r"(^|_)id$|^id_|uuid|identifier", "identifier (row key, not a measurement)"),
|
|
23
|
+
(r"date|time|timestamp|_at$|year|month|day", "date / time"),
|
|
24
|
+
(r"age", "age"),
|
|
25
|
+
(r"price|cost|amount|revenue|sales|income|salary|fare|spend", "monetary amount"),
|
|
26
|
+
(r"gender|sex", "demographic category"),
|
|
27
|
+
(r"country|city|state|region|zip|postcode|location", "geographic location"),
|
|
28
|
+
(r"score|rating|grade", "score / rating"),
|
|
29
|
+
(r"count|qty|quantity|num_|number", "count"),
|
|
30
|
+
(r"weight|height|length|width|size|bmi", "physical measurement"),
|
|
31
|
+
(r"lat(itude)?$|lon(gitude)?$|lng$", "geographic coordinate"),
|
|
32
|
+
(r"pct|percent|rate|ratio", "rate / proportion"),
|
|
33
|
+
(r"^is_|^has_|flag|survived|churn|target|label|outcome", "binary outcome / flag"),
|
|
34
|
+
(r"name|title|description|comment|text|notes", "free text / label"),
|
|
35
|
+
(r"group|category|type|class|segment|treatment", "grouping variable"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ColumnProfile:
|
|
41
|
+
name: str
|
|
42
|
+
dtype: str
|
|
43
|
+
kind: str # numeric | categorical | boolean | datetime | text | identifier
|
|
44
|
+
n_missing: int
|
|
45
|
+
pct_missing: float
|
|
46
|
+
n_unique: int
|
|
47
|
+
summary: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
notes: list[str] = field(default_factory=list)
|
|
49
|
+
inferred_meaning: str | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def skew(self) -> float | None:
|
|
53
|
+
return self.summary.get("skew")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DatasetProfile:
|
|
58
|
+
name: str
|
|
59
|
+
source: str | None
|
|
60
|
+
n_rows: int
|
|
61
|
+
n_cols: int
|
|
62
|
+
fingerprint: str
|
|
63
|
+
columns: dict[str, ColumnProfile]
|
|
64
|
+
correlations: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
issues: list[str] = field(default_factory=list)
|
|
66
|
+
suggested_analyses: list[str] = field(default_factory=list)
|
|
67
|
+
n_duplicate_rows: int = 0
|
|
68
|
+
n_complete_rows: int = 0
|
|
69
|
+
created_at: str = ""
|
|
70
|
+
|
|
71
|
+
# -- convenience accessors -------------------------------------------
|
|
72
|
+
def columns_of_kind(self, *kinds: str) -> list[str]:
|
|
73
|
+
return [c.name for c in self.columns.values() if c.kind in kinds]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def numeric(self) -> list[str]:
|
|
77
|
+
return self.columns_of_kind("numeric")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def categorical(self) -> list[str]:
|
|
81
|
+
return self.columns_of_kind("categorical", "boolean")
|
|
82
|
+
|
|
83
|
+
def get(self, column: str) -> ColumnProfile | None:
|
|
84
|
+
return self.columns.get(column)
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
d = asdict(self)
|
|
88
|
+
d["columns"] = {k: asdict(v) for k, v in self.columns.items()}
|
|
89
|
+
return d
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, d: dict[str, Any]) -> "DatasetProfile":
|
|
93
|
+
d = dict(d)
|
|
94
|
+
d["columns"] = {k: ColumnProfile(**v) for k, v in d["columns"].items()}
|
|
95
|
+
return cls(**d)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fingerprint(df: pd.DataFrame) -> str:
|
|
102
|
+
"""A cheap content hash used to decide whether the profile is stale."""
|
|
103
|
+
h = hashlib.sha256()
|
|
104
|
+
h.update(str(df.shape).encode())
|
|
105
|
+
h.update("|".join(f"{c}:{t}" for c, t in df.dtypes.astype(str).items()).encode())
|
|
106
|
+
try:
|
|
107
|
+
h.update(str(int(pd.util.hash_pandas_object(df, index=True).sum())).encode())
|
|
108
|
+
except (TypeError, ValueError): # unhashable cells, e.g. lists
|
|
109
|
+
h.update(df.head(1000).to_csv().encode())
|
|
110
|
+
return h.hexdigest()[:16]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _infer_meaning(name: str) -> str | None:
|
|
114
|
+
lowered = name.lower()
|
|
115
|
+
for pattern, meaning in _MEANING_HINTS:
|
|
116
|
+
if re.search(pattern, lowered):
|
|
117
|
+
return meaning
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _to_float(x: Any) -> float | None:
|
|
122
|
+
try:
|
|
123
|
+
if x is None or pd.isna(x):
|
|
124
|
+
return None
|
|
125
|
+
return round(float(x), 4)
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _looks_numeric_text(s: pd.Series) -> float:
|
|
131
|
+
sample = s.dropna().astype(str).head(500)
|
|
132
|
+
if sample.empty:
|
|
133
|
+
return 0.0
|
|
134
|
+
cleaned = sample.str.replace(r"[,\s$%€£]", "", regex=True)
|
|
135
|
+
return float(pd.to_numeric(cleaned, errors="coerce").notna().mean())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _looks_datetime_text(s: pd.Series) -> float:
|
|
139
|
+
sample = s.dropna().astype(str).head(200)
|
|
140
|
+
if sample.empty or not sample.str.contains(r"\d").all():
|
|
141
|
+
return 0.0
|
|
142
|
+
with warnings.catch_warnings():
|
|
143
|
+
warnings.simplefilter("ignore")
|
|
144
|
+
try:
|
|
145
|
+
parsed = pd.to_datetime(sample, errors="coerce", format="mixed")
|
|
146
|
+
except (TypeError, ValueError):
|
|
147
|
+
return 0.0
|
|
148
|
+
return float(parsed.notna().mean())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _profile_column(s: pd.Series, n_rows: int) -> ColumnProfile:
|
|
152
|
+
name = str(s.name)
|
|
153
|
+
n_missing = int(s.isna().sum())
|
|
154
|
+
non_null = s.dropna()
|
|
155
|
+
try:
|
|
156
|
+
n_unique = int(non_null.nunique())
|
|
157
|
+
except TypeError:
|
|
158
|
+
n_unique = int(non_null.astype(str).nunique())
|
|
159
|
+
col = ColumnProfile(
|
|
160
|
+
name=name,
|
|
161
|
+
dtype=str(s.dtype),
|
|
162
|
+
kind="text",
|
|
163
|
+
n_missing=n_missing,
|
|
164
|
+
pct_missing=round(100 * n_missing / n_rows, 2) if n_rows else 0.0,
|
|
165
|
+
n_unique=n_unique,
|
|
166
|
+
inferred_meaning=_infer_meaning(name),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if pd.api.types.is_bool_dtype(s):
|
|
170
|
+
col.kind = "boolean"
|
|
171
|
+
col.summary = {"true_share": _to_float(non_null.mean())}
|
|
172
|
+
elif pd.api.types.is_datetime64_any_dtype(s):
|
|
173
|
+
col.kind = "datetime"
|
|
174
|
+
col.summary = {"min": str(non_null.min()), "max": str(non_null.max())}
|
|
175
|
+
elif pd.api.types.is_numeric_dtype(s):
|
|
176
|
+
col.kind = "numeric"
|
|
177
|
+
x = non_null.astype(float)
|
|
178
|
+
q1, q3 = (x.quantile(0.25), x.quantile(0.75)) if len(x) else (np.nan, np.nan)
|
|
179
|
+
iqr = q3 - q1
|
|
180
|
+
n_outliers = int(((x < q1 - 1.5 * iqr) | (x > q3 + 1.5 * iqr)).sum()) if len(x) else 0
|
|
181
|
+
col.summary = {
|
|
182
|
+
"mean": _to_float(x.mean()),
|
|
183
|
+
"std": _to_float(x.std()),
|
|
184
|
+
"min": _to_float(x.min()),
|
|
185
|
+
"median": _to_float(x.median()),
|
|
186
|
+
"max": _to_float(x.max()),
|
|
187
|
+
"skew": _to_float(x.skew()) if len(x) > 2 else None,
|
|
188
|
+
"n_outliers_iqr": n_outliers,
|
|
189
|
+
}
|
|
190
|
+
name_l = name.lower()
|
|
191
|
+
if n_unique == n_rows and n_rows > 20 and re.search(r"(^|_)id$|^id_|index", name_l):
|
|
192
|
+
col.kind = "identifier"
|
|
193
|
+
col.notes.append("Unique integer per row - treat as an identifier, not a measurement.")
|
|
194
|
+
elif n_unique == 2:
|
|
195
|
+
col.notes.append("Only two distinct values - probably a binary flag coded as numbers.")
|
|
196
|
+
elif 2 < n_unique <= 10 and pd.api.types.is_integer_dtype(s):
|
|
197
|
+
col.notes.append(
|
|
198
|
+
f"Integer with only {n_unique} distinct values - may be an ordinal or coded category."
|
|
199
|
+
)
|
|
200
|
+
skew = col.summary["skew"]
|
|
201
|
+
if skew is not None and abs(skew) >= 1:
|
|
202
|
+
direction = "right" if skew > 0 else "left"
|
|
203
|
+
col.notes.append(
|
|
204
|
+
f"Strongly {direction}-skewed (skew={skew}); the median and IQR describe the centre "
|
|
205
|
+
"and spread better than the mean and SD."
|
|
206
|
+
)
|
|
207
|
+
if n_outliers:
|
|
208
|
+
col.notes.append(f"{n_outliers} values outside 1.5xIQR (potential outliers).")
|
|
209
|
+
else:
|
|
210
|
+
# object / string / categorical dtype
|
|
211
|
+
if isinstance(s.dtype, pd.CategoricalDtype):
|
|
212
|
+
col.kind = "categorical"
|
|
213
|
+
elif n_unique == n_rows and n_rows > 20:
|
|
214
|
+
col.kind = "identifier" if _infer_meaning(name) and "identifier" in _infer_meaning(name) else "text"
|
|
215
|
+
elif n_unique <= max(20, int(0.05 * n_rows)) or n_unique / max(len(non_null), 1) <= 0.5:
|
|
216
|
+
col.kind = "categorical"
|
|
217
|
+
else:
|
|
218
|
+
col.kind = "text"
|
|
219
|
+
|
|
220
|
+
top = non_null.astype(str).value_counts().head(5)
|
|
221
|
+
col.summary = {"top_values": {str(k): int(v) for k, v in top.items()}}
|
|
222
|
+
|
|
223
|
+
if _looks_numeric_text(s) > 0.95 and n_unique > 10:
|
|
224
|
+
col.notes.append(
|
|
225
|
+
"Stored as text but looks numeric - convert with pd.to_numeric(..., errors='coerce') "
|
|
226
|
+
"after checking which values fail to parse."
|
|
227
|
+
)
|
|
228
|
+
elif _looks_datetime_text(s) > 0.95:
|
|
229
|
+
col.notes.append("Stored as text but looks like dates - consider pd.to_datetime().")
|
|
230
|
+
|
|
231
|
+
if col.kind == "categorical":
|
|
232
|
+
as_str = non_null.astype(str)
|
|
233
|
+
normalised = as_str.str.strip().str.lower()
|
|
234
|
+
if normalised.nunique() < as_str.nunique():
|
|
235
|
+
col.notes.append(
|
|
236
|
+
"Inconsistent labels: some categories differ only by case or whitespace "
|
|
237
|
+
"(e.g. 'Male' vs 'male ')."
|
|
238
|
+
)
|
|
239
|
+
if n_unique > 15:
|
|
240
|
+
col.notes.append(f"High cardinality ({n_unique} categories) - pie charts will be unreadable.")
|
|
241
|
+
counts = as_str.value_counts()
|
|
242
|
+
rare = int((counts < 5).sum())
|
|
243
|
+
if rare and n_unique > 2:
|
|
244
|
+
col.notes.append(f"{rare} categories have fewer than 5 rows (sparse for chi-square tests).")
|
|
245
|
+
|
|
246
|
+
if n_unique <= 1 and n_rows > 1:
|
|
247
|
+
col.notes.append("Constant column - carries no information.")
|
|
248
|
+
if col.pct_missing >= 50:
|
|
249
|
+
col.notes.append(f"{col.pct_missing}% missing - analyses using this column will lose most rows.")
|
|
250
|
+
elif n_missing:
|
|
251
|
+
col.notes.append(f"{n_missing} missing values ({col.pct_missing}%).")
|
|
252
|
+
return col
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _suggest_analyses(p: DatasetProfile) -> list[str]:
|
|
256
|
+
out: list[str] = []
|
|
257
|
+
num = p.numeric
|
|
258
|
+
cat = [c for c in p.categorical if p.columns[c].n_unique <= 30]
|
|
259
|
+
dt = p.columns_of_kind("datetime")
|
|
260
|
+
|
|
261
|
+
for c in num[:6]:
|
|
262
|
+
col = p.columns[c]
|
|
263
|
+
if col.skew is not None and abs(col.skew) >= 1:
|
|
264
|
+
out.append(f"`{c}`: histogram + boxplot; report median/IQR (skewed). Consider np.log1p for modelling.")
|
|
265
|
+
else:
|
|
266
|
+
out.append(f"`{c}`: histogram (`df['{c}'].plot.hist()`) and describe() for centre/spread.")
|
|
267
|
+
for corr in p.correlations[:4]:
|
|
268
|
+
out.append(
|
|
269
|
+
f"`{corr['a']}` vs `{corr['b']}`: scatter plot (r={corr['r']}); check linearity before Pearson, "
|
|
270
|
+
"or use Spearman if skewed."
|
|
271
|
+
)
|
|
272
|
+
for c in cat[:3]:
|
|
273
|
+
k = p.columns[c].n_unique
|
|
274
|
+
chart = "bar chart" if k > 5 else "bar chart (a pie only works for a few parts of one whole)"
|
|
275
|
+
out.append(f"`{c}`: `value_counts()` + {chart}.")
|
|
276
|
+
for n in num[:2]:
|
|
277
|
+
if k == 2:
|
|
278
|
+
out.append(f"`{n}` by `{c}`: grouped boxplot; Welch's t-test (or Mann-Whitney U if skewed).")
|
|
279
|
+
elif 2 < k <= 10:
|
|
280
|
+
out.append(f"`{n}` by `{c}`: grouped boxplot; one-way ANOVA (or Kruskal-Wallis if skewed).")
|
|
281
|
+
if len(cat) >= 2:
|
|
282
|
+
out.append(f"`{cat[0]}` x `{cat[1]}`: `pd.crosstab` + chi-square test of independence.")
|
|
283
|
+
for d in dt[:1]:
|
|
284
|
+
for n in num[:2]:
|
|
285
|
+
out.append(f"`{n}` over `{d}`: line plot after sorting by date / resampling.")
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def profile_dataframe(df: pd.DataFrame, name: str, source: str | None = None) -> DatasetProfile:
|
|
290
|
+
n_rows, n_cols = df.shape
|
|
291
|
+
columns = {str(c): _profile_column(df[c], n_rows) for c in df.columns}
|
|
292
|
+
|
|
293
|
+
correlations: list[dict[str, Any]] = []
|
|
294
|
+
num_cols = [c for c, p in columns.items() if p.kind == "numeric" and p.n_unique > 2]
|
|
295
|
+
if 2 <= len(num_cols) <= 60:
|
|
296
|
+
corr = df[num_cols].corr(numeric_only=True)
|
|
297
|
+
seen = set()
|
|
298
|
+
for a in num_cols:
|
|
299
|
+
for b in num_cols:
|
|
300
|
+
if a >= b or (a, b) in seen:
|
|
301
|
+
continue
|
|
302
|
+
seen.add((a, b))
|
|
303
|
+
r = corr.loc[a, b]
|
|
304
|
+
if pd.notna(r) and abs(r) >= 0.5:
|
|
305
|
+
correlations.append({"a": a, "b": b, "r": round(float(r), 3)})
|
|
306
|
+
correlations.sort(key=lambda d: -abs(d["r"]))
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
n_dup = int(df.duplicated().sum())
|
|
310
|
+
except TypeError:
|
|
311
|
+
n_dup = 0
|
|
312
|
+
n_complete = int(df.dropna().shape[0])
|
|
313
|
+
|
|
314
|
+
profile = DatasetProfile(
|
|
315
|
+
name=name,
|
|
316
|
+
source=source,
|
|
317
|
+
n_rows=n_rows,
|
|
318
|
+
n_cols=n_cols,
|
|
319
|
+
fingerprint=fingerprint(df),
|
|
320
|
+
columns=columns,
|
|
321
|
+
correlations=correlations,
|
|
322
|
+
n_duplicate_rows=n_dup,
|
|
323
|
+
n_complete_rows=n_complete,
|
|
324
|
+
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
issues: list[str] = []
|
|
328
|
+
if n_dup:
|
|
329
|
+
issues.append(f"{n_dup} fully duplicated rows.")
|
|
330
|
+
if n_complete < n_rows:
|
|
331
|
+
dropped = n_rows - n_complete
|
|
332
|
+
issues.append(
|
|
333
|
+
f"`df.dropna()` would drop {dropped} of {n_rows} rows ({100 * dropped / max(n_rows, 1):.1f}%)."
|
|
334
|
+
)
|
|
335
|
+
for c in columns.values():
|
|
336
|
+
for note in c.notes:
|
|
337
|
+
if any(w in note for w in ("missing", "Inconsistent", "Stored as text", "Constant", "outliers")):
|
|
338
|
+
issues.append(f"`{c.name}`: {note}")
|
|
339
|
+
for corr in correlations:
|
|
340
|
+
if abs(corr["r"]) >= 0.95:
|
|
341
|
+
issues.append(f"`{corr['a']}` and `{corr['b']}` are nearly collinear (r={corr['r']}).")
|
|
342
|
+
profile.issues = issues
|
|
343
|
+
profile.suggested_analyses = _suggest_analyses(profile)
|
|
344
|
+
return profile
|
learnstat/providers.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Presets for the AI companies learnstat can talk to.
|
|
2
|
+
|
|
3
|
+
Every preset is an OpenAI-compatible ``/chat/completions`` endpoint, so one small
|
|
4
|
+
client covers them all. ``custom`` lets the user type any other endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Provider:
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
base_url: str
|
|
17
|
+
needs_key: bool = True
|
|
18
|
+
key_url: str = "" # where the user creates an API key
|
|
19
|
+
models: tuple[str, ...] = () # suggestions, used only when the live model list can't be fetched
|
|
20
|
+
note: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
PROVIDERS: tuple[Provider, ...] = (
|
|
24
|
+
Provider("openai", "OpenAI", "https://api.openai.com/v1",
|
|
25
|
+
key_url="https://platform.openai.com/api-keys",
|
|
26
|
+
models=("gpt-4o-mini", "gpt-4o")),
|
|
27
|
+
Provider("anthropic", "Anthropic (Claude)", "https://api.anthropic.com/v1",
|
|
28
|
+
key_url="https://console.anthropic.com/settings/keys",
|
|
29
|
+
models=("claude-sonnet-5-5", "claude-haiku-5-5", "claude-opus-5-5")),
|
|
30
|
+
Provider("gemini", "Google (Gemini)", "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
31
|
+
key_url="https://aistudio.google.com/apikey",
|
|
32
|
+
models=("gemini-2.5-flash", "gemini-2.5-pro")),
|
|
33
|
+
Provider("xai", "xAI (Grok)", "https://api.x.ai/v1",
|
|
34
|
+
key_url="https://console.x.ai"),
|
|
35
|
+
Provider("mistral", "Mistral AI", "https://api.mistral.ai/v1",
|
|
36
|
+
key_url="https://console.mistral.ai/api-keys",
|
|
37
|
+
models=("mistral-small-latest", "mistral-large-latest")),
|
|
38
|
+
Provider("deepseek", "DeepSeek", "https://api.deepseek.com/v1",
|
|
39
|
+
key_url="https://platform.deepseek.com/api_keys",
|
|
40
|
+
models=("deepseek-chat", "deepseek-reasoner")),
|
|
41
|
+
Provider("groq", "Groq", "https://api.groq.com/openai/v1",
|
|
42
|
+
key_url="https://console.groq.com/keys",
|
|
43
|
+
models=("llama-3.3-70b-versatile",)),
|
|
44
|
+
Provider("together", "Together AI", "https://api.together.xyz/v1",
|
|
45
|
+
key_url="https://api.together.ai/settings/api-keys"),
|
|
46
|
+
Provider("openrouter", "OpenRouter (many models, one key)", "https://openrouter.ai/api/v1",
|
|
47
|
+
key_url="https://openrouter.ai/keys",
|
|
48
|
+
models=("openai/gpt-4o-mini",)),
|
|
49
|
+
Provider("ollama", "Ollama (local, no key)", "http://localhost:11434/v1", needs_key=False,
|
|
50
|
+
note="Start Ollama first and pull a model, e.g. `ollama pull llama3.2`."),
|
|
51
|
+
Provider("lmstudio", "LM Studio (local, no key)", "http://localhost:1234/v1", needs_key=False,
|
|
52
|
+
note="Start LM Studio's local server and load a model first."),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
CUSTOM = Provider("custom", "Other (any OpenAI-compatible endpoint)", "", needs_key=True)
|
|
56
|
+
|
|
57
|
+
_BY_ID = {p.id: p for p in PROVIDERS}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get(provider_id: str | None) -> Provider | None:
|
|
61
|
+
if provider_id == "custom":
|
|
62
|
+
return CUSTOM
|
|
63
|
+
return _BY_ID.get(provider_id or "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def all_choices() -> tuple[Provider, ...]:
|
|
67
|
+
"""The providers in menu order, with the custom option last."""
|
|
68
|
+
return PROVIDERS + (CUSTOM,)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Models that are not chat models; hidden from the model menu (but still usable if typed).
|
|
72
|
+
_NON_CHAT = ("embed", "whisper", "tts", "dall-e", "moderation", "transcribe", "audio", "realtime",
|
|
73
|
+
"image", "imagen", "babbage", "davinci", "rerank", "guard", "veo", "aqa")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_chat_model(model_id: str) -> bool:
|
|
77
|
+
low = model_id.lower()
|
|
78
|
+
return not any(tag in low for tag in _NON_CHAT)
|