ei-tui 0.5.1 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,10 +53,10 @@ Priority queue for LLM requests:
53
53
 
54
54
  Multi-provider LLM abstraction layer:
55
55
  - Handles requests to Anthropic, OpenAI, Bedrock, local models
56
- - **Sets `max_tokens: 64000`** for all requests
56
+ - **Sets `max_tokens: 8000`** by default (safe for most providers; users can configure higher per-model)
57
57
  - Prevents unbounded generation (test showed timeout after 2min without limit)
58
58
  - Local models silently clamp to their configured maximums
59
- - Anthropic Opus 4 accepts 64K (200K total context - 64K output = 136K input budget)
59
+ - Anthropic Opus 4 accepts up to 64K output (configure `max_output_tokens` on the model to unlock)
60
60
 
61
61
  **JSON Response Parsing** (`parseJSONResponse()`):
62
62
  - **Strategy 1**: Extract from markdown code blocks (```json)
@@ -23,11 +23,10 @@ export function filterMessagesForContext(
23
23
 
24
24
  const msgMs = new Date(msg.timestamp).getTime();
25
25
 
26
- if (contextBoundary) {
27
- return msgMs >= boundaryMs;
28
- }
26
+ if (msgMs < windowStartMs) return false;
27
+ if (contextBoundary && msgMs < boundaryMs) return false;
29
28
 
30
- return msgMs >= windowStartMs;
29
+ return true;
31
30
  });
32
31
  }
33
32
 
@@ -1,5 +1,6 @@
1
- import type { ChatMessage, ProviderAccount } from "./types.js";
2
- import { getKnownContextWindow, DEFAULT_TOKEN_LIMIT } from "./model-context-windows.js";
1
+ import type { ChatMessage, ProviderAccount, ModelConfig } from "./types.js";
2
+ const DEFAULT_TOKEN_LIMIT = 8192;
3
+ const DEFAULT_MAX_OUTPUT_TOKENS = 8000;
3
4
 
4
5
  export interface ProviderConfig {
5
6
  baseURL: string;
@@ -9,7 +10,7 @@ export interface ProviderConfig {
9
10
 
10
11
  export interface ResolvedModel {
11
12
  provider: string;
12
- model: string;
13
+ model: string | undefined;
13
14
  config: ProviderConfig;
14
15
  extraHeaders?: Record<string, string>;
15
16
  }
@@ -19,6 +20,8 @@ export interface LLMCallOptions {
19
20
  temperature?: number;
20
21
  /** OpenAI-compatible tools array. When present and non-empty, sent with tool_choice: "auto". */
21
22
  tools?: Record<string, unknown>[];
23
+ /** Fire-and-forget callback invoked after a successful response to increment usage counters. */
24
+ onUsageUpdate?: (modelId: string, usage: { calls: number; tokens_in: number; tokens_out: number }) => void;
22
25
  }
23
26
 
24
27
  export interface LLMRawResponse {
@@ -43,27 +46,90 @@ let llmCallCount = 0;
43
46
 
44
47
 
45
48
 
49
+ function isGuid(str: string): boolean {
50
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str);
51
+ }
52
+
53
+ function buildResolvedModel(account: ProviderAccount, model: ModelConfig): ResolvedModel {
54
+ return {
55
+ provider: account.name,
56
+ model: model.name === "(default)" ? undefined : model.name,
57
+ config: {
58
+ name: account.name,
59
+ baseURL: account.url,
60
+ apiKey: account.api_key || "",
61
+ },
62
+ extraHeaders: account.extra_headers,
63
+ };
64
+ }
65
+
66
+ export function resolveModelById(
67
+ modelId: string,
68
+ accounts: ProviderAccount[]
69
+ ): { account: ProviderAccount; model: ModelConfig } | undefined {
70
+ for (const account of accounts) {
71
+ if (!account.enabled || account.type !== "llm") continue;
72
+ const model = account.models?.find((m) => m.id === modelId);
73
+ if (model) return { account, model };
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ export function getDisplayName(account: ProviderAccount, model: ModelConfig): string {
79
+ return `${account.name}:${model.name}`;
80
+ }
81
+
46
82
  export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]): ResolvedModel {
47
83
  if (!modelSpec) {
48
84
  throw new Error("No model specified. Set a provider on this persona with /provider, or set a default_model in settings.");
49
85
  }
86
+
87
+ if (accounts && isGuid(modelSpec)) {
88
+ const result = resolveModelById(modelSpec, accounts);
89
+ if (result) {
90
+ return buildResolvedModel(result.account, result.model);
91
+ }
92
+
93
+ const fallbackAccount = accounts.find((acc) => acc.enabled && acc.type === "llm" && acc.default_model);
94
+ if (fallbackAccount?.default_model) {
95
+ const fallbackResult = resolveModelById(fallbackAccount.default_model, accounts);
96
+ if (fallbackResult) {
97
+ return buildResolvedModel(fallbackResult.account, fallbackResult.model);
98
+ }
99
+ }
100
+
101
+ throw new Error(
102
+ `Model "${modelSpec}" not found. It may have been deleted. Update this persona's model in settings.`
103
+ );
104
+ }
105
+
50
106
  let provider = "";
