ei-tui 0.6.0 → 0.6.1

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.
@@ -0,0 +1,479 @@
1
+ import YAML from "yaml";
2
+ import type {
3
+ PersonaEntity,
4
+ PersonaTrait,
5
+ PersonaTopic,
6
+ ToolDefinition,
7
+ ProviderAccount,
8
+ } from "../../../src/core/types.js";
9
+ import { modelGuidToDisplay, displayToModelGuid } from "./yaml-shared.js";
10
+
11
+ const PLACEHOLDER_LONG_DESC = "Detailed description of this persona's personality, background, and role";
12
+
13
+ interface YAMLTrait {
14
+ name: string;
15
+ description: string;
16
+ sentiment: number;
17
+ strength: number;
18
+ }
19
+
20
+ interface YAMLPersonaTopic {
21
+ name: string;
22
+ perspective: string;
23
+ approach: string;
24
+ personal_stake: string;
25
+ exposure_current: number;
26
+ exposure_desired: number;
27
+ }
28
+
29
+ interface EditablePersonaData {
30
+ display_name?: string;
31
+ aliases?: string[];
32
+ short_description?: string;
33
+ long_description?: string;
34
+ model?: string | null;
35
+ group_primary?: string | null;
36
+ groups_visible?: Record<string, boolean>[];
37
+ traits: YAMLTrait[];
38
+ topics: YAMLPersonaTopic[];
39
+ heartbeat_delay_ms?: string | number;
40
+ context_window_hours?: number;
41
+ is_paused?: boolean;
42
+ pause_until?: string;
43
+ is_static?: boolean;
44
+ include_message_timestamps?: boolean;
45
+ tools?: Record<string, Record<string, boolean>>;
46
+ }
47
+
48
+ const PLACEHOLDER_TRAIT: YAMLTrait = {
49
+ name: "Example Trait",
50
+ description: "Delete this placeholder or modify it to define a real trait",
51
+ sentiment: 0,
52
+ strength: 0.5,
53
+ };
54
+
55
+ const PLACEHOLDER_TOPIC: YAMLPersonaTopic = {
56
+ name: "Example Topic",
57
+ perspective: "How this persona views or thinks about this topic",
58
+ approach: "How this persona prefers to engage with this topic",
59
+ personal_stake: "Why this topic matters to this persona personally",
60
+ exposure_current: 0.5,
61
+ exposure_desired: 0.5,
62
+ };
63
+
64
+ function buildPersonaToolsMap(
65
+ enabledToolIds: string[],
66
+ allTools: ToolDefinition[],
67
+ allProviders: import('../../../src/core/types.js').ToolProvider[]
68
+ ): Record<string, Record<string, boolean>> | undefined {
69
+ if (allTools.length === 0) return undefined;
70
+ const enabledSet = new Set(enabledToolIds);
71
+ const result: Record<string, Record<string, boolean>> = {};
72
+ for (const provider of allProviders.filter(p => p.enabled)) {
73
+ const providerTools = allTools.filter(t => t.provider_id === provider.id);
74
+ if (providerTools.length === 0) continue;
75
+ result[provider.display_name] = Object.fromEntries(
76
+ providerTools.map(t => [t.display_name, enabledSet.has(t.id)])
77
+ );
78
+ }
79
+ return Object.keys(result).length > 0 ? result : undefined;
80
+ }
81
+
82
+ function resolvePersonaToolsFromMap(
83
+ toolsMap: Record<string, Record<string, boolean>> | undefined,
84
+ allTools: ToolDefinition[],
85
+ allProviders: import('../../../src/core/types.js').ToolProvider[]
86
+ ): string[] | undefined {
87
+ if (!toolsMap) return undefined;
88
+ const enabledIds: string[] = [];
89
+ for (const [providerDisplayName, toolToggles] of Object.entries(toolsMap)) {
90
+ const provider = allProviders.find(p => p.display_name === providerDisplayName);
91
+ if (!provider) continue;
92
+ for (const [toolDisplayName, enabled] of Object.entries(toolToggles)) {
93
+ if (!enabled) continue;
94
+ const tool = allTools.find(t => t.provider_id === provider.id && t.display_name === toolDisplayName);
95
+ if (tool) enabledIds.push(tool.id);
96
+ }
97
+ }
98
+ return enabledIds.length > 0 ? enabledIds : [];
99
+ }
100
+
101
+ export function newPersonaToYAML(name: string, allTools?: ToolDefinition[], allProviders?: import('../../../src/core/types.js').ToolProvider[]): string {
102
+ const toolsMap = buildPersonaToolsMap([], allTools ?? [], allProviders ?? []);
103
+
104
+ const data: EditablePersonaData = {
105
+ display_name: name,
106
+ long_description: PLACEHOLDER_LONG_DESC,
107
+ model: undefined,
108
+ group_primary: "General",
109
+ groups_visible: [{ General: true }],
110
+ traits: [PLACEHOLDER_TRAIT],
111
+ topics: [PLACEHOLDER_TOPIC],
112
+ tools: toolsMap,
113
+ };
114
+
115
+ return YAML.stringify(data, {
116
+ lineWidth: 0,
117
+ });
118
+ }
119
+
120
+ export function newPersonaFromYAML(yamlContent: string, allTools?: ToolDefinition[], allProviders?: import('../../../src/core/types.js').ToolProvider[]): Partial<PersonaEntity> {
121
+ const data = YAML.parse(yamlContent) as EditablePersonaData;
122
+
123
+ const isTraitPlaceholder = (t: YAMLTrait) =>
124
+ t.name === PLACEHOLDER_TRAIT.name &&
125
+ t.description === PLACEHOLDER_TRAIT.description;
126
+
127
+ const traits: PersonaTrait[] = [];
128
+ for (const t of data.traits ?? []) {
129
+ if (isTraitPlaceholder(t)) continue;
130
+ traits.push({
131
+ id: crypto.randomUUID(),
132
+ name: t.name,
133
+ description: t.description,
134
+ sentiment: t.sentiment ?? 0,
135
+ strength: t.strength,
136
+ last_updated: new Date().toISOString(),
137
+ });
138
+ }
139
+
140
+ const isTopicPlaceholder = (t: YAMLPersonaTopic) =>
141
+ t.name === PLACEHOLDER_TOPIC.name &&
142
+ t.perspective === PLACEHOLDER_TOPIC.perspective;
143
+
144
+ const topics: PersonaTopic[] = [];
145
+ for (const t of data.topics ?? []) {
146
+ if (isTopicPlaceholder(t)) continue;
147
+ topics.push({
148
+ id: crypto.randomUUID(),
149
+ name: t.name,
150
+ perspective: t.perspective,
151
+ approach: t.approach,
152
+ personal_stake: t.personal_stake,
153
+ sentiment: 0,
154
+ exposure_current: t.exposure_current,
155
+ exposure_desired: t.exposure_desired,
156
+ last_updated: new Date().toISOString(),
157
+ });
158
+ }
159
+
160
+ const stripPlaceholder = (value: string | undefined, placeholder: string): string | undefined => {
161
+ return value === placeholder ? undefined : value;
162
+ };
163
+
164
+ const groupsVisible: string[] = [];
165
+ for (const groupRecord of data.groups_visible ?? []) {
166
+ for (const [groupName, isVisible] of Object.entries(groupRecord)) {
167
+ if (isVisible) groupsVisible.push(groupName);
168
+ }
169
+ }
170
+
171
+ return {
172
+ long_description: stripPlaceholder(data.long_description, PLACEHOLDER_LONG_DESC),
173
+ model: data.model ?? undefined,
174
+ group_primary: data.group_primary ?? "General",
175
+ groups_visible: groupsVisible.length > 0 ? groupsVisible : ["General"],
176
+ traits,
177
+ topics,
178
+ heartbeat_delay_ms: data.heartbeat_delay_ms === 'default' || data.heartbeat_delay_ms === undefined
179
+ ? undefined
180
+ : Number(data.heartbeat_delay_ms) || undefined,
181
+ context_window_hours: data.context_window_hours,
182
+ tools: resolvePersonaToolsFromMap(data.tools, allTools ?? [], allProviders ?? []),
183
+ };
184
+ }
185
+
186
+ export function personaToYAML(persona: PersonaEntity, allGroups?: string[], allTools?: ToolDefinition[], allProviders?: import('../../../src/core/types.js').ToolProvider[], accounts?: ProviderAccount[]): string {
187
+ const useTraitPlaceholder = persona.traits.length === 0;
188
+ const useTopicPlaceholder = persona.topics.length === 0;
189
+
190
+ const groupsForYAML: Record<string, boolean>[] = [];
191
+ const visibleSet = new Set(persona.groups_visible ?? []);
192
+ const groupsToShow = allGroups ?? persona.groups_visible ?? [];
193
+ for (const groupName of groupsToShow) {
194
+ groupsForYAML.push({ [groupName]: visibleSet.has(groupName) });
195
+ }
196
+
197
+ const toolsMap = buildPersonaToolsMap(persona.tools ?? [], allTools ?? [], allProviders ?? []);
198
+
199
+ const modelDisplay = (persona.model && accounts && accounts.length > 0)
200
+ ? modelGuidToDisplay(persona.model, accounts)
201
+ : persona.model;
202
+
203
+ const data: EditablePersonaData = {
204
+ display_name: persona.display_name,
205
+ aliases: persona.aliases,
206
+ short_description: persona.short_description,
207
+ long_description: persona.long_description || PLACEHOLDER_LONG_DESC,
208
+ model: modelDisplay ?? null,
209
+ group_primary: persona.group_primary,
210
+ groups_visible: groupsForYAML,
211
+ traits: useTraitPlaceholder
212
+ ? [PLACEHOLDER_TRAIT]
213
+ : persona.traits.map(({ name, description, sentiment, strength }) => ({ name, description, sentiment: sentiment ?? 0, strength: strength ?? 0.5 })),
214
+ topics: useTopicPlaceholder
215
+ ? [PLACEHOLDER_TOPIC]
216
+ : persona.topics.map(({ name, perspective, approach, personal_stake, exposure_current, exposure_desired }) => ({
217
+ name, perspective, approach, personal_stake, exposure_current, exposure_desired
218
+ })),
219
+ heartbeat_delay_ms: persona.heartbeat_delay_ms || 'default',
220
+ context_window_hours: persona.context_window_hours,
221
+ is_paused: persona.is_paused || undefined,
222
+ pause_until: persona.pause_until,
223
+ is_static: persona.is_static || undefined,
224
+ include_message_timestamps: persona.include_message_timestamps || undefined,
225
+ tools: toolsMap,
226
+ };
227
+
228
+ return YAML.stringify(data, {
229
+ lineWidth: 0,
230
+ });
231
+ }
232
+
233
+ export interface PersonaYAMLResult {
234
+ updates: Partial<PersonaEntity>;
235
+ deletedTraitIds: string[];
236
+ deletedTopicIds: string[];
237
+ }
238
+
239
+ export function personaFromYAML(yamlContent: string, original: PersonaEntity, allTools?: ToolDefinition[], allProviders?: import('../../../src/core/types.js').ToolProvider[], accounts?: ProviderAccount[]): PersonaYAMLResult {
240
+ const data = YAML.parse(yamlContent) as EditablePersonaData;
241
+
242
+ const deletedTraitIds: string[] = [];
243
+ const deletedTopicIds: string[] = [];
244
+
245
+ const isTraitPlaceholder = (t: YAMLTrait) =>
246
+ t.name === PLACEHOLDER_TRAIT.name &&
247
+ t.description === PLACEHOLDER_TRAIT.description;
248
+
249
+ const traits: PersonaTrait[] = [];
250
+ for (const t of data.traits ?? []) {
251
+ if (isTraitPlaceholder(t)) continue;
252
+ const existing = original.traits.find(orig => orig.name === t.name);
253
+ traits.push({
254
+ id: existing?.id ?? crypto.randomUUID(),
255
+ name: t.name,
256
+ description: t.description,
257
+ sentiment: t.sentiment ?? existing?.sentiment ?? 0,
258
+ strength: t.strength,
259
+ last_updated: new Date().toISOString(),
260
+ });
261
+ }
262
+
263
+ for (const orig of original.traits) {
264
+ if (!traits.some(t => t.id === orig.id)) {
265
+ deletedTraitIds.push(orig.id);
266
+ }
267
+ }
268
+
269
+ const isTopicPlaceholder = (t: YAMLPersonaTopic) =>
270
+ t.name === PLACEHOLDER_TOPIC.name &&
271
+ t.perspective === PLACEHOLDER_TOPIC.perspective;
272
+
273
+ const topics: PersonaTopic[] = [];
274
+ for (const t of data.topics ?? []) {
275
+ if (isTopicPlaceholder(t)) continue;
276
+ const existing = original.topics.find(orig => orig.name === t.name);
277
+ topics.push({
278
+ id: existing?.id ?? crypto.randomUUID(),
279
+ name: t.name,
280
+ perspective: t.perspective,
281
+ approach: t.approach,
282
+ personal_stake: t.personal_stake,
283
+ sentiment: existing?.sentiment ?? 0,
284
+ exposure_current: t.exposure_current,
285
+ exposure_desired: t.exposure_desired,
286
+ last_updated: new Date().toISOString(),
287
+ });
288
+ }
289
+
290
+ for (const orig of original.topics) {
291
+ if (!topics.some(t => t.id === orig.id)) {
292
+ deletedTopicIds.push(orig.id);
293
+ }
294
+ }
295
+
296
+ const stripPlaceholder = (value: string | undefined, placeholder: string): string | undefined => {
297
+ return value === placeholder ? undefined : value;
298
+ };
299
+
300
+ const groupsVisible: string[] = [];
301
+ for (const groupRecord of data.groups_visible ?? []) {
302
+ for (const [groupName, isVisible] of Object.entries(groupRecord)) {
303
+ if (isVisible) groupsVisible.push(groupName);
304
+ }
305
+ }
306
+
307
+ let resolvedModel: string | undefined = data.model ?? undefined;
308
+ if (data.model && accounts && accounts.length > 0) {
309
+ const guid = displayToModelGuid(data.model, accounts);
310
+ if (guid !== undefined) {
311
+ resolvedModel = guid;
312
+ } else if (data.model.includes(':')) {
313
+ throw new Error(`Model "${data.model}" not found. Use "ProviderName:modelName" format with a valid provider and model.`);
314
+ }
315
+ }
316
+
317
+ const updates: Partial<PersonaEntity> = {
318
+ display_name: data.display_name,
319
+ aliases: data.aliases,
320
+ short_description: data.short_description,
321
+ long_description: stripPlaceholder(data.long_description, PLACEHOLDER_LONG_DESC),
322
+ model: resolvedModel,
323
+ group_primary: data.group_primary,
324
+ groups_visible: groupsVisible,
325
+ traits,
326
+ topics,
327
+ heartbeat_delay_ms: data.heartbeat_delay_ms === 'default' || data.heartbeat_delay_ms === undefined
328
+ ? undefined
329
+ : Number(data.heartbeat_delay_ms) || undefined,
330
+ context_window_hours: data.context_window_hours,
331
+ is_paused: data.is_paused ?? false,
332
+ pause_until: data.pause_until,
333
+ is_static: data.is_static ?? false,
334
+ include_message_timestamps: data.include_message_timestamps ?? false,
335
+ tools: resolvePersonaToolsFromMap(data.tools, allTools ?? [], allProviders ?? []),
336
+ last_updated: new Date().toISOString(),
337
+ };
338
+
339
+ return { updates, deletedTraitIds, deletedTopicIds };
340
+ }
341
+
342
+ // =============================================================================
343
+ // PERSONA PREVIEW SERIALIZATION (from-person generation flow)
344
+ // =============================================================================
345
+
346
+ export function descriptionEntryToYAML(personaName: string): string {
347
+ return `# New Persona: ${personaName}
348
+ # Describe who this persona is. This will be used to generate traits and topics.
349
+ # Save to generate • :q to cancel.
350
+
351
+ description: |
352
+
353
+ `;
354
+ }
355
+
356
+ export function descriptionFromYAML(content: string): { description: string; relationship?: string } {
357
+ const data = YAML.parse(content) as { description?: string; relationship?: string };
358
+ if (!data) throw new Error("Failed to parse YAML");
359
+
360
+ const description = (data.description ?? "").replace(/\n/g, " ").trim();
361
+ const relationship = data.relationship?.trim() || undefined;
362
+
363
+ return { description, relationship };
364
+ }
365
+
366
+ export function personaPreviewToYAML(
367
+ preview: import('../../../src/prompts/generation/types.js').PersonaGenerationResult,
368
+ personaName: string,
369
+ personName?: string,
370
+ previousLongDescription?: string
371
+ ): string {
372
+ const normalizeLine = (s: string) => s.replace(/\n/g, ' ').trim();
373
+
374
+ const headerLines: string[] = [
375
+ `# Persona Preview: ${personaName}`,
376
+ ];
377
+ if (personName) {
378
+ headerLines.push(`# Source: ${personName}`);
379
+ }
380
+ headerLines.push(`# Edit or delete entries. Save or quit to apply • :cq to cancel.`);
381
+ headerLines.push(``);
382
+ if (previousLongDescription) {
383
+ headerLines.push(`# Previously: ${normalizeLine(previousLongDescription)}`);
384
+ }
385
+
386
+ const longDescYAML = `long_description: ${JSON.stringify(normalizeLine(preview.long_description))}`;
387
+ const shortDescYAML = `short_description: ${JSON.stringify(normalizeLine(preview.short_description ?? ''))}`;
388
+ const aliasesLine = (preview.aliases && preview.aliases.length > 0)
389
+ ? `aliases: ${preview.aliases.join(', ')}`
390
+ : null;
391
+
392
+ const traitsYAML = YAML.stringify(
393
+ { traits: preview.traits.map(t => ({
394
+ name: t.name,
395
+ description: t.description,
396
+ sentiment: Math.round(t.sentiment * 100) / 100,
397
+ strength: Math.round(t.strength * 100) / 100,
398
+ }))},
399
+ { lineWidth: 0 }
400
+ );
401
+
402
+ const topicsYAML = YAML.stringify(
403
+ { topics: preview.topics.map(t => ({
404
+ name: t.name,
405
+ perspective: t.perspective,
406
+ approach: t.approach,
407
+ personal_stake: t.personal_stake,
408
+ sentiment: Math.round(t.sentiment * 100) / 100,
409
+ exposure_current: Math.round(t.exposure_current * 100) / 100,
410
+ exposure_desired: Math.round(t.exposure_desired * 100) / 100,
411
+ }))},
412
+ { lineWidth: 0 }
413
+ );
414
+
415
+ return [
416
+ headerLines.join('\n'),
417
+ longDescYAML,
418
+ shortDescYAML,
419
+ ...(aliasesLine ? [aliasesLine] : []),
420
+ traitsYAML.trimEnd(),
421
+ topicsYAML.trimEnd(),
422
+ '',
423
+ ].join('\n');
424
+ }
425
+
426
+ interface PreviewYAMLData {
427
+ long_description?: string;
428
+ short_description?: string;
429
+ traits?: Array<{
430
+ name: string;
431
+ description: string;
432
+ sentiment?: number;
433
+ strength?: number;
434
+ }>;
435
+ topics?: Array<{
436
+ name: string;
437
+ perspective: string;
438
+ approach: string;
439
+ personal_stake: string;
440
+ sentiment?: number;
441
+ exposure_current?: number;
442
+ exposure_desired?: number;
443
+ }>;
444
+ }
445
+
446
+ export function personaPreviewFromYAML(content: string): { long_description: string; short_description?: string; aliases?: string[]; traits: PersonaTrait[]; topics: PersonaTopic[] } {
447
+ const data = YAML.parse(content) as PreviewYAMLData & { aliases?: string };
448
+ if (!data) throw new Error("Failed to parse YAML");
449
+
450
+ const long_description = (data.long_description ?? "").replace(/\n/g, ' ').trim();
451
+ const short_description = data.short_description?.replace(/\n/g, ' ').trim() || undefined;
452
+
453
+ const traits: PersonaTrait[] = (data.traits ?? []).map(t => ({
454
+ id: crypto.randomUUID(),
455
+ name: t.name,
456
+ description: t.description,
457
+ sentiment: t.sentiment ?? 0,
458
+ strength: t.strength ?? 0.5,
459
+ last_updated: new Date().toISOString(),
460
+ }));
461
+
462
+ const topics: PersonaTopic[] = (data.topics ?? []).map(t => ({
463
+ id: crypto.randomUUID(),
464
+ name: t.name,
465
+ perspective: t.perspective,
466
+ approach: t.approach,
467
+ personal_stake: t.personal_stake,
468
+ sentiment: t.sentiment ?? 0,
469
+ exposure_current: t.exposure_current ?? 0.5,
470
+ exposure_desired: t.exposure_desired ?? 0.5,
471
+ last_updated: new Date().toISOString(),
472
+ }));
473
+
474
+ const aliases = data.aliases
475
+ ? data.aliases.split(',').map((s: string) => s.trim()).filter(Boolean)
476
+ : undefined;
477
+
478
+ return { long_description, short_description, aliases, traits, topics };
479
+ }
@@ -0,0 +1,215 @@
1
+ import YAML from "yaml";
2
+ import type {
3
+ ProviderAccount,
4
+ ProviderType,
5
+ } from "../../../src/core/types.js";
6
+ import { modelGuidToDisplay } from "./yaml-shared.js";
7
+
8
+ interface EditableModelData {
9
+ name: string;
10
+ token_limit?: number;
11
+ max_output_tokens?: number;
12
+ _delete?: boolean;
13
+ }
14
+
15
+ interface EditableProviderData {
16
+ name: string;
17
+ type: "llm" | "storage";
18
+ url: string;
19
+ api_key?: string;
20
+ default_model?: string;
21
+ token_limit?: number | null;
22
+ extra_headers?: Record<string, string>;
23
+ enabled?: boolean;
24
+ models?: EditableModelData[];
25
+ _delete?: boolean;
26
+ }
27
+
28
+ export interface ProviderYAMLResult {
29
+ account: ProviderAccount;
30
+ _delete: boolean;
31
+ }
32
+
33
+ function resolveEnvVar(value: string | undefined): string | undefined {
34
+ if (!value || !value.startsWith("$")) return value;
35
+ const varName = value.slice(1);
36
+ return process.env[varName] || value;
37
+ }
38
+
39
+ const PLACEHOLDER_PROVIDER_NAME = "My Provider";
40
+ const PLACEHOLDER_PROVIDER_URL = "https://api.example.com/v1";
41
+ const PLACEHOLDER_PROVIDER_API_KEY = "your-api-key-or-$ENVAR";
42
+ const PLACEHOLDER_PROVIDER_DEFAULT_MODEL = "model-name";
43
+
44
+ function parseModels(editableModels: EditableModelData[]): import('../../../src/core/types.js').ModelConfig[] {
45
+ const result: import('../../../src/core/types.js').ModelConfig[] = [];
46
+ for (const m of editableModels) {
47
+ if (m._delete) continue;
48
+ result.push({
49
+ id: crypto.randomUUID(),
50
+ name: m.name,
51
+ token_limit: m.token_limit,
52
+ max_output_tokens: m.max_output_tokens,
53
+ });
54
+ }
55
+ return result;
56
+ }
57
+
58
+ export function newProviderToYAML(name?: string): string {
59
+ const placeholderData = {
60
+ name: name ?? PLACEHOLDER_PROVIDER_NAME,
61
+ type: "llm",
62
+ url: PLACEHOLDER_PROVIDER_URL,
63
+ api_key: PLACEHOLDER_PROVIDER_API_KEY,
64
+ default_model: PLACEHOLDER_PROVIDER_DEFAULT_MODEL,
65
+ token_limit: null,
66
+ extra_headers: {},
67
+ enabled: true,
68
+ };
69
+
70
+ const modelsYAML = [
71
+ "models:",
72
+ " - name: (default)",
73
+ " # _delete: true",
74
+ "# _delete: true # Delete this entire provider",
75
+ ].join("\n");
76
+
77
+ return YAML.stringify(placeholderData, { lineWidth: 0 }).trimEnd() + "\n" + modelsYAML + "\n";
78
+ }
79
+
80
+ export function newProviderFromYAML(yamlContent: string): ProviderAccount {
81
+ const cleaned = yamlContent
82
+ .split('\n')
83
+ .filter(line => !/^\s*#/.test(line))
84
+ .join('\n');
85
+ const data = YAML.parse(cleaned) as EditableProviderData;
86
+
87
+ if (!data.name || data.name === PLACEHOLDER_PROVIDER_NAME) {
88
+ throw new Error("Provider name is required");
89
+ }
90
+ if (!data.url || data.url === PLACEHOLDER_PROVIDER_URL) {
91
+ throw new Error("Provider URL is required");
92
+ }
93
+ if (data.api_key === PLACEHOLDER_PROVIDER_API_KEY) {
94
+ data.api_key = undefined;
95
+ }
96
+ if (data.default_model === PLACEHOLDER_PROVIDER_DEFAULT_MODEL) {
97
+ data.default_model = undefined;
98
+ }
99
+
100
+ if (data.token_limit !== undefined && data.token_limit !== null && (typeof data.token_limit !== "number" || isNaN(data.token_limit))) {
101
+ throw new Error(`token_limit must be a number (got: ${JSON.stringify(data.token_limit)}). Note: underscore separators (100_000) are not valid in YAML.`);
102
+ }
103
+
104
+ const models = parseModels(data.models ?? []);
105
+
106
+ return {
107
+ id: crypto.randomUUID(),
108
+ name: data.name,
109
+ type: (data.type === "storage" ? "storage" : "llm") as ProviderType,
110
+ url: data.url,
111
+ api_key: resolveEnvVar(data.api_key),
112
+ default_model: data.default_model,
113
+ token_limit: data.token_limit ?? undefined,
114
+ extra_headers: data.extra_headers && Object.keys(data.extra_headers).length > 0 ? data.extra_headers : undefined,
115
+ enabled: data.enabled ?? true,
116
+ models: models.length > 0 ? models : undefined,
117
+ created_at: new Date().toISOString(),
118
+ };
119
+ }
120
+
121
+ export function providerToYAML(account: ProviderAccount): string {
122
+ const defaultModelDisplay = account.default_model
123
+ ? modelGuidToDisplay(account.default_model, [account])
124
+ : undefined;
125
+
126
+ const topData = {
127
+ name: account.name,
128
+ type: account.type as "llm" | "storage",
129
+ url: account.url,
130
+ api_key: account.api_key,
131
+ default_model: defaultModelDisplay,
132
+ token_limit: account.token_limit ?? null,
133
+ extra_headers: account.extra_headers,
134
+ enabled: account.enabled ?? true,
135
+ };
136
+
137
+ const topYAML = YAML.stringify(topData, { lineWidth: 0 }).trimEnd();
138
+
139
+ const modelLines: string[] = ["models:"];
140
+ const modelList = account.models ?? [];
141
+ if (modelList.length > 0) {
142
+ for (const m of modelList) {
143
+ modelLines.push(` - name: ${m.name}`);
144
+ if (m.token_limit !== undefined) {
145
+ modelLines.push(` token_limit: ${m.token_limit}`);
146
+ }
147
+ if (m.max_output_tokens !== undefined) {
148
+ modelLines.push(` max_output_tokens: ${m.max_output_tokens}`);
149
+ }
150
+ modelLines.push(` _delete: false`);
151
+ }
152
+ } else {
153
+ modelLines.push(" - name: (default)");
154
+ modelLines.push(" _delete: false");
155
+ }
156
+ modelLines.push("_delete: false # Set to true to delete this entire provider");
157
+
158
+ return topYAML + "\n" + modelLines.join("\n") + "\n";
159
+ }
160
+
161
+ export function providerFromYAML(yamlContent: string, original: ProviderAccount): ProviderYAMLResult {
162
+ const cleaned = yamlContent
163
+ .split('\n')
164
+ .filter(line => !/^\s*#/.test(line))
165
+ .join('\n');
166
+ const data = YAML.parse(cleaned) as EditableProviderData;
167
+
168
+ if (!data.name) {
169
+ throw new Error("Provider name is required");
170
+ }
171
+ if (!data.url) {
172
+ throw new Error("Provider URL is required");
173
+ }
174
+
175
+ if (data.token_limit !== undefined && data.token_limit !== null && (typeof data.token_limit !== "number" || isNaN(data.token_limit))) {
176
+ throw new Error(`token_limit must be a number (got: ${JSON.stringify(data.token_limit)}). Note: underscore separators (100_000) are not valid in YAML.`);
177
+ }
178
+
179
+ if (data._delete) {
180
+ return { account: original, _delete: true };
181
+ }
182
+
183
+ const existingModels = original.models ?? [];
184
+ const parsedModels: import('../../../src/core/types.js').ModelConfig[] = [];
185
+ for (const m of data.models ?? []) {
186
+ if (m._delete) continue;
187
+ const existing = existingModels.find(em => em.name === m.name);
188
+ parsedModels.push({
189
+ id: existing?.id ?? crypto.randomUUID(),
190
+ name: m.name,
191
+ token_limit: m.token_limit,
192
+ max_output_tokens: m.max_output_tokens,
193
+ total_calls: existing?.total_calls,
194
+ total_tokens_in: existing?.total_tokens_in,
195
+ total_tokens_out: existing?.total_tokens_out,
196
+ last_used: existing?.last_used,
197
+ });
198
+ }
199
+
200
+ const account: ProviderAccount = {
201
+ id: original.id,
202
+ name: data.name,
203
+ type: (data.type === "storage" ? "storage" : "llm") as ProviderType,
204
+ url: data.url,
205
+ api_key: resolveEnvVar(data.api_key),
206
+ default_model: data.default_model,
207
+ token_limit: data.token_limit ?? undefined,
208
+ extra_headers: data.extra_headers && Object.keys(data.extra_headers).length > 0 ? data.extra_headers : undefined,
209
+ enabled: data.enabled ?? true,
210
+ models: parsedModels.length > 0 ? parsedModels : undefined,
211
+ created_at: original.created_at,
212
+ };
213
+
214
+ return { account, _delete: false };
215
+ }