File size: 8,810 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
export interface KeyCommandOptions {
  url: string;
  args: string[];
  token?: string;
  key?: string;
  keyId?: number;
  timeoutMs?: number;
  dryRun: boolean;
}

interface KeyRow {
  id: number;
  platform: string;
  enabled: boolean;
  status: string;
  lastHealthError?: string | null;
  modelScope?: string[] | null;
  models?: Array<{ id: number; kind: string }>;
}

interface ModelRow {
  id: number;
  platform: string;
  modelId: string;
  enabled: boolean;
  keyId?: number | null;
}

export function keysHelp(): string {
  return [
    'Provider key management (dashboard authentication):',
    '  freellmapi keys add <platform> [--key KEY]',
    '  freellmapi keys list',
    '  freellmapi keys remove <platform> [--id ID]',
    '  freellmapi keys test <platform> [--id ID]',
    '',
    'Options: --url URL, --token TOKEN, --timeout MS (default 30000)',
    'Use FREELLMAPI_DASHBOARD_TOKEN or --token for the dashboard session token.',
    'The unified --api-key / FREELLMAPI_API_KEY is not dashboard authentication.',
    'Omit --key for hidden terminal input. Keyless providers do not prompt.',
    'remove requires --id when a platform has multiple keys; test checks all matches.',
    'list shows enabled chat model counts, respecting each key\'s model scope.',
    'add saves the key, then checks it; failed validation leaves it saved and exits 1.',
  ].join('\n');
}

function gatewayRoot(value: string): string {
  let url: URL;
  try { url = new URL(value.trim()); } catch { throw new Error('--url must be an HTTP(S) gateway URL'); }
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
    throw new Error('--url must be an HTTP(S) URL without credentials, a query, or a fragment');
  }
  return url.href.replace(/\/+$/, '').replace(/\/v1$/i, '');
}

function modelCount(key: KeyRow, models: ModelRow[]): number {
  const customIds = new Set(key.models?.filter(m => m.kind === 'chat').map(m => m.id));
  return models.filter(model => model.enabled && model.platform === key.platform
    && (!key.modelScope?.length || key.modelScope.includes(model.modelId))
    && (key.platform === 'custom' ? customIds.has(model.id) : !model.keyId || model.keyId === key.id)).length;
}

const statuses = new Set(['healthy', 'invalid', 'unknown', 'rate_limited', 'error']);
function statusOf(status: string): string {
  return statuses.has(status) ? status : 'unknown';
}

