add Claude SDK gateway installer setup

This commit is contained in:
Alex Newman
2026-05-04 20:21:36 -07:00
parent 8bef7c6a34
commit c4097b4ebb
8 changed files with 386 additions and 202 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+157 -12
View File
@@ -7,6 +7,7 @@ import { homedir } from 'os';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js'; import { SettingsDefaultsManager, type SettingsDefaults } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js'; import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { loadClaudeMemEnv, saveClaudeMemEnv } from '../../shared/EnvManager.js';
import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js'; import { ensureWorkerStarted, type WorkerStartResult } from '../../services/worker-spawner.js';
import { import {
ensureBun, ensureBun,
@@ -584,13 +585,103 @@ function mergeSettings(updates: Record<string, string>): boolean {
} }
type ProviderId = 'claude' | 'gemini' | 'openrouter'; type ProviderId = 'claude' | 'gemini' | 'openrouter';
type ClaudeAccessMode = 'subscription' | 'api-key';
type ClaudeApiMode = 'direct' | 'gateway';
async function promptProvider(options: InstallOptions): Promise<ProviderId> { async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude'; const initialProvider = (getSetting('CLAUDE_MEM_PROVIDER') as ProviderId) || 'claude';
const persistClaudeProvider = () => { const persistClaudeProvider = (authMethod: 'subscription' | 'api-key' | 'gateway' = 'subscription') => {
const wrote = mergeSettings({ CLAUDE_MEM_PROVIDER: 'claude' }); const wrote = mergeSettings({
if (wrote) log.info('Saved provider=claude to ~/.claude-mem/settings.json'); CLAUDE_MEM_PROVIDER: 'claude',
CLAUDE_MEM_CLAUDE_AUTH_METHOD: authMethod,
});
if (wrote) log.info('Saved Claude Agent SDK configuration to ~/.claude-mem/settings.json');
};
const useSubscriptionAuth = () => {
persistClaudeProvider('subscription');
saveClaudeMemEnv({
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
log.info('Configured claude-mem to use your logged-in Claude SDK account.');
};
const configureDirectApiKey = async (): Promise<void> => {
const existing = loadClaudeMemEnv().ANTHROPIC_API_KEY || '';
if (existing.trim().length > 0) {
saveClaudeMemEnv({
ANTHROPIC_API_KEY: existing.trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
return;
}
const apiKeyResult = await p.password({
message: 'Paste your Anthropic API key:',
mask: '*',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
});
if (p.isCancel(apiKeyResult)) {
log.warn('API key prompt cancelled — using your logged-in Claude SDK account.');
useSubscriptionAuth();
return;
}
saveClaudeMemEnv({
ANTHROPIC_API_KEY: String(apiKeyResult).trim(),
ANTHROPIC_BASE_URL: '',
ANTHROPIC_AUTH_TOKEN: '',
});
persistClaudeProvider('api-key');
log.info('Saved Anthropic API key for the Claude Agent SDK path.');
};
const configureGateway = async (): Promise<void> => {
const existing = loadClaudeMemEnv();
const baseUrlResult = await p.text({
message: 'Gateway URL:',
placeholder: existing.ANTHROPIC_BASE_URL || 'http://localhost:4000',
defaultValue: existing.ANTHROPIC_BASE_URL || '',
validate: (v?: string) => {
const value = v?.trim() ?? '';
if (!value) return 'Gateway URL required';
try {
new URL(value);
return undefined;
} catch {
return 'Enter a valid URL, for example http://localhost:4000';
}
},
});
if (p.isCancel(baseUrlResult)) {
log.warn('Gateway setup cancelled — using your logged-in Claude SDK account.');
useSubscriptionAuth();
return;
}
const tokenResult = await p.password({
message: 'Gateway key/token (leave blank if your local gateway does not require one):',
mask: '*',
});
if (p.isCancel(tokenResult)) {
log.warn('Gateway key prompt cancelled — continuing without a gateway token.');
}
saveClaudeMemEnv({
ANTHROPIC_API_KEY: '',
ANTHROPIC_BASE_URL: String(baseUrlResult).trim(),
ANTHROPIC_AUTH_TOKEN: p.isCancel(tokenResult) ? '' : String(tokenResult).trim(),
});
persistClaudeProvider('gateway');
log.info('Configured Claude Agent SDK gateway in ~/.claude-mem/.env.');
}; };
if (!isInteractive) { if (!isInteractive) {
@@ -611,21 +702,44 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
if (options.provider) { if (options.provider) {
selectedProvider = options.provider; selectedProvider = options.provider;
} else { } else {
const result = await p.select<ProviderId>({ const result = await p.select<ClaudeAccessMode>({
message: 'Which LLM provider should claude-mem use to compress observations?', message: 'Do you use a subscription plan or an API key/gateway for the memory agent?',
options: [ options: [
{ value: 'claude', label: 'Claude Code auth (default — no extra setup, uses your existing Claude Code subscription)' }, { value: 'subscription', label: 'Subscription plan (recommended — uses your logged-in Claude SDK account)' },
{ value: 'gemini', label: 'Gemini API key (free tier available — fast and cheap)' }, { value: 'api-key', label: 'API key or gateway (Anthropic, LiteLLM, or compatible proxy)' },
{ value: 'openrouter', label: 'OpenRouter API key (BYO model — wide selection of frontier and open models)' },
], ],
initialValue: initialProvider, initialValue: initialProvider === 'claude' ? 'subscription' : 'api-key',
}); });
if (p.isCancel(result)) { if (p.isCancel(result)) {
p.cancel('Installation cancelled.'); p.cancel('Installation cancelled.');
process.exit(0); process.exit(0);
} }
selectedProvider = result as ProviderId; if (result === 'subscription') {
useSubscriptionAuth();
return 'claude';
}
const apiModeResult = await p.select<ClaudeApiMode>({
message: 'How should claude-mem connect?',
options: [
{ value: 'direct', label: 'Anthropic API key' },
{ value: 'gateway', label: 'LiteLLM or custom gateway' },
],
initialValue: loadClaudeMemEnv().ANTHROPIC_BASE_URL ? 'gateway' : 'direct',
});
if (p.isCancel(apiModeResult)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
if (apiModeResult === 'gateway') {
await configureGateway();
} else {
await configureDirectApiKey();
}
return 'claude';
} }
if (selectedProvider === 'claude') { if (selectedProvider === 'claude') {
@@ -648,7 +762,7 @@ async function promptProvider(options: InstallOptions): Promise<ProviderId> {
const apiKeyResult = await p.password({ const apiKeyResult = await p.password({
message: `Paste your ${providerLabel} API key:`, message: `Paste your ${providerLabel} API key:`,
mask: '*', mask: '*',
validate: (v: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined, validate: (v?: string) => (!v || v.trim().length === 0) ? 'API key required' : undefined,
}); });
if (p.isCancel(apiKeyResult)) { if (p.isCancel(apiKeyResult)) {
@@ -674,8 +788,10 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
'claude-sonnet-4-6', 'claude-sonnet-4-6',
'claude-opus-4-7', 'claude-opus-4-7',
]); ]);
const authMethod = getSetting('CLAUDE_MEM_CLAUDE_AUTH_METHOD');
const allowCustomModel = authMethod === 'gateway';
if (options.model) { if (options.model && !allowCustomModel) {
if (!allowed.has(options.model)) { if (!allowed.has(options.model)) {
throw new Error( throw new Error(
`Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`, `Unknown Claude model: ${options.model}. Allowed: ${[...allowed].join(', ')}`,
@@ -687,10 +803,39 @@ async function promptClaudeModel(options: InstallOptions): Promise<void> {
} }
return; return;
} }
if (options.model && allowCustomModel) {
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: options.model });
if (wrote) {
log.info(`Saved gateway model=${options.model} to ~/.claude-mem/settings.json`);
}
return;
}
if (!isInteractive) return; if (!isInteractive) return;
const initialModel = getSetting('CLAUDE_MEM_MODEL'); const initialModel = getSetting('CLAUDE_MEM_MODEL');
if (allowCustomModel) {
const result = await p.text({
message: 'Which model should the gateway use?',
placeholder: 'claude-haiku-4-5-20251001',
defaultValue: initialModel || 'claude-haiku-4-5-20251001',
validate: (v?: string) => (!v || v.trim().length === 0) ? 'Model required' : undefined,
});
if (p.isCancel(result)) {
p.cancel('Installation cancelled.');
process.exit(0);
}
const selectedModel = String(result).trim();
const wrote = mergeSettings({ CLAUDE_MEM_MODEL: selectedModel });
if (wrote) {
log.info(`Saved gateway model=${selectedModel} to ~/.claude-mem/settings.json`);
}
return;
}
const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001'; const initialValue = allowed.has(initialModel) ? initialModel : 'claude-haiku-4-5-20251001';
const result = await p.select<string>({ const result = await p.select<string>({
@@ -87,6 +87,7 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_WORKER_PORT', 'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_WORKER_HOST', 'CLAUDE_MEM_WORKER_HOST',
'CLAUDE_MEM_PROVIDER', 'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_CLAUDE_AUTH_METHOD',
'CLAUDE_MEM_GEMINI_API_KEY', 'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL', 'CLAUDE_MEM_GEMINI_MODEL',
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED', 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
@@ -194,6 +195,13 @@ export class SettingsRoutes extends BaseRouteHandler {
} }
} }
if (settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD) {
const validClaudeAuthMethods = ['subscription', 'api-key', 'gateway', 'cli'];
if (!validClaudeAuthMethods.includes(settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD)) {
return { valid: false, error: 'CLAUDE_MEM_CLAUDE_AUTH_METHOD must be "subscription", "api-key", "gateway", or "cli"' };
}
}
if (settings.CLAUDE_MEM_GEMINI_MODEL) { if (settings.CLAUDE_MEM_GEMINI_MODEL) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview']; const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) { if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
+24 -5
View File
@@ -22,6 +22,7 @@ const BLOCKED_ENV_VARS = [
export interface ClaudeMemEnv { export interface ClaudeMemEnv {
ANTHROPIC_API_KEY?: string; ANTHROPIC_API_KEY?: string;
ANTHROPIC_BASE_URL?: string; ANTHROPIC_BASE_URL?: string;
ANTHROPIC_AUTH_TOKEN?: string;
GEMINI_API_KEY?: string; GEMINI_API_KEY?: string;
OPENROUTER_API_KEY?: string; OPENROUTER_API_KEY?: string;
} }
@@ -56,7 +57,7 @@ function parseEnvFile(content: string): Record<string, string> {
function serializeEnvFile(env: Record<string, string>): string { function serializeEnvFile(env: Record<string, string>): string {
const lines: string[] = [ const lines: string[] = [
'# claude-mem credentials', '# claude-mem credentials',
'# This file stores API keys for claude-mem memory agent', '# This file stores keys and gateway settings for the claude-mem memory agent',
'# Edit this file or use claude-mem settings to configure', '# Edit this file or use claude-mem settings to configure',
'', '',
]; ];
@@ -83,6 +84,7 @@ export function loadClaudeMemEnv(): ClaudeMemEnv {
const result: ClaudeMemEnv = {}; const result: ClaudeMemEnv = {};
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY; if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL; if (parsed.ANTHROPIC_BASE_URL) result.ANTHROPIC_BASE_URL = parsed.ANTHROPIC_BASE_URL;
if (parsed.ANTHROPIC_AUTH_TOKEN) result.ANTHROPIC_AUTH_TOKEN = parsed.ANTHROPIC_AUTH_TOKEN;
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY; if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY; if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
@@ -126,6 +128,13 @@ export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
delete updated.ANTHROPIC_BASE_URL; delete updated.ANTHROPIC_BASE_URL;
} }
} }
if (env.ANTHROPIC_AUTH_TOKEN !== undefined) {
if (env.ANTHROPIC_AUTH_TOKEN) {
updated.ANTHROPIC_AUTH_TOKEN = env.ANTHROPIC_AUTH_TOKEN;
} else {
delete updated.ANTHROPIC_AUTH_TOKEN;
}
}
if (env.GEMINI_API_KEY !== undefined) { if (env.GEMINI_API_KEY !== undefined) {
if (env.GEMINI_API_KEY) { if (env.GEMINI_API_KEY) {
updated.GEMINI_API_KEY = env.GEMINI_API_KEY; updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
@@ -171,6 +180,9 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
if (credentials.ANTHROPIC_BASE_URL) { if (credentials.ANTHROPIC_BASE_URL) {
isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL; isolatedEnv.ANTHROPIC_BASE_URL = credentials.ANTHROPIC_BASE_URL;
} }
if (credentials.ANTHROPIC_AUTH_TOKEN) {
isolatedEnv.ANTHROPIC_AUTH_TOKEN = credentials.ANTHROPIC_AUTH_TOKEN;
}
if (credentials.GEMINI_API_KEY) { if (credentials.GEMINI_API_KEY) {
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY; isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
} }
@@ -214,10 +226,9 @@ export async function buildIsolatedEnvWithFreshOAuth(
if (!includeCredentials) return isolatedEnv; if (!includeCredentials) return isolatedEnv;
// If the user already configured an ANTHROPIC_API_KEY in ~/.claude-mem/.env, // If the user already configured explicit Anthropic/gateway credentials in
// honor that and skip OAuth lookup entirely. API key auth is preferred when // ~/.claude-mem/.env, honor those and skip OAuth lookup entirely.
// explicitly configured because it's stateless and stable. if (isolatedEnv.ANTHROPIC_API_KEY || isolatedEnv.ANTHROPIC_AUTH_TOKEN) {
if (isolatedEnv.ANTHROPIC_API_KEY) {
clearStaleMarker(); clearStaleMarker();
return isolatedEnv; return isolatedEnv;
} }
@@ -276,10 +287,18 @@ export function hasAnthropicApiKey(): boolean {
return !!env.ANTHROPIC_API_KEY; return !!env.ANTHROPIC_API_KEY;
} }
export function hasAnthropicAuthToken(): boolean {
const env = loadClaudeMemEnv();
return !!env.ANTHROPIC_AUTH_TOKEN;
}
export function getAuthMethodDescription(): string { export function getAuthMethodDescription(): string {
if (hasAnthropicApiKey()) { if (hasAnthropicApiKey()) {
return 'API key (from ~/.claude-mem/.env)'; return 'API key (from ~/.claude-mem/.env)';
} }
if (hasAnthropicAuthToken()) {
return 'Gateway auth token (from ~/.claude-mem/.env)';
}
// Note: this is a quick sync hint for logging — the authoritative OAuth // Note: this is a quick sync hint for logging — the authoritative OAuth
// path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at // path is buildIsolatedEnvWithFreshOAuth() which reads the keychain at
// spawn time. process.env may or may not carry a token here. // spawn time. process.env may or may not carry a token here.
+1 -1
View File
@@ -74,7 +74,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_HOST: '127.0.0.1', CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion', CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key) CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'subscription', // Default to logged-in Claude SDK auth (not API key)
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM) CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users