@aragon-agent/cli 0.6.12 → 0.6.14
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/CHANGELOG.md +27 -0
- package/README.md +108 -31
- package/dist/agent/controller.d.ts +38 -2
- package/dist/agent/controller.js +185 -34
- package/dist/agent/model-profile-settings.d.ts +31 -0
- package/dist/agent/model-profile-settings.js +208 -0
- package/dist/agent/queued-messages.d.ts +12 -0
- package/dist/agent/queued-messages.js +18 -0
- package/dist/agent/reducer.d.ts +13 -1
- package/dist/agent/reducer.js +52 -12
- package/dist/boot/guard.d.ts +3 -3
- package/dist/boot/guard.js +3 -3
- package/dist/cli.js +48 -16
- package/dist/commands/builtins.js +66 -39
- package/dist/commands/queue-command.d.ts +3 -0
- package/dist/commands/queue-command.js +5 -0
- package/dist/commands/registry.d.ts +3 -0
- package/dist/compaction/child.d.ts +4 -2
- package/dist/compaction/child.js +8 -1
- package/dist/compaction/compactor.d.ts +7 -2
- package/dist/compaction/compactor.js +21 -16
- package/dist/compaction/context-command.js +10 -3
- package/dist/compaction/meter.js +2 -0
- package/dist/compaction/pressure.d.ts +1 -0
- package/dist/compaction/pressure.js +1 -0
- package/dist/compaction/types.d.ts +2 -0
- package/dist/compaction/wiring.d.ts +4 -3
- package/dist/compaction/wiring.js +10 -2
- package/dist/config/cli-commands.d.ts +2 -0
- package/dist/config/cli-commands.js +16 -4
- package/dist/config/load.d.ts +5 -2
- package/dist/config/load.js +23 -12
- package/dist/config/model-profile-resolution.d.ts +20 -0
- package/dist/config/model-profile-resolution.js +81 -0
- package/dist/config/model-profile-store.d.ts +67 -0
- package/dist/config/model-profile-store.js +153 -0
- package/dist/config/model-profiles.d.ts +44 -0
- package/dist/config/model-profiles.js +121 -0
- package/dist/config/schema.d.ts +11 -2
- package/dist/config/store.d.ts +11 -1
- package/dist/config/store.js +43 -27
- package/dist/fast/resolve.d.ts +2 -1
- package/dist/fast/resolve.js +6 -4
- package/dist/fast/wiring.d.ts +5 -3
- package/dist/fast/wiring.js +8 -4
- package/dist/input/stdin-filter.js +3 -2
- package/dist/logging/install.js +4 -2
- package/dist/logging/secret-registry.d.ts +2 -0
- package/dist/logging/secret-registry.js +18 -0
- package/dist/team/runtime.d.ts +2 -1
- package/dist/team/subagent.d.ts +3 -1
- package/dist/team/subagent.js +3 -1
- package/dist/ui/ActivityLine.d.ts +6 -70
- package/dist/ui/ActivityLine.js +12 -34
- package/dist/ui/App.d.ts +1 -0
- package/dist/ui/App.js +116 -215
- package/dist/ui/BottomStatusRow.d.ts +6 -86
- package/dist/ui/BottomStatusRow.js +11 -26
- package/dist/ui/Composer.d.ts +16 -83
- package/dist/ui/Composer.js +9 -100
- package/dist/ui/Logo.d.ts +3 -5
- package/dist/ui/Logo.js +4 -8
- package/dist/ui/PromptInput.d.ts +4 -1
- package/dist/ui/PromptInput.js +44 -35
- package/dist/ui/QueuePanel.d.ts +8 -0
- package/dist/ui/QueuePanel.js +9 -0
- package/dist/ui/QueueStatusRow.d.ts +2 -0
- package/dist/ui/QueueStatusRow.js +1 -1
- package/dist/ui/StatusBar.d.ts +13 -149
- package/dist/ui/StatusBar.js +27 -142
- package/dist/ui/capabilities.d.ts +2 -0
- package/dist/ui/capabilities.js +6 -0
- package/dist/ui/clipboard-task.d.ts +29 -0
- package/dist/ui/clipboard-task.js +66 -0
- package/dist/ui/clipboard.d.ts +29 -50
- package/dist/ui/clipboard.js +141 -75
- package/dist/ui/color-runtime.d.ts +5 -0
- package/dist/ui/color-runtime.js +29 -0
- package/dist/ui/composer-limits.d.ts +3 -4
- package/dist/ui/composer-limits.js +4 -9
- package/dist/ui/composer-rows.d.ts +1 -1
- package/dist/ui/composer-rows.js +24 -137
- package/dist/ui/editor-navigation.d.ts +45 -0
- package/dist/ui/editor-navigation.js +85 -0
- package/dist/ui/editor-reducer.d.ts +4 -0
- package/dist/ui/editor-reducer.js +29 -15
- package/dist/ui/glyphs.js +10 -8
- package/dist/ui/ink-runtime.d.ts +3 -0
- package/dist/ui/ink-runtime.js +30 -0
- package/dist/ui/interaction-copy.d.ts +77 -0
- package/dist/ui/interaction-copy.js +40 -0
- package/dist/ui/interaction-hints.d.ts +18 -0
- package/dist/ui/interaction-hints.js +92 -0
- package/dist/ui/layout/budget.js +1 -1
- package/dist/ui/layout/queue-layout.d.ts +19 -0
- package/dist/ui/layout/queue-layout.js +26 -0
- package/dist/ui/layout/status-layout.d.ts +55 -0
- package/dist/ui/layout/status-layout.js +110 -0
- package/dist/ui/layout/todo-layout.d.ts +1 -0
- package/dist/ui/layout/todo-layout.js +1 -1
- package/dist/ui/overlays/HelpOverlay.js +8 -8
- package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
- package/dist/ui/overlays/ModelProfileEditor.js +103 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
- package/dist/ui/overlays/ModelProfilePicker.js +167 -0
- package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
- package/dist/ui/overlays/QueueOverlay.js +79 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
- package/dist/ui/overlays/SettingsScreen.js +347 -3
- package/dist/ui/overlays/model-profile-state.d.ts +74 -0
- package/dist/ui/overlays/model-profile-state.js +119 -0
- package/dist/ui/render-cache.d.ts +2 -2
- package/dist/ui/render-cache.js +4 -3
- package/dist/ui/run-status-row.d.ts +7 -19
- package/dist/ui/run-status-row.js +2 -18
- package/dist/ui/selection/selection-controller.d.ts +2 -2
- package/dist/ui/selection/selection-controller.js +1 -0
- package/dist/ui/use-wheel-routing.js +2 -1
- package/package.json +2 -2
|
@@ -13,6 +13,7 @@ import process from 'node:process';
|
|
|
13
13
|
import { getConfigBackupPath, getConfigPath, getHomeRoot, getLogsDir, getPromptHistoryPath, getUiStatePath, isHomeOverridden, } from './app-paths.js';
|
|
14
14
|
import { getHomeMigrationBreadcrumbPath } from './migrate-home.js';
|
|
15
15
|
import { loadPersistedConfig, updatePersistedConfig } from './store.js';
|
|
16
|
+
import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
|
|
16
17
|
import { clampFastConfig, clampRetryConfig, clampCompactionConfig, parseThresholdInput, clampUpdateConfig, coercePositiveInt, maskSecret, DEFAULT_FAST_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TEAM_CONFIG, DEFAULT_COMPACTION_CONFIG, DEFAULT_UPDATE_CONFIG, } from './schema.js';
|
|
17
18
|
import { clampLogLevel } from '../logging/levels.js';
|
|
18
19
|
/** Config keys under `log.` that `aragon config set` accepts. */
|
|
@@ -386,12 +387,23 @@ export function warnIfRedactionDisabled(key, value) {
|
|
|
386
387
|
// get / list
|
|
387
388
|
// ---------------------------------------------------------------------------
|
|
388
389
|
/** Every secret is masked wherever it is printed, `--json` included (R-12). */
|
|
389
|
-
function maskConfig(config) {
|
|
390
|
+
export function maskConfig(config) {
|
|
390
391
|
const apiKeys = {};
|
|
391
392
|
for (const [provider, key] of Object.entries(config.apiKeys)) {
|
|
392
393
|
apiKeys[provider] = maskSecret(key);
|
|
393
394
|
}
|
|
394
|
-
|
|
395
|
+
const masked = { ...config, apiKeys };
|
|
396
|
+
if (config.modelProfiles !== undefined) {
|
|
397
|
+
if (validateModelProfiles(config.modelProfiles).length)
|
|
398
|
+
masked.modelProfiles = { invalid: true };
|
|
399
|
+
else {
|
|
400
|
+
const profiles = normalizeModelProfiles(config.modelProfiles);
|
|
401
|
+
masked.modelProfiles = { ...profiles, entries: profiles.entries.map((entry) => ({
|
|
402
|
+
...entry, apiKey: entry.apiKey ? 'Configured' : 'Missing',
|
|
403
|
+
})) };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return masked;
|
|
395
407
|
}
|
|
396
408
|
/**
|
|
397
409
|
* The human-readable label for a value whose raw form would mislead, or
|
|
@@ -503,8 +515,8 @@ export async function runConfigEdit() {
|
|
|
503
515
|
JSON.parse(readFileSync(file, 'utf-8'));
|
|
504
516
|
process.stdout.write(`${file} saved.\n`);
|
|
505
517
|
}
|
|
506
|
-
catch
|
|
507
|
-
process.stderr.write(`${file} is not valid JSON
|
|
518
|
+
catch {
|
|
519
|
+
process.stderr.write(`${file} is not valid JSON.\n` +
|
|
508
520
|
`The previous version is still at ${backup}.\n`);
|
|
509
521
|
process.exitCode = 1;
|
|
510
522
|
}
|
package/dist/config/load.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* legitimately long single tool run can never be watchdog-killed.
|
|
7
7
|
*/
|
|
8
8
|
import { type CliConfig } from './schema.js';
|
|
9
|
+
import type { ModelRole } from './model-profiles.js';
|
|
9
10
|
export interface CliFlags {
|
|
10
11
|
provider?: string;
|
|
11
12
|
model?: string;
|
|
@@ -240,9 +241,11 @@ export interface CliFlags {
|
|
|
240
241
|
* Resolve the effective config by merging all layers, then derive the runtime
|
|
241
242
|
* (non-persisted) fields and enforce the timeout invariant.
|
|
242
243
|
*/
|
|
243
|
-
export declare function loadConfig(flags?: CliFlags
|
|
244
|
+
export declare function loadConfig(flags?: CliFlags, options?: {
|
|
245
|
+
interactive?: boolean;
|
|
246
|
+
}): CliConfig;
|
|
244
247
|
/**
|
|
245
248
|
* Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
|
|
246
249
|
* The one-shot `--api-key` override wins, but only for the active provider.
|
|
247
250
|
*/
|
|
248
|
-
export declare function makeGetApiKey(config: CliConfig): (providerId: string) => string | undefined;
|
|
251
|
+
export declare function makeGetApiKey(config: CliConfig, role?: ModelRole): (providerId: string) => string | undefined;
|
package/dist/config/load.js
CHANGED
|
@@ -9,13 +9,14 @@ import process from 'node:process';
|
|
|
9
9
|
import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
|
|
10
10
|
import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
|
|
11
11
|
import { readConfigFile } from './store.js';
|
|
12
|
+
import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey } from './model-profile-resolution.js';
|
|
12
13
|
import { getConfigPath } from './app-paths.js';
|
|
13
14
|
import { setHistoryEnabled } from './prompt-history.js';
|
|
14
15
|
import { setEntryRetain } from '../agent/entry-limits.js';
|
|
15
16
|
import { readSubmitCount } from './ui-state.js';
|
|
16
17
|
import { getLogger } from '../logging/logger.js';
|
|
17
18
|
import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
|
|
18
|
-
import { detectCapabilities } from '../ui/capabilities.js';
|
|
19
|
+
import { detectCapabilities, resolveTuiCapabilities } from '../ui/capabilities.js';
|
|
19
20
|
/** Interpret a boolean-ish env value (`1/true/on/yes`) as `true`. */
|
|
20
21
|
function envFlagTrue(value) {
|
|
21
22
|
if (!value)
|
|
@@ -435,16 +436,22 @@ function resolveContextWindow(env, file) {
|
|
|
435
436
|
* Resolve the effective config by merging all layers, then derive the runtime
|
|
436
437
|
* (non-persisted) fields and enforce the timeout invariant.
|
|
437
438
|
*/
|
|
438
|
-
export function loadConfig(flags = {}) {
|
|
439
|
+
export function loadConfig(flags = {}, options = {}) {
|
|
439
440
|
const cwd = flags.cwd ? flags.cwd : process.cwd();
|
|
440
441
|
// `.env` must be loaded before reading env overrides.
|
|
441
442
|
loadDotenv(cwd);
|
|
442
443
|
const read = readConfigFile();
|
|
443
|
-
const
|
|
444
|
+
const projection = projectModelProfiles(read.config ?? {});
|
|
445
|
+
const file = projection.file;
|
|
444
446
|
const env = readEnvConfig();
|
|
445
447
|
if (read.parseError) {
|
|
446
448
|
getLogger().error('config', 'config_parse_failed', { error: read.parseError });
|
|
447
449
|
}
|
|
450
|
+
if (projection.issues.length) {
|
|
451
|
+
getLogger().warn('config', 'model_profiles_invalid', {
|
|
452
|
+
error: 'Invalid model profiles; using custom connections. Repair with aragon config edit.',
|
|
453
|
+
});
|
|
454
|
+
}
|
|
448
455
|
const provider = pick(flags.provider, env.partial.provider, file.provider) ?? DEFAULT_CONFIG.provider;
|
|
449
456
|
const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
|
|
450
457
|
const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
|
|
@@ -512,13 +519,17 @@ export function loadConfig(flags = {}) {
|
|
|
512
519
|
// registering twice costs a `Set.add`, missing once costs a credential.
|
|
513
520
|
registerSecretsFrom(apiKeys);
|
|
514
521
|
registerSecret(flags.apiKey);
|
|
515
|
-
const
|
|
522
|
+
const caps = options.interactive
|
|
523
|
+
? resolveTuiCapabilities(process.env, flags.color)
|
|
524
|
+
: detectCapabilities(process.env, process.stdout);
|
|
525
|
+
const color = options.interactive
|
|
526
|
+
? caps.colorLevel !== 0
|
|
527
|
+
: flags.color !== undefined ? flags.color : !process.env.NO_COLOR;
|
|
516
528
|
// `--no-color` implies calmer chrome; env / file may also opt in explicitly.
|
|
517
529
|
const reducedMotion = envFlagTrue(process.env.ARAGON_REDUCED_MOTION) ||
|
|
518
530
|
(file.reducedMotion ?? DEFAULT_CONFIG.reducedMotion) ||
|
|
519
531
|
!color;
|
|
520
532
|
// Detect terminal capabilities once; force monochrome when color is disabled.
|
|
521
|
-
const caps = detectCapabilities(process.env, process.stdout);
|
|
522
533
|
const colorLevel = color ? caps.colorLevel : 0;
|
|
523
534
|
// No flag and no env var: a privacy switch you set once, not a per-run knob.
|
|
524
535
|
const historyEnabled = file.historyEnabled ?? DEFAULT_CONFIG.historyEnabled;
|
|
@@ -543,6 +554,7 @@ export function loadConfig(flags = {}) {
|
|
|
543
554
|
// it through the same module-singleton channel `setHistoryEnabled` uses.
|
|
544
555
|
setEntryRetain(transcriptRetain);
|
|
545
556
|
const config = {
|
|
557
|
+
modelProfiles: projection.issues.length ? undefined : file.modelProfiles,
|
|
546
558
|
provider,
|
|
547
559
|
model,
|
|
548
560
|
baseUrl,
|
|
@@ -605,6 +617,10 @@ export function loadConfig(flags = {}) {
|
|
|
605
617
|
unicode: caps.unicode,
|
|
606
618
|
apiKeyOverride: flags.apiKey && flags.apiKey.trim().length > 0 ? flags.apiKey.trim() : undefined,
|
|
607
619
|
};
|
|
620
|
+
config.modelProfileState = resolveModelProfileState(config, config.modelProfiles, projection.issues.length > 0);
|
|
621
|
+
if (config.apiKeyOverride && (config.modelProfiles?.mainId || config.modelProfiles?.fastId)) {
|
|
622
|
+
config.apiKeyOverrideTarget = { provider, baseUrl: baseUrl?.trim() || null };
|
|
623
|
+
}
|
|
608
624
|
recordResolvedConfig(config);
|
|
609
625
|
return config;
|
|
610
626
|
}
|
|
@@ -646,11 +662,6 @@ function recordResolvedConfig(config) {
|
|
|
646
662
|
* Build the `getApiKey(providerId)` resolver the core `Agent` consumes.
|
|
647
663
|
* The one-shot `--api-key` override wins, but only for the active provider.
|
|
648
664
|
*/
|
|
649
|
-
export function makeGetApiKey(config) {
|
|
650
|
-
return (providerId) => {
|
|
651
|
-
if (config.apiKeyOverride && providerId === config.provider) {
|
|
652
|
-
return config.apiKeyOverride;
|
|
653
|
-
}
|
|
654
|
-
return config.apiKeys[providerId];
|
|
655
|
-
};
|
|
665
|
+
export function makeGetApiKey(config, role = 'main') {
|
|
666
|
+
return (providerId) => resolveModelRoleKey({ config, role, providerId });
|
|
656
667
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type CliConfig, type PersistedConfig } from './schema.js';
|
|
2
|
+
import { type ModelProfilesConfig, type ModelProfilesRuntime, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
|
|
3
|
+
export interface ProfileProjection {
|
|
4
|
+
file: Partial<PersistedConfig>;
|
|
5
|
+
state: ModelProfilesRuntime;
|
|
6
|
+
issues: ProfileValidationIssue[];
|
|
7
|
+
}
|
|
8
|
+
export interface RoleKeyInput {
|
|
9
|
+
config: CliConfig;
|
|
10
|
+
role: ModelRole;
|
|
11
|
+
providerId: string;
|
|
12
|
+
}
|
|
13
|
+
/** Compare configured endpoint spelling strictly, with null and undefined equivalent. */
|
|
14
|
+
export declare function normalizeProfileBaseUrl(value: string | null | undefined): string | null;
|
|
15
|
+
/** Project a valid library onto a copy without rewriting legacy preferences. */
|
|
16
|
+
export declare function projectModelProfiles(file: Partial<PersistedConfig>): ProfileProjection;
|
|
17
|
+
/** Derive binding status from current connections, including session/model overrides. */
|
|
18
|
+
export declare function resolveModelProfileState(config: CliConfig, profiles?: ModelProfilesConfig | undefined, invalid?: boolean): ModelProfilesRuntime;
|
|
19
|
+
/** Resolve only credentials scoped to the requested role and actual connection target. */
|
|
20
|
+
export declare function resolveModelRoleKey(input: RoleKeyInput): string | undefined;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG } from './schema.js';
|
|
2
|
+
import { emptyModelProfileState, normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
|
|
3
|
+
/** Compare configured endpoint spelling strictly, with null and undefined equivalent. */
|
|
4
|
+
export function normalizeProfileBaseUrl(value) {
|
|
5
|
+
return value?.trim() || null;
|
|
6
|
+
}
|
|
7
|
+
/** Project a valid library onto a copy without rewriting legacy preferences. */
|
|
8
|
+
export function projectModelProfiles(file) {
|
|
9
|
+
const issues = validateModelProfiles(file.modelProfiles);
|
|
10
|
+
const state = emptyModelProfileState(issues.length > 0);
|
|
11
|
+
const projected = { ...file };
|
|
12
|
+
if (issues.length || !file.modelProfiles)
|
|
13
|
+
return { file: projected, state, issues };
|
|
14
|
+
const library = normalizeModelProfiles(file.modelProfiles);
|
|
15
|
+
projected.modelProfiles = library;
|
|
16
|
+
for (const role of ['main', 'fast']) {
|
|
17
|
+
const entry = library.entries.find((profile) => profile.id === library[`${role}Id`]);
|
|
18
|
+
if (!entry)
|
|
19
|
+
continue;
|
|
20
|
+
state[role] = { selectedId: entry.id, appliedId: entry.id,
|
|
21
|
+
overriddenFields: [], invalid: false };
|
|
22
|
+
const connection = { provider: entry.provider, model: entry.model, baseUrl: entry.baseUrl };
|
|
23
|
+
if (role === 'main')
|
|
24
|
+
Object.assign(projected, connection);
|
|
25
|
+
else
|
|
26
|
+
projected.fast = { ...DEFAULT_CONFIG.fast, ...file.fast, ...connection,
|
|
27
|
+
baseUrl: entry.baseUrl ?? '' };
|
|
28
|
+
}
|
|
29
|
+
return { file: projected, state, issues };
|
|
30
|
+
}
|
|
31
|
+
/** Derive binding status from current connections, including session/model overrides. */
|
|
32
|
+
export function resolveModelProfileState(config, profiles = config.modelProfiles, invalid = false) {
|
|
33
|
+
const issues = validateModelProfiles(profiles);
|
|
34
|
+
const state = emptyModelProfileState(invalid || issues.length > 0);
|
|
35
|
+
if (issues.length || !profiles)
|
|
36
|
+
return state;
|
|
37
|
+
for (const role of ['main', 'fast']) {
|
|
38
|
+
const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
|
|
39
|
+
if (!entry)
|
|
40
|
+
continue;
|
|
41
|
+
const connection = role === 'main' ? config : config.fast;
|
|
42
|
+
const changed = ['provider', 'model', 'baseUrl'].filter((field) => field === 'baseUrl'
|
|
43
|
+
? normalizeProfileBaseUrl(connection[field]) !== normalizeProfileBaseUrl(entry[field])
|
|
44
|
+
: connection[field] !== entry[field]);
|
|
45
|
+
state[role] = { selectedId: entry.id,
|
|
46
|
+
appliedId: changed.some((field) => field !== 'model') ? null : entry.id,
|
|
47
|
+
overriddenFields: changed, invalid: false };
|
|
48
|
+
}
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
/** Resolve only credentials scoped to the requested role and actual connection target. */
|
|
52
|
+
export function resolveModelRoleKey(input) {
|
|
53
|
+
const { config, role, providerId } = input;
|
|
54
|
+
const shared = config.apiKeys[providerId] || undefined;
|
|
55
|
+
const profiles = validateModelProfiles(config.modelProfiles).length
|
|
56
|
+
? undefined : config.modelProfiles;
|
|
57
|
+
const profileMode = profiles && (profiles.mainId !== null || profiles.fastId !== null);
|
|
58
|
+
if (!profileMode) {
|
|
59
|
+
const target = config.apiKeyOverrideTarget;
|
|
60
|
+
if (target) {
|
|
61
|
+
return role === 'main' && providerId === config.provider
|
|
62
|
+
&& target.provider === config.provider
|
|
63
|
+
&& normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)
|
|
64
|
+
? config.apiKeyOverride || shared : shared;
|
|
65
|
+
}
|
|
66
|
+
return config.apiKeyOverride && providerId === config.provider
|
|
67
|
+
? config.apiKeyOverride : shared;
|
|
68
|
+
}
|
|
69
|
+
const actualProvider = role === 'main'
|
|
70
|
+
? config.provider : config.fast.provider || config.provider;
|
|
71
|
+
if (providerId !== actualProvider)
|
|
72
|
+
return shared;
|
|
73
|
+
const target = config.apiKeyOverrideTarget;
|
|
74
|
+
if (role === 'main' && config.apiKeyOverride && target?.provider === config.provider
|
|
75
|
+
&& normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)) {
|
|
76
|
+
return config.apiKeyOverride;
|
|
77
|
+
}
|
|
78
|
+
const state = resolveModelProfileState(config)[role];
|
|
79
|
+
const entry = profiles.entries.find((profile) => profile.id === state.appliedId);
|
|
80
|
+
return entry?.apiKey || shared;
|
|
81
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ModelProfilesConfig, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
|
|
2
|
+
import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig } from './schema.js';
|
|
3
|
+
export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log'>> {
|
|
4
|
+
fast?: Partial<FastConfig>;
|
|
5
|
+
compaction?: Partial<CompactionConfig>;
|
|
6
|
+
log?: Partial<LogConfig>;
|
|
7
|
+
}
|
|
8
|
+
export interface ModelSettingsDraft {
|
|
9
|
+
profiles?: ModelProfilesConfig;
|
|
10
|
+
patch: ModelSettingsPatch;
|
|
11
|
+
activateRoles: ModelRole[];
|
|
12
|
+
diskRevision: string;
|
|
13
|
+
liveRevision: number;
|
|
14
|
+
baseline: PersistedConfig;
|
|
15
|
+
liveBaseline: CliConfig;
|
|
16
|
+
readError?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ModelSettingsSaveResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
persisted: boolean;
|
|
21
|
+
status: 'applied' | 'restart_required' | 'rejected' | 'saved_apply_failed';
|
|
22
|
+
code?: 'invalid' | 'busy' | 'conflict' | 'read_failed' | 'write_failed' | 'profile_key_required' | 'legacy_conversion_unsupported' | 'apply_failed';
|
|
23
|
+
issues?: ProfileValidationIssue[];
|
|
24
|
+
restartRequired?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type ConfigReadError = 'invalid_json' | 'invalid_root' | 'read_failed';
|
|
27
|
+
export interface ModelSettingsDisk {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
config: PersistedConfig;
|
|
30
|
+
parsed: Partial<PersistedConfig> | null;
|
|
31
|
+
raw: Buffer | null;
|
|
32
|
+
revision: string;
|
|
33
|
+
error?: ConfigReadError;
|
|
34
|
+
}
|
|
35
|
+
/** A stable, safe error for adapters; never includes caught exception messages. */
|
|
36
|
+
export declare class ModelProfileConfigError extends Error {
|
|
37
|
+
readonly code: NonNullable<ModelSettingsSaveResult['code']>;
|
|
38
|
+
constructor(code: NonNullable<ModelSettingsSaveResult['code']>);
|
|
39
|
+
}
|
|
40
|
+
/** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
|
|
41
|
+
export declare function readModelSettingsDisk(): ModelSettingsDisk;
|
|
42
|
+
export interface ModelSettingsCommitInput {
|
|
43
|
+
candidate: PersistedConfig;
|
|
44
|
+
diskRevision: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ModelSettingsStoreDependencies {
|
|
47
|
+
read: () => ModelSettingsDisk;
|
|
48
|
+
write: (candidate: PersistedConfig) => void;
|
|
49
|
+
}
|
|
50
|
+
/** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
|
|
51
|
+
export declare function commitModelSettings(input: ModelSettingsCommitInput, dependencies?: ModelSettingsStoreDependencies): ModelSettingsSaveResult;
|
|
52
|
+
export interface LegacyPatchInput {
|
|
53
|
+
current: PersistedConfig;
|
|
54
|
+
patch: ModelSettingsPatch & {
|
|
55
|
+
modelProfiles?: ModelProfilesConfig;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
|
|
59
|
+
export declare function adaptLegacyConnectionPatch(input: LegacyPatchInput): LegacyPatchInput['patch'];
|
|
60
|
+
/** A detached default endpoint must not acquire a gateway through legacy inheritance. */
|
|
61
|
+
export declare function assertLegacyFastTarget(fast: {
|
|
62
|
+
provider: string;
|
|
63
|
+
baseUrl?: string | null;
|
|
64
|
+
}, main: {
|
|
65
|
+
provider: string;
|
|
66
|
+
baseUrl?: string | null;
|
|
67
|
+
}): void;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { getConfigPath } from './app-paths.js';
|
|
4
|
+
import { normalizePersistedConfig, writeConfigFile } from './store.js';
|
|
5
|
+
import { registerProfileSecrets } from '../logging/secret-registry.js';
|
|
6
|
+
import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
|
|
7
|
+
import { normalizeProfileBaseUrl, projectModelProfiles } from './model-profile-resolution.js';
|
|
8
|
+
/** A stable, safe error for adapters; never includes caught exception messages. */
|
|
9
|
+
export class ModelProfileConfigError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super(code === 'profile_key_required' || code === 'legacy_conversion_unsupported'
|
|
13
|
+
? `${code}: Use /settings to edit or copy this profile.`
|
|
14
|
+
: `${code}: Check the configuration with aragon config edit.`);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = 'ModelProfileConfigError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
|
|
20
|
+
export function readModelSettingsDisk() {
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = readFileSync(getConfigPath());
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const missing = error.code === 'ENOENT';
|
|
27
|
+
return { ok: missing, config: normalizePersistedConfig({}), parsed: null, raw: null,
|
|
28
|
+
revision: missing ? 'missing' : 'unreadable',
|
|
29
|
+
...(missing ? {} : { error: 'read_failed' }) };
|
|
30
|
+
}
|
|
31
|
+
const revision = createHash('sha256').update(raw).digest('hex');
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw.toString('utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return failedRead(raw, revision, 'invalid_json');
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
40
|
+
return failedRead(raw, revision, 'invalid_root');
|
|
41
|
+
}
|
|
42
|
+
registerProfileSecrets(parsed);
|
|
43
|
+
return { ok: true, raw, revision, parsed: parsed,
|
|
44
|
+
config: normalizePersistedConfig(parsed) };
|
|
45
|
+
}
|
|
46
|
+
function failedRead(raw, revision, error) {
|
|
47
|
+
return { ok: false, raw, revision, error, config: normalizePersistedConfig({}), parsed: null };
|
|
48
|
+
}
|
|
49
|
+
/** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
|
|
50
|
+
export function commitModelSettings(input, dependencies = {
|
|
51
|
+
read: readModelSettingsDisk, write: writeConfigFile,
|
|
52
|
+
}) {
|
|
53
|
+
const current = dependencies.read();
|
|
54
|
+
if (!current.ok)
|
|
55
|
+
return rejected('read_failed');
|
|
56
|
+
if (current.revision !== input.diskRevision)
|
|
57
|
+
return rejected('conflict');
|
|
58
|
+
if (validateModelProfiles(current.config.modelProfiles).length)
|
|
59
|
+
return rejected('invalid');
|
|
60
|
+
registerProfileSecrets(input.candidate);
|
|
61
|
+
const issues = validateModelProfiles(input.candidate.modelProfiles);
|
|
62
|
+
if (issues.length)
|
|
63
|
+
return { ...rejected('invalid'), issues };
|
|
64
|
+
try {
|
|
65
|
+
dependencies.write(input.candidate);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return rejected('write_failed');
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, persisted: true, status: 'applied' };
|
|
71
|
+
}
|
|
72
|
+
function rejected(code) {
|
|
73
|
+
return { ok: false, persisted: false, status: 'rejected', code };
|
|
74
|
+
}
|
|
75
|
+
function touchesConnection(patch) {
|
|
76
|
+
return Boolean(patch && ['provider', 'model', 'baseUrl'].some((key) => Object.prototype.hasOwnProperty.call(patch, key)));
|
|
77
|
+
}
|
|
78
|
+
/** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
|
|
79
|
+
export function adaptLegacyConnectionPatch(input) {
|
|
80
|
+
const { current, patch } = input;
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'modelProfiles')) {
|
|
82
|
+
if (validateModelProfiles(current.modelProfiles).length
|
|
83
|
+
|| validateModelProfiles(patch.modelProfiles).length) {
|
|
84
|
+
throw new ModelProfileConfigError('invalid');
|
|
85
|
+
}
|
|
86
|
+
let result = patch;
|
|
87
|
+
for (const role of ['main', 'fast']) {
|
|
88
|
+
if (!patch.modelProfiles?.[`${role}Id`]) {
|
|
89
|
+
result = adaptUnboundProviders(current, result, role);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
const roles = ['main', 'fast'].filter((role) => touchesConnection(role === 'main' ? patch : patch.fast));
|
|
95
|
+
if (!roles.length)
|
|
96
|
+
return patch;
|
|
97
|
+
if (!current.modelProfiles)
|
|
98
|
+
return adaptUnboundProviders(current, patch);
|
|
99
|
+
if (validateModelProfiles(current.modelProfiles).length) {
|
|
100
|
+
throw new ModelProfileConfigError('invalid');
|
|
101
|
+
}
|
|
102
|
+
const profiles = normalizeModelProfiles(current.modelProfiles);
|
|
103
|
+
const result = { ...patch, modelProfiles: profiles };
|
|
104
|
+
for (const role of roles)
|
|
105
|
+
adaptRole(current, result, role);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function adaptUnboundProviders(current, patch, role) {
|
|
109
|
+
const result = { ...patch };
|
|
110
|
+
if (role !== 'fast' && patch.provider !== undefined && patch.provider !== current.provider
|
|
111
|
+
&& !Object.prototype.hasOwnProperty.call(patch, 'baseUrl'))
|
|
112
|
+
result.baseUrl = null;
|
|
113
|
+
if (role !== 'main' && patch.fast?.provider !== undefined
|
|
114
|
+
&& patch.fast.provider !== current.fast.provider
|
|
115
|
+
&& !Object.prototype.hasOwnProperty.call(patch.fast, 'baseUrl')) {
|
|
116
|
+
result.fast = { ...patch.fast, baseUrl: '' };
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function adaptRole(current, patch, role) {
|
|
121
|
+
const profiles = patch.modelProfiles;
|
|
122
|
+
const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
|
|
123
|
+
if (!entry) {
|
|
124
|
+
Object.assign(patch, adaptUnboundProviders(current, patch, role));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (entry.apiKey)
|
|
128
|
+
throw new ModelProfileConfigError('profile_key_required');
|
|
129
|
+
const explicit = role === 'main' ? patch : patch.fast;
|
|
130
|
+
const provider = explicit.provider ?? entry.provider;
|
|
131
|
+
const changedProvider = provider !== entry.provider;
|
|
132
|
+
const baseUrl = Object.prototype.hasOwnProperty.call(explicit, 'baseUrl')
|
|
133
|
+
? explicit.baseUrl ?? null : changedProvider ? null : entry.baseUrl;
|
|
134
|
+
const connection = { provider, model: explicit.model ?? entry.model, baseUrl };
|
|
135
|
+
if (role === 'fast') {
|
|
136
|
+
const main = projectModelProfiles(current).file;
|
|
137
|
+
const mainProvider = patch.provider ?? main.provider ?? current.provider;
|
|
138
|
+
const mainUrl = Object.prototype.hasOwnProperty.call(patch, 'baseUrl')
|
|
139
|
+
? patch.baseUrl : main.baseUrl;
|
|
140
|
+
assertLegacyFastTarget(connection, { provider: mainProvider, baseUrl: mainUrl });
|
|
141
|
+
patch.fast = { ...connection, ...patch.fast, baseUrl: baseUrl ?? '' };
|
|
142
|
+
}
|
|
143
|
+
else
|
|
144
|
+
Object.assign(patch, connection);
|
|
145
|
+
profiles[`${role}Id`] = null;
|
|
146
|
+
}
|
|
147
|
+
/** A detached default endpoint must not acquire a gateway through legacy inheritance. */
|
|
148
|
+
export function assertLegacyFastTarget(fast, main) {
|
|
149
|
+
if (!normalizeProfileBaseUrl(fast.baseUrl) && (fast.provider || main.provider) === main.provider
|
|
150
|
+
&& normalizeProfileBaseUrl(main.baseUrl)) {
|
|
151
|
+
throw new ModelProfileConfigError('legacy_conversion_unsupported');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type ModelRole = 'main' | 'fast';
|
|
2
|
+
export interface ModelProfile {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
baseUrl: string | null;
|
|
8
|
+
apiKey: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface ModelProfilesConfig {
|
|
11
|
+
version: 1;
|
|
12
|
+
entries: ModelProfile[];
|
|
13
|
+
mainId: string | null;
|
|
14
|
+
fastId: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface ProfileBindingState {
|
|
17
|
+
selectedId: string | null;
|
|
18
|
+
appliedId: string | null;
|
|
19
|
+
overriddenFields: Array<'provider' | 'model' | 'baseUrl'>;
|
|
20
|
+
invalid: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface ModelProfilesRuntime {
|
|
23
|
+
main: ProfileBindingState;
|
|
24
|
+
fast: ProfileBindingState;
|
|
25
|
+
}
|
|
26
|
+
export interface ProfileValidationIssue {
|
|
27
|
+
path: string;
|
|
28
|
+
code: 'invalid' | 'duplicate_id' | 'missing_reference' | 'unsupported_version';
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
/** Return a fresh empty library without initiating a disk migration. */
|
|
32
|
+
export declare function emptyModelProfiles(): ModelProfilesConfig;
|
|
33
|
+
/** Return independently mutable role state. */
|
|
34
|
+
export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
|
|
35
|
+
/** Validate untrusted input; diagnostics never include supplied values. */
|
|
36
|
+
export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
|
|
37
|
+
/** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
|
|
38
|
+
export declare function normalizeModelProfiles(value: unknown): ModelProfilesConfig;
|
|
39
|
+
/** Create an entry with a stable UUID and a frozen default name. */
|
|
40
|
+
export declare function createModelProfile(input: Omit<ModelProfile, 'id'>): ModelProfile;
|
|
41
|
+
/** Duplicate the current draft entry, including its explicitly edited credential. */
|
|
42
|
+
export declare function duplicateModelProfile(entry: ModelProfile): ModelProfile;
|
|
43
|
+
/** Remove an unused entry; binding references must be changed explicitly first. */
|
|
44
|
+
export declare function removeModelProfile(library: ModelProfilesConfig, id: string): ModelProfilesConfig;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ADAPTER_PROVIDERS } from './schema.js';
|
|
3
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
4
|
+
/** Return a fresh empty library without initiating a disk migration. */
|
|
5
|
+
export function emptyModelProfiles() {
|
|
6
|
+
return { version: 1, entries: [], mainId: null, fastId: null };
|
|
7
|
+
}
|
|
8
|
+
/** Return independently mutable role state. */
|
|
9
|
+
export function emptyModelProfileState(invalid = false) {
|
|
10
|
+
const binding = () => ({ selectedId: null, appliedId: null,
|
|
11
|
+
overriddenFields: [], invalid });
|
|
12
|
+
return { main: binding(), fast: binding() };
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function validText(value, max, allowEmpty = false) {
|
|
18
|
+
return typeof value === 'string' && !CONTROL.test(value)
|
|
19
|
+
&& (allowEmpty || value.trim().length > 0) && [...value.trim()].length <= max;
|
|
20
|
+
}
|
|
21
|
+
function validUrl(value) {
|
|
22
|
+
if (value === null || (typeof value === 'string' && !value.trim()))
|
|
23
|
+
return true;
|
|
24
|
+
if (typeof value !== 'string' || CONTROL.test(value))
|
|
25
|
+
return false;
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(value.trim());
|
|
28
|
+
return ['http:', 'https:'].includes(url.protocol) && Boolean(url.hostname)
|
|
29
|
+
&& !url.username && !url.password && !value.includes('?') && !value.includes('#');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function validateEntry(value, index) {
|
|
36
|
+
const path = `modelProfiles.entries[${index}]`;
|
|
37
|
+
const invalid = (field, message) => ({
|
|
38
|
+
path: field ? `${path}.${field}` : path, code: 'invalid', message,
|
|
39
|
+
});
|
|
40
|
+
if (!isRecord(value))
|
|
41
|
+
return [invalid('', 'Profile must be an object.')];
|
|
42
|
+
const issues = [];
|
|
43
|
+
if (!validText(value.id, 128))
|
|
44
|
+
issues.push(invalid('id', 'ID must have 1-128 safe characters.'));
|
|
45
|
+
if (!validText(value.name, 80, true)) {
|
|
46
|
+
issues.push(invalid('name', 'Name must have at most 80 safe characters.'));
|
|
47
|
+
}
|
|
48
|
+
if (!ADAPTER_PROVIDERS.includes(value.provider)) {
|
|
49
|
+
issues.push(invalid('provider', 'Select a supported provider.'));
|
|
50
|
+
}
|
|
51
|
+
if (!validText(value.model, 256)) {
|
|
52
|
+
issues.push(invalid('model', 'Model must have 1-256 safe characters.'));
|
|
53
|
+
}
|
|
54
|
+
if (!validUrl(value.baseUrl)) {
|
|
55
|
+
issues.push(invalid('baseUrl', 'Use an HTTP(S) URL without credentials, query or fragment.'));
|
|
56
|
+
}
|
|
57
|
+
if (value.apiKey !== null && !validText(value.apiKey, 8192)) {
|
|
58
|
+
issues.push(invalid('apiKey', 'Enter a nonempty key up to 8192 safe characters.'));
|
|
59
|
+
}
|
|
60
|
+
return issues;
|
|
61
|
+
}
|
|
62
|
+
/** Validate untrusted input; diagnostics never include supplied values. */
|
|
63
|
+
export function validateModelProfiles(value) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return [];
|
|
66
|
+
if (!isRecord(value))
|
|
67
|
+
return [{ path: 'modelProfiles', code: 'invalid',
|
|
68
|
+
message: 'Model profiles must be an object. Repair with aragon config edit.' }];
|
|
69
|
+
if (value.version !== 1)
|
|
70
|
+
return [{ path: 'modelProfiles.version',
|
|
71
|
+
code: 'unsupported_version', message: 'Unsupported model profiles version.' }];
|
|
72
|
+
if (!Array.isArray(value.entries) || value.entries.length > 200) {
|
|
73
|
+
return [{ path: 'modelProfiles.entries', code: 'invalid',
|
|
74
|
+
message: 'Use an array with at most 200 profiles; remove unused profiles to add more.' }];
|
|
75
|
+
}
|
|
76
|
+
const issues = value.entries.flatMap(validateEntry);
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
value.entries.forEach((entry, index) => {
|
|
79
|
+
if (!isRecord(entry) || typeof entry.id !== 'string')
|
|
80
|
+
return;
|
|
81
|
+
if (ids.has(entry.id))
|
|
82
|
+
issues.push({ path: `modelProfiles.entries[${index}].id`,
|
|
83
|
+
code: 'duplicate_id', message: 'Profile IDs must be unique.' });
|
|
84
|
+
ids.add(entry.id);
|
|
85
|
+
});
|
|
86
|
+
for (const field of ['mainId', 'fastId']) {
|
|
87
|
+
if (value[field] !== null && (typeof value[field] !== 'string' || !ids.has(value[field]))) {
|
|
88
|
+
issues.push({ path: `modelProfiles.${field}`, code: 'missing_reference',
|
|
89
|
+
message: 'Select an existing profile or Current custom.' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return issues;
|
|
93
|
+
}
|
|
94
|
+
/** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
|
|
95
|
+
export function normalizeModelProfiles(value) {
|
|
96
|
+
if (validateModelProfiles(value).length)
|
|
97
|
+
throw new Error('Invalid model profiles.');
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return emptyModelProfiles();
|
|
100
|
+
const library = value;
|
|
101
|
+
return { version: 1, mainId: library.mainId, fastId: library.fastId,
|
|
102
|
+
entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
|
|
103
|
+
|| [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
|
|
104
|
+
model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
|
|
105
|
+
apiKey: entry.apiKey?.trim() || null })) };
|
|
106
|
+
}
|
|
107
|
+
/** Create an entry with a stable UUID and a frozen default name. */
|
|
108
|
+
export function createModelProfile(input) {
|
|
109
|
+
const entry = { ...input, id: randomUUID() };
|
|
110
|
+
return normalizeModelProfiles({ ...emptyModelProfiles(), entries: [entry] }).entries[0];
|
|
111
|
+
}
|
|
112
|
+
/** Duplicate the current draft entry, including its explicitly edited credential. */
|
|
113
|
+
export function duplicateModelProfile(entry) {
|
|
114
|
+
return { ...entry, id: randomUUID(), name: `${[...entry.name].slice(0, 73).join('')} (copy)` };
|
|
115
|
+
}
|
|
116
|
+
/** Remove an unused entry; binding references must be changed explicitly first. */
|
|
117
|
+
export function removeModelProfile(library, id) {
|
|
118
|
+
if (library.mainId === id || library.fastId === id)
|
|
119
|
+
throw new Error('Profile is still in use.');
|
|
120
|
+
return { ...library, entries: library.entries.filter((entry) => entry.id !== id) };
|
|
121
|
+
}
|