ei-tui 1.3.1 → 1.3.2
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.
- package/package.json +1 -1
- package/tui/README.md +8 -0
- package/tui/src/context/ei.tsx +4 -1
- package/tui/src/util/provider-detection.ts +177 -23
package/package.json
CHANGED
package/tui/README.md
CHANGED
|
@@ -10,6 +10,14 @@ Ei is designed to run consistently across machines and environments, so it keeps
|
|
|
10
10
|
|
|
11
11
|
**On first run**, Ei reads environment variables like `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. to auto-configure providers for you. After that, those values are saved to Ei's local state (`~/.local/share/ei/state.json` by default) and the env vars are no longer consulted.
|
|
12
12
|
|
|
13
|
+
Detected providers are configured with sensible defaults out of the box:
|
|
14
|
+
|
|
15
|
+
- **Models**: Only chat-capable models are included — TTS, image generation, embeddings, and other non-chat model families are filtered out. You get one model per tier (e.g. fast/mini for extraction, capable for chat, powerful for complex work) rather than a wall of 100+ options.
|
|
16
|
+
- **Token limits**: Known models get pre-configured `token_limit` and `max_output_tokens` values based on real-world Ei usage, not just the provider's advertised maximums.
|
|
17
|
+
- **Rewrite model**: If a high-capability model is detected (Anthropic Opus, OpenAI o-series), it's automatically set as your `rewrite_model` — used by `/generate` and `/dedupe`. No manual `/settings` step needed.
|
|
18
|
+
|
|
19
|
+
All of this only applies on first run. Existing profiles are never modified by detection.
|
|
20
|
+
|
|
13
21
|
This means:
|
|
14
22
|
|
|
15
23
|
- **Rotating an API key?** Update it in Ei with `/provider`, not just in your shell.
|
package/tui/src/context/ei.tsx
CHANGED
|
@@ -832,7 +832,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
832
832
|
setDetectedProviders(allStatuses);
|
|
833
833
|
|
|
834
834
|
if (detected.length > 0) {
|
|
835
|
-
const accounts = buildProviderAccounts(detected);
|
|
835
|
+
const { accounts, suggestedRewriteModelId } = buildProviderAccounts(detected);
|
|
836
836
|
const topProvider = detected[0];
|
|
837
837
|
const defaultModel = `${topProvider.name}:${topProvider.selected.extractionModel}`;
|
|
838
838
|
setFirstBootDefaultModel(defaultModel);
|
|
@@ -842,6 +842,9 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
842
842
|
...currentHuman.settings,
|
|
843
843
|
accounts,
|
|
844
844
|
default_model: defaultModel,
|
|
845
|
+
...(!currentHuman.settings?.rewrite_model && suggestedRewriteModelId && {
|
|
846
|
+
rewrite_model: suggestedRewriteModelId,
|
|
847
|
+
}),
|
|
845
848
|
},
|
|
846
849
|
});
|
|
847
850
|
const names = detected.map((d) => d.name).join(" and ");
|
|
@@ -58,10 +58,141 @@ export const ALL_PROVIDER_NAMES: ReadonlyArray<string> = [
|
|
|
58
58
|
...CLOUD_PROVIDERS.map((p) => p.name),
|
|
59
59
|
];
|
|
60
60
|
|
|
61
|
+
// Ei-curated effective limits for known models.
|
|
62
|
+
// These are NOT the provider's advertised maximums — they're the limits Ei uses in practice.
|
|
63
|
+
// For example, Haiku's advertised context is 200k but real-world extraction quality degrades
|
|
64
|
+
// above ~100k, so we cap it there. When adding new models, prefer conservative values based
|
|
65
|
+
// on actual usage over marketing specs.
|
|
66
|
+
export const KNOWN_MODEL_LIMITS: Readonly<Record<string, { token_limit?: number; max_output_tokens?: number }>> = {
|
|
67
|
+
// Anthropic — claude-opus-4.x
|
|
68
|
+
"claude-opus-4-7": { token_limit: 200000, max_output_tokens: 128000 },
|
|
69
|
+
"claude-opus-4-6": { token_limit: 200000, max_output_tokens: 128000 },
|
|
70
|
+
"claude-opus-4-5-20251101": { token_limit: 200000, max_output_tokens: 64000 },
|
|
71
|
+
"claude-opus-4-1-20250805": { token_limit: 200000, max_output_tokens: 32000 },
|
|
72
|
+
// Anthropic — claude-sonnet-4.x
|
|
73
|
+
"claude-sonnet-4-6": { token_limit: 200000, max_output_tokens: 64000 },
|
|
74
|
+
"claude-sonnet-4-5-20250929": { token_limit: 200000, max_output_tokens: 64000 },
|
|
75
|
+
// Anthropic — claude-haiku-4.x
|
|
76
|
+
// Note: advertised context is 200k but extraction quality degrades above ~100k in practice
|
|
77
|
+
"claude-haiku-4-5-20251001": { token_limit: 100000, max_output_tokens: 64000 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Sort model IDs by version numerically descending so "4-6" correctly beats "4-5".
|
|
81
|
+
// Snapshot date suffixes (8-digit YYYYMMDD) are stripped before comparison so that
|
|
82
|
+
// "claude-sonnet-4-6" sorts higher than "claude-sonnet-4-5-20250929".
|
|
83
|
+
function sortModelsDesc(modelIds: string[]): string[] {
|
|
84
|
+
const stripDate = (id: string) => id.replace(/-\d{8}$/, "");
|
|
85
|
+
return [...modelIds].sort((a, b) => {
|
|
86
|
+
const aParts = stripDate(a).split(/[-.]/).map((p) => (isNaN(Number(p)) ? p : Number(p)));
|
|
87
|
+
const bParts = stripDate(b).split(/[-.]/).map((p) => (isNaN(Number(p)) ? p : Number(p)));
|
|
88
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
89
|
+
const av = aParts[i] ?? 0;
|
|
90
|
+
const bv = bParts[i] ?? 0;
|
|
91
|
+
if (av < bv) return 1;
|
|
92
|
+
if (av > bv) return -1;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
function latestMatch(modelIds: string[], pattern: string): string | undefined {
|
|
62
99
|
const matches = modelIds.filter((id) => id.toLowerCase().includes(pattern));
|
|
63
100
|
if (matches.length === 0) return undefined;
|
|
64
|
-
return
|
|
101
|
+
return sortModelsDesc(matches)[0];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// For Anthropic: keep only the single latest model per tier (haiku/sonnet/opus).
|
|
105
|
+
// Drops older snapshots and deprecated models (e.g. claude-opus-4-20250514) so the
|
|
106
|
+
// initial provider config stays clean. Users can add older models manually if needed.
|
|
107
|
+
function filterAnthropicModels(modelIds: string[]): string[] {
|
|
108
|
+
const tiers = ["haiku", "sonnet", "opus"];
|
|
109
|
+
const kept: string[] = [];
|
|
110
|
+
for (const tier of tiers) {
|
|
111
|
+
const latest = latestMatch(modelIds, tier);
|
|
112
|
+
if (latest) kept.push(latest);
|
|
113
|
+
}
|
|
114
|
+
// Preserve any models that don't match a known tier (future-proofing)
|
|
115
|
+
const unknowns = modelIds.filter((id) => !tiers.some((t) => id.toLowerCase().includes(t)));
|
|
116
|
+
return [...kept, ...unknowns];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// For OpenAI: the /models endpoint returns everything — TTS, image generation, audio,
|
|
120
|
+
// embeddings, moderation, legacy completions, etc. Keep only chat-capable model families
|
|
121
|
+
// and trim to one latest per tier so the provider config stays useful.
|
|
122
|
+
function filterOpenAIModels(modelIds: string[]): string[] {
|
|
123
|
+
const NON_CHAT_PATTERNS = [
|
|
124
|
+
"tts", "whisper", "dall-e", "embedding", "davinci", "babbage",
|
|
125
|
+
"moderation", "audio", "realtime", "transcribe", "image", "sora",
|
|
126
|
+
"chat-latest", "codex",
|
|
127
|
+
];
|
|
128
|
+
const isNonChat = (id: string) => {
|
|
129
|
+
const lower = id.toLowerCase();
|
|
130
|
+
return NON_CHAT_PATTERNS.some((p) => lower.includes(p));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const chatModels = modelIds.filter((id) => !isNonChat(id));
|
|
134
|
+
|
|
135
|
+
// Tiers in priority order. Mini variants are their own tier for extraction use.
|
|
136
|
+
const tiers = [
|
|
137
|
+
{ name: "o-series", match: (id: string) => /^o\d/.test(id.toLowerCase()) && !id.toLowerCase().includes("mini") },
|
|
138
|
+
{ name: "gpt-5", match: (id: string) => id.toLowerCase().includes("gpt-5") && !id.toLowerCase().includes("mini") },
|
|
139
|
+
{ name: "gpt-4.1", match: (id: string) => id.toLowerCase().includes("gpt-4.1") && !id.toLowerCase().includes("mini") },
|
|
140
|
+
{ name: "gpt-4o", match: (id: string) => id.toLowerCase().includes("gpt-4o") && !id.toLowerCase().includes("mini") },
|
|
141
|
+
{ name: "mini", match: (id: string) => id.toLowerCase().includes("mini") },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const kept: string[] = [];
|
|
145
|
+
const consumed = new Set<string>();
|
|
146
|
+
|
|
147
|
+
for (const tier of tiers) {
|
|
148
|
+
const matches = chatModels.filter((id) => tier.match(id) && !consumed.has(id));
|
|
149
|
+
const latest = sortModelsDesc(matches)[0];
|
|
150
|
+
if (latest) {
|
|
151
|
+
kept.push(latest);
|
|
152
|
+
consumed.add(latest);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return kept;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// For Gemini: the /models endpoint returns chat models, embedding models, image/video
|
|
160
|
+
// generation (Imagen, Veo), audio (Lyria), TTS variants, robotics previews, and research
|
|
161
|
+
// models. Keep only plain gemini-N.N-flash and gemini-N.N-pro chat families, latest per tier.
|
|
162
|
+
function filterGeminiModels(modelIds: string[]): string[] {
|
|
163
|
+
const NON_CHAT_PATTERNS = [
|
|
164
|
+
"embedding", "imagen", "veo", "lyria", "robotics", "tts", "audio",
|
|
165
|
+
"native-audio", "computer-use", "deep-research", "aqa", "live",
|
|
166
|
+
"-image-", "gemma",
|
|
167
|
+
];
|
|
168
|
+
const isNonChat = (id: string) => {
|
|
169
|
+
const lower = id.toLowerCase();
|
|
170
|
+
return NON_CHAT_PATTERNS.some((p) => lower.includes(p));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const chatModels = modelIds.filter((id) => !isNonChat(id));
|
|
174
|
+
|
|
175
|
+
const tiers = ["pro", "flash"];
|
|
176
|
+
const kept: string[] = [];
|
|
177
|
+
const consumed = new Set<string>();
|
|
178
|
+
|
|
179
|
+
for (const tier of tiers) {
|
|
180
|
+
const latest = latestMatch(chatModels.filter((id) => !consumed.has(id)), tier);
|
|
181
|
+
if (latest) {
|
|
182
|
+
kept.push(latest);
|
|
183
|
+
consumed.add(latest);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return kept;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function filterModelsForProvider(providerName: string, modelIds: string[]): string[] {
|
|
191
|
+
const name = providerName.toLowerCase();
|
|
192
|
+
if (name === "anthropic") return filterAnthropicModels(modelIds);
|
|
193
|
+
if (name === "openai") return filterOpenAIModels(modelIds);
|
|
194
|
+
if (name === "gemini") return filterGeminiModels(modelIds);
|
|
195
|
+
return modelIds;
|
|
65
196
|
}
|
|
66
197
|
|
|
67
198
|
export function selectModelsForProvider(
|
|
@@ -82,22 +213,20 @@ export function selectModelsForProvider(
|
|
|
82
213
|
}
|
|
83
214
|
|
|
84
215
|
if (name === "anthropic") {
|
|
216
|
+
const filtered = filterAnthropicModels(modelIds);
|
|
85
217
|
return {
|
|
86
|
-
extractionModel: latestMatch(
|
|
87
|
-
chatModel: latestMatch(
|
|
88
|
-
bonusModel: latestMatch(
|
|
218
|
+
extractionModel: latestMatch(filtered, "haiku") ?? filtered[0],
|
|
219
|
+
chatModel: latestMatch(filtered, "sonnet") ?? filtered[0],
|
|
220
|
+
bonusModel: latestMatch(filtered, "opus"),
|
|
89
221
|
};
|
|
90
222
|
}
|
|
91
223
|
|
|
92
224
|
if (name === "openai") {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
);
|
|
225
|
+
const filtered = filterOpenAIModels(modelIds);
|
|
226
|
+
const list = filtered.length > 0 ? filtered : modelIds;
|
|
96
227
|
return {
|
|
97
|
-
extractionModel: latestMatch(
|
|
98
|
-
chatModel:
|
|
99
|
-
? [...gpt4oNonMini].sort().reverse()[0]
|
|
100
|
-
: modelIds[0],
|
|
228
|
+
extractionModel: latestMatch(list, "mini") ?? list[0],
|
|
229
|
+
chatModel: list[0],
|
|
101
230
|
};
|
|
102
231
|
}
|
|
103
232
|
|
|
@@ -109,9 +238,11 @@ export function selectModelsForProvider(
|
|
|
109
238
|
}
|
|
110
239
|
|
|
111
240
|
if (name === "gemini") {
|
|
241
|
+
const filtered = filterGeminiModels(modelIds);
|
|
242
|
+
const list = filtered.length > 0 ? filtered : modelIds;
|
|
112
243
|
return {
|
|
113
|
-
extractionModel: latestMatch(
|
|
114
|
-
chatModel: latestMatch(
|
|
244
|
+
extractionModel: latestMatch(list, "flash") ?? list[0],
|
|
245
|
+
chatModel: latestMatch(list, "pro") ?? list[0],
|
|
115
246
|
};
|
|
116
247
|
}
|
|
117
248
|
|
|
@@ -212,29 +343,50 @@ export async function detectProviders(
|
|
|
212
343
|
return { detected, statuses };
|
|
213
344
|
}
|
|
214
345
|
|
|
346
|
+
export interface ProviderBootstrapResult {
|
|
347
|
+
accounts: ProviderAccount[];
|
|
348
|
+
suggestedRewriteModelId?: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
215
351
|
export function buildProviderAccounts(
|
|
216
352
|
detected: ProviderDetectionResult[]
|
|
217
|
-
):
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
353
|
+
): ProviderBootstrapResult {
|
|
354
|
+
let suggestedRewriteModelId: string | undefined;
|
|
355
|
+
|
|
356
|
+
const accounts = detected.map((d) => {
|
|
357
|
+
const makeModel = (modelName: string): ModelConfig => {
|
|
358
|
+
const limits = KNOWN_MODEL_LIMITS[modelName];
|
|
359
|
+
return {
|
|
360
|
+
id: crypto.randomUUID(),
|
|
361
|
+
name: modelName,
|
|
362
|
+
...(limits?.token_limit !== undefined && { token_limit: limits.token_limit }),
|
|
363
|
+
...(limits?.max_output_tokens !== undefined && { max_output_tokens: limits.max_output_tokens }),
|
|
364
|
+
};
|
|
365
|
+
};
|
|
223
366
|
|
|
224
367
|
const seenNames = new Set<string>();
|
|
225
368
|
const models: ModelConfig[] = [];
|
|
226
369
|
|
|
227
|
-
const pushIfNew = (name: string) => {
|
|
370
|
+
const pushIfNew = (name: string): ModelConfig => {
|
|
228
371
|
if (!seenNames.has(name)) {
|
|
229
372
|
seenNames.add(name);
|
|
230
|
-
|
|
373
|
+
const model = makeModel(name);
|
|
374
|
+
models.push(model);
|
|
375
|
+
return model;
|
|
231
376
|
}
|
|
377
|
+
return models.find((m) => m.name === name)!;
|
|
232
378
|
};
|
|
233
379
|
|
|
234
380
|
pushIfNew(d.selected.chatModel);
|
|
235
381
|
pushIfNew(d.selected.extractionModel);
|
|
236
|
-
if (d.selected.bonusModel)
|
|
237
|
-
|
|
382
|
+
if (d.selected.bonusModel) {
|
|
383
|
+
const bonusConfig = pushIfNew(d.selected.bonusModel);
|
|
384
|
+
if (!suggestedRewriteModelId) {
|
|
385
|
+
suggestedRewriteModelId = bonusConfig.id;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const modelList = filterModelsForProvider(d.name, d.modelIds);
|
|
389
|
+
for (const id of modelList) pushIfNew(id);
|
|
238
390
|
|
|
239
391
|
const cloudConfig = CLOUD_PROVIDERS.find((p) => p.name === d.name);
|
|
240
392
|
const apiKey = cloudConfig ? `$${cloudConfig.envVar}` : d.apiKey;
|
|
@@ -251,4 +403,6 @@ export function buildProviderAccounts(
|
|
|
251
403
|
models,
|
|
252
404
|
};
|
|
253
405
|
});
|
|
406
|
+
|
|
407
|
+
return { accounts, suggestedRewriteModelId };
|
|
254
408
|
}
|