export async function runKeys(
  options: KeyCommandOptions,
  prompt: () => Promise<string>,
): Promise<number> {
  const [action, rawPlatform, ...extra] = options.args;
  if (!action || !['add', 'list', 'remove', 'test'].includes(action)) {
    throw new Error(`Expected keys add, list, remove, or test.\n\n${keysHelp()}`);
  }
  if (extra.length || (action === 'list' ? rawPlatform !== undefined : !rawPlatform)) {
    throw new Error(`Invalid arguments for keys ${action}.\n\n${keysHelp()}`);
  }
  if (options.dryRun) throw new Error('--dry-run is not supported by keys; no request was made');
  if (options.key !== undefined && action !== 'add') throw new Error('--key is only supported by keys add');
  if (options.keyId !== undefined && action !== 'remove' && action !== 'test') {
    throw new Error('--id is only supported by keys remove and keys test');
  }
  const platform = rawPlatform?.toLowerCase();
  if (platform && !/^[a-z][a-z0-9_-]*$/.test(platform)) throw new Error('Invalid provider platform identifier');
  const token = options.token?.trim();
  if (!token) throw new Error('Set FREELLMAPI_DASHBOARD_TOKEN or pass --token with a dashboard session token');
  const root = gatewayRoot(options.url);
  const timeout = options.timeoutMs ?? 30_000;
  if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 2_147_483_647) {
    throw new Error('--timeout must be a positive integer of at most 2147483647 milliseconds');
  }

  async function request<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
    let response: Response;
    try {
      response = await fetch(`${root}${path}`, {
        method,
        headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(timeout),
        redirect: 'error',
        cache: 'no-store',
      });
    } catch {
      throw new Error('Could not complete the dashboard request. Check --url, connectivity, and --timeout.');
    }
    // Provider error bodies may contain credentials. Only report the HTTP
    // status, never echo arbitrary response bodies or transport errors.
    if (response.status === 401 || response.status === 403) {
      throw new Error('Dashboard authentication failed. Supply a current dashboard session token with --token or FREELLMAPI_DASHBOARD_TOKEN.');
    }
    if (!response.ok) throw new Error(`Dashboard request failed (HTTP ${response.status})`);
    if (response.status === 204) return undefined as T;
    try { return await response.json() as T; } catch { throw new Error('The gateway returned an invalid JSON response'); }
  }

  async function listKeys(): Promise<KeyRow[]> {
    const rows = await request<KeyRow[]>('/api/keys');
    if (!Array.isArray(rows) || rows.some(row => !row || !Number.isSafeInteger(row.id)
      || row.id <= 0 || typeof row.platform !== 'string')) {
      throw new Error('The gateway returned an invalid key list');
    }
    return rows;
  }

  async function check(id: number, provider: string): Promise<number> {
    const result = await request<{ keyId: number; status: string }>(`/api/health/check/${id}`, 'POST');
    if (!result || result.keyId !== id) throw new Error('The gateway returned an invalid health check result');
    // A transport failure preserves the previous status on the server, so
    // HTTP 200 / status=healthy alone cannot prove this check succeeded.
    const current = (await listKeys()).find(row => row.id === id);
    if (!current) throw new Error('The key no longer exists');
    const status = statusOf(result.status === 'healthy' ? current.status : result.status);
    const inconclusive = !!current.lastHealthError && status !== 'invalid';
    process.stdout.write(`${id}\t${provider}\t${inconclusive ? `inconclusive (stored status: ${statusOf(current.status)})` : status}\n`);
    return status === 'healthy' && !inconclusive ? 0 : 1;
  }

  if (action === 'add') {
    const { providers } = await request<{ providers: Array<{ platform: string; keyless?: boolean }> }>('/api/keys/providers');
    const provider = providers.find(p => p.platform === platform);
    if (!provider) {
      throw new Error(platform === 'custom'
        ? 'Custom endpoints require URL and model registration; add them in the dashboard.'
        : 'Unknown provider platform. Use the provider identifier shown in the dashboard.');
    }
    const value = (options.key ?? (provider.keyless ? '' : await prompt())).trim();
    if (!value && !provider.keyless) throw new Error('A provider API key is required');
    const saved = await request<{ id: number }>('/api/keys', 'POST', { platform, key: value });
    if (!Number.isSafeInteger(saved.id) || saved.id <= 0) throw new Error('The gateway did not return a valid saved key ID');
    process.stdout.write(`Saved ${platform} key ${saved.id}. Checking it now; the key remains saved if validation fails.\n`);
    return check(saved.id, platform!);
  }

  const rows = await listKeys();
  if (action === 'list') {
    if (!rows.length) {
      process.stdout.write('No provider keys configured.\n');
      return 0;
    }
    const models = await request<ModelRow[]>('/api/models');
    if (!Array.isArray(models)) throw new Error('The gateway returned an invalid model list');
    process.stdout.write('ID\tPLATFORM\tMODELS\tENABLED\tSTATUS\n');
    for (const row of rows) {
      const provider = /^[a-z][a-z0-9_-]*$/.test(row.platform) ? row.platform : 'unknown';
      process.stdout.write(`${row.id}\t${provider}\t${modelCount(row, models)}\t${row.enabled ? 'enabled' : 'disabled'}\t${statusOf(row.status)}\n`);
    }
    return 0;
  }

  const selected = rows.filter(row => row.platform === platform && (options.keyId === undefined || row.id === options.keyId));
  if (!selected.length) throw new Error('No matching key found for that platform and ID');
  if (action === 'remove') {
    if (selected.length > 1) throw new Error('Multiple keys match. Run keys list and specify one with --id.');
    await request(`/api/keys/${selected[0].id}`, 'DELETE');
    process.stdout.write(`Removed ${platform} key ${selected[0].id}.\n`);
    return 0;
  }

  let exitCode = 0;
  for (const row of selected) exitCode = Math.max(exitCode, await check(row.id, platform!));
  return exitCode;
}