51
107
  let model = modelSpec;
52
-
108
+
53
109
  if (modelSpec.includes(":")) {
54
110
  const [p, ...rest] = modelSpec.split(":");
55
111
  provider = p;
56
112
  model = rest.join(":");
57
113
  }
58
- // Try to find matching account by name (case-insensitive)
59
- // Check both "provider:model" format AND bare account names
114
+
60
115
  if (accounts) {
61
- const searchName = provider || modelSpec; // If no ":", the whole spec might be an account name
116
+ const searchName = provider || modelSpec;
62
117
  const matchingAccount = accounts.find(
63
118
  (acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled && acc.type === "llm"
64
119
  );
65
120
  if (matchingAccount) {
66
- // If bare account name was used, get model from account's default_model
121
+ const matchingModel = matchingAccount.models?.find((m) => m.name === model);
122
+ if (matchingModel) {
123
+ return buildResolvedModel(matchingAccount, matchingModel);
124
+ }
125
+
126
+ if (!provider && matchingAccount.default_model && matchingAccount.models) {
127
+ const defaultModel = matchingAccount.models.find((m) => m.id === matchingAccount.default_model);
128
+ if (defaultModel) {
129
+ return buildResolvedModel(matchingAccount, defaultModel);
130
+ }
131
+ }
132
+
67
133
  const resolvedModel = provider ? model : (matchingAccount.default_model || model);
68
134
  return {
69
135
  provider: matchingAccount.name,
@@ -77,7 +143,7 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
77
143
  };
78
144
  }
79
145
  }
80
-
146
+
81
147
  throw new Error(
82
148
  `No provider "${provider || modelSpec}" found. Create one with /provider new, or check that it's enabled.`
83
149
  );
@@ -85,44 +151,48 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
85
151
 
86
152
  const tokenLimitLoggedModels = new Set<string>();
87
153
 
154
+ function findModelAndAccount(
155
+ spec: string,
156
+ accounts: ProviderAccount[]
157
+ ): { model: ModelConfig | undefined; account: ProviderAccount | undefined } {
158
+ if (spec.includes(":")) {
159
+ const [providerName, ...rest] = spec.split(":");
160
+ const modelName = rest.join(":");
161
+ const account = accounts.find(
162
+ (a) => a.name.toLowerCase() === providerName.toLowerCase() && a.enabled
163
+ );
164
+ const model = account?.models?.find((m) => m.name === modelName);
165
+ return { model, account };
166
+ }
167
+ for (const account of accounts) {
168
+ const model = account.models?.find((m) => m.id === spec);
169
+ if (model) return { model, account };
170
+ }
171
+ return { model: undefined, account: undefined };
172
+ }
173
+
88
174
  export function resolveTokenLimit(
89
175
  modelSpec?: string,
90
176
  accounts?: ProviderAccount[]
91
177
  ): number {
92
178
  const spec = modelSpec || "";
93
179
 
94
- let provider = "";
95
- let model = spec;
96
- if (spec.includes(":")) {
97
- const [p, ...rest] = spec.split(":");
98
- provider = p;
99
- model = rest.join(":");
100
- }
180
+ if (accounts && spec) {
181
+ const { model, account } = findModelAndAccount(spec, accounts);
101
182
 
102
- // 1. User override on matching account
103
- if (accounts) {
104
- const searchName = provider || spec;
105
- const matchingAccount = accounts.find(
106
- (acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled
107
- );
108
- if (matchingAccount?.token_limit) {
109
- logTokenLimit(model, "user-override", matchingAccount.token_limit);
110
- return matchingAccount.token_limit;
183
+ if (model?.token_limit) {
184
+ logTokenLimit(spec, "model-config", model.token_limit);
185
+ return model.token_limit;
111
186
  }
112
- if (matchingAccount && !provider) {
113
- model = matchingAccount.default_model || model;
114
- }
115
- }
116
187
 
117
- // 2. Lookup table
118
- const known = getKnownContextWindow(model);
119
- if (known) {
120
- logTokenLimit(model, "lookup-table", known);
121
- return known;
188
+ if (account?.token_limit) {
189
+ const displayName = spec.includes(":") ? spec.split(":").slice(1).join(":") : spec;
190
+ logTokenLimit(displayName, "user-override", account.token_limit);
191
+ return account.token_limit;
192
+ }
122
193
  }
123
194
 
124
- // 3. Conservative default
125
- logTokenLimit(model, "default", DEFAULT_TOKEN_LIMIT);
195
+ logTokenLimit(spec, "default", DEFAULT_TOKEN_LIMIT);
126
196
  return DEFAULT_TOKEN_LIMIT;
127
197
  }
128
198
 
@@ -148,13 +218,16 @@ export async function callLLMRaw(
148
218
  ): Promise<LLMRawResponse> {
149
219
  llmCallCount++;
150
220
 
151
- const { signal, temperature = 0.7 } = options;
221
+ const { signal, temperature = 0.7, onUsageUpdate } = options;
152
222
 
153
223
  if (signal?.aborted) {
154
224
  throw new Error("LLM call aborted");
155
225
  }
156
226
 
157
227
  const { model, config, extraHeaders } = resolveModel(modelSpec, accounts);
228
+ const { model: modelConfig } = (accounts && modelSpec)
229
+ ? findModelAndAccount(modelSpec, accounts)
230
+ : { model: undefined };
158
231
 
159
232
  const chatMessages: ChatMessage[] = [
160
233
  { role: "system", content: systemPrompt },
@@ -186,10 +259,10 @@ export async function callLLMRaw(
186
259
  }
187
260
 
188
261
  const requestBody: Record<string, unknown> = {
189
- model,
262
+ ...(model !== undefined && { model }),
190
263
  messages: finalMessages,
191
264
  temperature,
192
- max_tokens: 64000, // Opus 4: 128K max output, 200K total context. Local models clamp to their config. Prevents runaway generation.
265
+ max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
193
266
  };
194
267
 
195
268
  if (options.tools && options.tools.length > 0) {
@@ -210,6 +283,13 @@ export async function callLLMRaw(
210
283
  }
211
284
 
212
285
  const data = await response.json();
286
+
287
+ if (onUsageUpdate && modelConfig) {
288
+ const tokensIn = data.usage?.prompt_tokens ?? data.usage?.input_tokens ?? 0;
289
+ const tokensOut = data.usage?.completion_tokens ?? data.usage?.output_tokens ?? 0;
290
+ onUsageUpdate(modelConfig.id, { calls: 1, tokens_in: tokensIn, tokens_out: tokensOut });
291
+ }
292
+
213
293
  const choice = data.choices?.[0];
214
294
 
215
295
  const assistantMessage = choice?.message as Record<string, unknown> | undefined;
@@ -66,8 +66,6 @@ export interface ExtractionOptions {
66
66
  ceremony_progress?: number;
67
67
  /** Override model for extraction LLM calls */
68
68
  extraction_model?: string;
69
- /** Override token budget for chunking */
70
- extraction_token_limit?: number;
71
69
  /**
72
70
  * Controls whether external (integration-imported) messages are included.
73
71
  * - "exclude": skip messages where external === true
@@ -88,9 +86,6 @@ const EXTRACTION_BUDGET_RATIO = 0.75;
88
86
  const MIN_EXTRACTION_TOKENS = 10000;
89
87
 
90
88
  function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions): number {
91
- if (options?.extraction_token_limit) {
92
- return Math.max(MIN_EXTRACTION_TOKENS, Math.floor(options.extraction_token_limit * EXTRACTION_BUDGET_RATIO));
93
- }
94
89
  const human = state.getHuman();
95
90
  const modelForTokenLimit = options?.extraction_model ?? human.settings?.default_model;
96
91
  const tokenLimit = resolveTokenLimit(modelForTokenLimit, human.settings?.accounts);
@@ -107,6 +107,7 @@ import {
107
107
  getQueueActiveItems,
108
108
  getDLQItems,
109
109
  updateQueueItem,
110
+ deleteQueueItems,
110
111
  clearQueue,
111
112
  submitOneShot,
112
113
  } from "./queue-manager.js";
@@ -1931,6 +1932,10 @@ const toolNextSteps = new Set([
1931
1932
  return updateQueueItem(this.stateManager, id, updates);
1932
1933
  }
1933
1934
 
1935
+ deleteQueueItems(ids: string[]): number {
1936
+ return deleteQueueItems(this.stateManager, ids);
1937
+ }
1938
+
1934
1939
  async clearQueue(): Promise<number> {
1935
1940
  return clearQueue(this.stateManager, this.queueProcessor);
1936
1941
  }
@@ -51,6 +51,10 @@ export function updateQueueItem(
51
51
  return sm.queue_updateItem(id, updates);
52
52
  }
53
53
 
54
+ export function deleteQueueItems(sm: StateManager, ids: string[]): number {
55
+ return sm.queue_deleteItems(ids);
56
+ }
57
+
54
58
  export async function clearQueue(sm: StateManager, qp: QueueProcessor): Promise<number> {
55
59
  qp.abort();
56
60
  return sm.queue_clear();
@@ -542,6 +542,7 @@ export class QueueProcessor {
542
542
  `be parsed as valid JSON. Please reformat it as the JSON object described in your ` +
543
543
  `system instructions. Respond with ONLY the JSON object, or \`{}\` if no changes ` +
544
544
  `are needed.\n\n---\n${malformedContent}\n---` +
545
+ `\n\nThe user does NOT know there was a problem - This request is from Ei to you to try to fix it for them.` +
545
546
  `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
546
547
 
547
548
  try {
@@ -158,6 +158,13 @@ export class QueueState {
158
158
  return true;
159
159
  }
160
160
 
161
+ deleteItems(ids: string[]): number {
162
+ const idSet = new Set(ids);
163
+ const before = this.queue.length;
164
+ this.queue = this.queue.filter(r => !idSet.has(r.id));
165
+ return before - this.queue.length;
166
+ }
167
+
161
168
  trimDLQ(): number {
162
169
  const dlqItems = this.queue.filter(r => r.state === "dlq");
163
170
  const cutoff = new Date();
@@ -50,15 +50,20 @@ export class StateManager {
50
50
  this.queueState.load(state.queue);
51
51
  this.tools = state.tools ?? [];
52
52
  this.providers = state.providers ?? [];
53
- this.migrateLearnedByToIds();
54
- this.migrateFactValidation();
55
- this.migrateMessageFlags();
56
- this.migrateInterestedPersonas();
53
+ this.runMigrations();
57
54
  } else {
58
55
  this.humanState.load(createDefaultHumanEntity());
59
56
  }
60
57
  }
61
58
 
59
+ private runMigrations(): void {
60
+ this.migrateLearnedByToIds();
61
+ this.migrateFactValidation();
62
+ this.migrateMessageFlags();
63
+ this.migrateInterestedPersonas();
64
+ this.migrateProviderModel();
65
+ }
66
+
62
67
  /**
63
68
  * Migration: learned_by used to store display names; now stores persona IDs.
64
69
  * On load, attempt to resolve display names -> IDs using current persona map.
@@ -222,6 +227,162 @@ export class StateManager {
222
227
  }
223
228
  }
224
229
 
230
+ private migrateProviderModel(): void {
231
+ const human = this.humanState.get();
232
+ const settings = human.settings;
233
+ if (!settings?.accounts?.length) return;
234
+
235
+ const modelLookup = new Map<string, string>();
236
+
237
+ // Helper: ensure a model exists in an account and register it in modelLookup.
238
+ // ref must be in "ProviderName:model-name" format.
239
+ const ensureModelInAccount = (ref: string): void => {
240
+ const colonIdx = ref.indexOf(":");
241
+ if (colonIdx === -1) return;
242
+ const providerName = ref.substring(0, colonIdx);
243
+ const modelName = ref.substring(colonIdx + 1);
244
+ const account = settings.accounts!.find((a) => a.name === providerName);
245
+ if (!account) return;
246
+
247
+ if (!account.models) account.models = [];
248
+
249
+ const existing = account.models.find((m) => m.name === modelName);
250
+ if (existing) {
251
+ modelLookup.set(ref, existing.id);
252
+ } else {
253
+ const newModel = {
254
+ id: crypto.randomUUID(),
255
+ name: modelName,
256
+ };
257
+ account.models.push(newModel);
258
+ modelLookup.set(ref, newModel.id);
259
+ }
260
+ };
261
+
262
+ const isProviderRef = (val: string): boolean => {
263
+ const colonIdx = val.indexOf(":");
264
+ if (colonIdx === -1) return false;
265
+ const providerName = val.substring(0, colonIdx);
266
+ return settings.accounts!.some((a) => a.name === providerName);
267
+ };
268
+
269
+ // Phase 1: Collect ALL model refs from everywhere they can appear.
270
+ const allRefs: string[] = [];
271
+ const pushRef = (ref: string | undefined): void => {
272
+ if (ref && isProviderRef(ref)) allRefs.push(ref);
273
+ };
274
+
275
+ pushRef(settings.default_model);
276
+ pushRef(settings.oneshot_model);
277
+ pushRef(settings.rewrite_model);
278
+ pushRef(settings.opencode?.extraction_model);
279
+ pushRef(settings.claudeCode?.extraction_model);
280
+
281
+ const personas = this.personaState.getAll();
282
+ for (const persona of personas) {
283
+ pushRef(persona.model);
284
+ }
285
+
286
+ // Also include account.default_model values (legacy strings, not yet GUIDs)
287
+ for (const account of settings.accounts) {
288
+ if (account.default_model && isProviderRef(account.default_model)) {
289
+ allRefs.push(account.default_model);
290
+ }
291
+ }
292
+
293
+ // Phase 2: For each ref, ensure model exists in the matching account.
294
+ for (const ref of allRefs) {
295
+ ensureModelInAccount(ref);
296
+ }
297
+
298
+ // Helper: check if a value looks like a UUID (already migrated)
299
+ const isUUID = (val: string): boolean =>
300
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
301
+
302
+ // Phase 3: Ensure every account has a models array and default_model is a GUID.
303
+ for (const account of settings.accounts) {
304
+ if (!account.models) {
305
+ account.models = [];
306
+ }
307
+
308
+ // Handle account.default_model - could be:
309
+ // 1. Already a GUID (already migrated) - leave it
310
+ // 2. A "Provider:model" ref - look up in modelLookup
311
+ // 3. A plain model name like "claude-haiku-4-5-20251001" - add to models[] and convert
312
+ if (account.default_model) {
313
+ if (isUUID(account.default_model)) {
314
+ // Already migrated, nothing to do
315
+ } else if (isProviderRef(account.default_model)) {
316
+ // It's a "Provider:model" ref - should be in modelLookup from Phase 2
317
+ const guid = modelLookup.get(account.default_model);
318
+ if (guid) account.default_model = guid;
319
+ } else {
320
+ // Plain model name - check if it exists in models[], add if not, convert to GUID
321
+ const existing = account.models.find((m) => m.name === account.default_model);
322
+ if (existing) {
323
+ account.default_model = existing.id;
324
+ } else {
325
+ const model = {
326
+ id: crypto.randomUUID(),
327
+ name: account.default_model,
328
+ token_limit: (account as any).token_limit as number | undefined,
329
+ max_output_tokens: undefined as number | undefined,
330
+ };
331
+ account.models.push(model);
332
+ modelLookup.set(`${account.name}:${model.name}`, model.id);
333
+ account.default_model = model.id;
334
+ }
335
+ }
336
+ }
337
+
338
+ // If still no models, create a placeholder
339
+ if (account.models.length === 0) {
340
+ const model = { id: crypto.randomUUID(), name: "(default)" };
341
+ account.models.push(model);
342
+ modelLookup.set(`${account.name}:(default)`, model.id);
343
+ account.default_model = model.id;
344
+ }
345
+
346
+ delete (account as any).token_limit;
347
+ }
348
+
349
+ const resolveRef = (ref: string | undefined): string | undefined => {
350
+ if (!ref) return ref;
351
+ const guid = modelLookup.get(ref);
352
+ if (guid) return guid;
353
+ const colonIdx = ref.indexOf(":");
354
+ if (colonIdx === -1) return ref;
355
+ return undefined;
356
+ };
357
+
358
+ settings.default_model = resolveRef(settings.default_model);
359
+ settings.oneshot_model = resolveRef(settings.oneshot_model);
360
+ settings.rewrite_model = resolveRef(settings.rewrite_model);
361
+
362
+ if (settings.opencode) {
363
+ settings.opencode.extraction_model = resolveRef(settings.opencode.extraction_model);
364
+ delete (settings.opencode as any).extraction_token_limit;
365
+ }
366
+
367
+ if (settings.claudeCode) {
368
+ settings.claudeCode.extraction_model = resolveRef(settings.claudeCode.extraction_model);
369
+ delete (settings.claudeCode as any).extraction_token_limit;
370
+ }
371
+
372
+ for (const persona of personas) {
373
+ if (persona.model) {
374
+ const resolved = resolveRef(persona.model);
375
+ const colonIdx = (resolved ?? persona.model).indexOf(":");
376
+ const finalModel = colonIdx !== -1 ? undefined : (resolved ?? persona.model);
377
+ this.personaState.update(persona.id, { model: finalModel });
378
+ }
379
+ }
380
+
381
+ this.humanState.set(human);
382
+ this.scheduleSave();
383
+ console.log("[StateManager] Migrated provider/model references to GUID-based ModelConfig system");
384
+ }
385
+
225
386
  /**
226
387
  * Returns true if value looks like a persona ID (UUID or the special "ei" id).
227
388
  * Display names are free-form strings that won't match UUID format.
@@ -621,6 +782,12 @@ export class StateManager {
621
782
  return result;
622
783
  }
623
784
 
785
+ queue_deleteItems(ids: string[]): number {
786
+ const result = this.queueState.deleteItems(ids);
787
+ if (result > 0) this.scheduleSave();
788
+ return result;
789
+ }
790
+
624
791
  queue_dlqLength(): number {
625
792
  return this.queueState.dlqLength();
626
793
  }
@@ -764,6 +931,67 @@ export class StateManager {
764
931
  return result;
765
932
  }
766
933
 
934
+ deleteModel(providerId: string, modelId: string): { success: boolean; error?: string; cleared: string[] } {
935
+ const human = this.humanState.get();
936
+ const settings = human.settings;
937
+ if (!settings?.accounts?.length) {
938
+ return { success: false, error: `Provider not found: ${providerId}`, cleared: [] };
939
+ }
940
+
941
+ const provider = settings.accounts.find(a => a.id === providerId);
942
+ if (!provider) {
943
+ return { success: false, error: `Provider not found: ${providerId}`, cleared: [] };
944
+ }
945
+
946
+ if (!provider.models?.find(m => m.id === modelId)) {
947
+ return { success: false, error: `Model not found: ${modelId}`, cleared: [] };
948
+ }
949
+
950
+ if ((provider.models?.length ?? 0) <= 1) {
951
+ return { success: false, error: `Cannot delete the last model on a provider`, cleared: [] };
952
+ }
953
+
954
+ const cleared: string[] = [];
955
+
956
+ if (settings.default_model === modelId) {
957
+ settings.default_model = undefined;
958
+ cleared.push("settings.default_model");
959
+ }
960
+ if (settings.oneshot_model === modelId) {
961
+ settings.oneshot_model = undefined;
962
+ cleared.push("settings.oneshot_model");
963
+ }
964
+ if (settings.rewrite_model === modelId) {
965
+ settings.rewrite_model = undefined;
966
+ cleared.push("settings.rewrite_model");
967
+ }
968
+ if (settings.opencode?.extraction_model === modelId) {
969
+ settings.opencode.extraction_model = undefined;
970
+ cleared.push("settings.opencode.extraction_model");
971
+ }
972
+ if (settings.claudeCode?.extraction_model === modelId) {
973
+ settings.claudeCode.extraction_model = undefined;
974
+ cleared.push("settings.claudeCode.extraction_model");
975
+ }
976
+ if (provider.default_model === modelId) {
977
+ provider.default_model = undefined;
978
+ cleared.push("provider.default_model");
979
+ }
980
+
981
+ provider.models = provider.models!.filter(m => m.id !== modelId);
982
+ this.humanState.set(human);
983
+
984
+ for (const persona of this.personaState.getAll()) {
985
+ if (persona.model === modelId) {
986
+ this.personaState.update(persona.id, { model: undefined });
987
+ cleared.push(`persona:${persona.display_name}`);
988
+ }
989
+ }
990
+
991
+ this.scheduleSave();
992
+ return { success: true, cleared };
993
+ }
994
+
767
995
  async flush(): Promise<void> {
768
996
  await this.persistenceState.flush();
769
997
  }
@@ -788,6 +1016,7 @@ export class StateManager {
788
1016
  this.providers = state.providers ?? [];
789
1017
  this.tools = state.tools ?? [];
790
1018
  this.persistenceState.markExistingData();
1019
+ this.runMigrations();
791
1020
  this.scheduleSave();
792
1021
  }
793
1022
 
@@ -81,7 +81,7 @@ export function toOpenAITools(tools: ToolDefinition[]): Record<string, unknown>[
81
81
  name: t.name,
82
82
  description: t.description,
83
83
  parameters: t.input_schema,
84
- ...(t.is_submit ? { strict: true } : {}),
84
+
85
85
  },
86
86
  }));
87
87
  }