Merge pull request #29 from multica-ai/agent-api-key-baseurl

feat(agent): add LLM provider config and Hub agent persistence
This commit is contained in:
LinYushen
2026-01-30 14:08:26 +08:00
committed by GitHub
15 changed files with 283 additions and 24 deletions

52
.env.example Normal file
View File

@@ -0,0 +1,52 @@
# =============================================================================
# LLM Provider Configuration
# =============================================================================
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# Then load before running:
# source .env && pnpm dev:console
# =============================================================================
# Default LLM provider (e.g., openai, anthropic, deepseek, kimi-coding, groq, mistral)
export LLM_PROVIDER=
# --- OpenAI ---
export OPENAI_API_KEY=
export OPENAI_BASE_URL=
export OPENAI_MODEL=
# --- Anthropic ---
export ANTHROPIC_API_KEY=
export ANTHROPIC_BASE_URL=
export ANTHROPIC_MODEL=
# --- DeepSeek ---
export DEEPSEEK_API_KEY=
export DEEPSEEK_BASE_URL=
export DEEPSEEK_MODEL=
# --- Kimi (Moonshot) ---
export MOONSHOT_API_KEY=
export MOONSHOT_BASE_URL=
export MOONSHOT_MODEL=
# --- Groq ---
export GROQ_API_KEY=
export GROQ_BASE_URL=
export GROQ_MODEL=
# --- Mistral ---
export MISTRAL_API_KEY=
export MISTRAL_BASE_URL=
export MISTRAL_MODEL=
# --- Together ---
export TOGETHER_API_KEY=
export TOGETHER_BASE_URL=
export TOGETHER_MODEL=
# --- Google ---
export GOOGLE_API_KEY=
export GOOGLE_BASE_URL=
export GOOGLE_MODEL=

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ release
# env
.env*
!.env.example
# platform specific
*.dmg

View File

@@ -33,9 +33,50 @@ skills/ # Bundled skills (commit, code-review)
```bash
pnpm install
pnpm dev
```
### Environment Configuration
The Agent requires LLM provider credentials. Copy the example and fill in your values:
```bash
cp .env.example .env
# Edit .env with your API keys
```
Example `.env` for OpenAI:
```bash
export LLM_PROVIDER=openai
export OPENAI_API_KEY=sk-xxx
export OPENAI_BASE_URL=https://api.openai.com/v1
export OPENAI_MODEL=gpt-4o
```
Load the environment before starting services that use the Agent:
```bash
# Hub Console (requires LLM env vars)
source .env && pnpm dev:console
# Agent CLI
source .env && pnpm agent:cli "hello"
# Gateway (no LLM env vars needed)
pnpm dev:gateway
```
See `.env.example` for all supported providers (OpenAI, Anthropic, DeepSeek, Kimi, Groq, Mistral, etc.).
### Configuration Priority
Each setting is resolved in order (first match wins):
1. **CLI argument**`--provider`, `--model`, `--api-key`, `--base-url`
2. **Environment variable**`LLM_PROVIDER`, `OPENAI_MODEL`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, etc.
3. **Session metadata** — restored from previous session
4. **Default**`kimi-coding` provider with `kimi-k2-thinking` model
## Agent CLI
Use the agent module directly from the CLI for isolated testing.

View File

@@ -5,6 +5,8 @@ type CliOptions = {
profile?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
apiKey?: string | undefined;
baseUrl?: string | undefined;
system?: string | undefined;
thinking?: string | undefined;
cwd?: string | undefined;
@@ -21,6 +23,8 @@ function printUsage() {
console.log(" --profile ID Load agent profile (identity, soul, tools, memory)");
console.log(" --provider NAME LLM provider (e.g., openai, anthropic, kimi)");
console.log(" --model NAME Model name");
console.log(" --api-key KEY API key (overrides environment variable)");
console.log(" --base-url URL Custom base URL for the provider");
console.log(" --system TEXT System prompt (ignored if --profile is set)");
console.log(" --thinking LEVEL Thinking level");
console.log(" --cwd DIR Working directory for commands");
@@ -53,6 +57,14 @@ function parseArgs(argv: string[]) {
opts.model = args.shift();
continue;
}
if (arg === "--api-key") {
opts.apiKey = args.shift();
continue;
}
if (arg === "--base-url") {
opts.baseUrl = args.shift();
continue;
}
if (arg === "--system") {
opts.system = args.shift();
continue;
@@ -112,6 +124,8 @@ async function main() {
profileId: opts.profile,
provider: opts.provider,
model: opts.model,
apiKey: opts.apiKey,
baseUrl: opts.baseUrl,
systemPrompt: opts.system,
thinkingLevel: opts.thinking as any,
cwd: opts.cwd,

View File

@@ -10,7 +10,6 @@
*/
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
createAgentProfile,
@@ -18,8 +17,9 @@ import {
getProfileDir,
profileExists,
} from "./profile/index.js";
import { DATA_DIR } from "../shared/index.js";
const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles");
const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles");
type Command = "new" | "list" | "show" | "edit" | "help";

View File

@@ -3,11 +3,11 @@
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { PROFILE_FILES, type AgentProfile } from "./types.js";
import { DATA_DIR } from "../../shared/index.js";
const DEFAULT_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles");
const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles");
export interface StorageOptions {
baseDir?: string | undefined;

View File

@@ -13,9 +13,12 @@ import {
} from "./context-window/index.js";
/**
* Get API Key based on provider
* Get API Key based on provider.
* Priority: explicit key > provider-specific env var > generic env var format.
*/
function resolveApiKey(provider: string): string | undefined {
function resolveApiKey(provider: string, explicitKey?: string): string | undefined {
if (explicitKey) return explicitKey;
const providerEnvMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
@@ -39,6 +42,66 @@ function resolveApiKey(provider: string): string | undefined {
return process.env[`${normalizedProvider}_API_KEY`];
}
/**
* Get Base URL based on provider.
* Priority: explicit URL > provider-specific env var > generic env var format.
*/
function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined {
if (explicitUrl) return explicitUrl;
const providerEnvMap: Record<string, string> = {
openai: "OPENAI_BASE_URL",
anthropic: "ANTHROPIC_BASE_URL",
google: "GOOGLE_BASE_URL",
"google-genai": "GOOGLE_BASE_URL",
kimi: "MOONSHOT_BASE_URL",
"kimi-coding": "MOONSHOT_BASE_URL",
deepseek: "DEEPSEEK_BASE_URL",
groq: "GROQ_BASE_URL",
mistral: "MISTRAL_BASE_URL",
together: "TOGETHER_BASE_URL",
};
const envVar = providerEnvMap[provider];
if (envVar) {
return process.env[envVar];
}
// Try generic format: PROVIDER_BASE_URL
const normalizedProvider = provider.toUpperCase().replace(/-/g, "_");
return process.env[`${normalizedProvider}_BASE_URL`];
}
/**
* Get Model ID based on provider.
* Priority: explicit model > provider-specific env var > generic env var format.
*/
function resolveModelId(provider: string, explicitModel?: string): string | undefined {
if (explicitModel) return explicitModel;
const providerEnvMap: Record<string, string> = {
openai: "OPENAI_MODEL",
anthropic: "ANTHROPIC_MODEL",
google: "GOOGLE_MODEL",
"google-genai": "GOOGLE_MODEL",
kimi: "MOONSHOT_MODEL",
"kimi-coding": "MOONSHOT_MODEL",
deepseek: "DEEPSEEK_MODEL",
groq: "GROQ_MODEL",
mistral: "MISTRAL_MODEL",
together: "TOGETHER_MODEL",
};
const envVar = providerEnvMap[provider];
if (envVar) {
return process.env[envVar];
}
// Try generic format: PROVIDER_MODEL
const normalizedProvider = provider.toUpperCase().replace(/-/g, "_");
return process.env[`${normalizedProvider}_MODEL`];
}
export class Agent {
private readonly agent: PiAgentCore;
private readonly output;
@@ -57,7 +120,16 @@ export class Agent {
this.output = createAgentOutput({ stdout, stderr });
this.debug = options.debug ?? false;
this.agent = new PiAgentCore();
// Resolve provider and model from options > env vars > defaults
const resolvedProvider = options.provider ?? process.env.LLM_PROVIDER ?? "kimi-coding";
const resolvedModel = resolveModelId(resolvedProvider, options.model);
const apiKey = resolveApiKey(resolvedProvider, options.apiKey);
this.agent = new PiAgentCore(
apiKey
? { getApiKey: (_provider: string) => apiKey }
: {},
);
// Load Agent Profile (if profileId is specified)
let systemPrompt: string | undefined;
@@ -101,11 +173,15 @@ export class Agent {
return tempSession.getMeta();
})();
const model = options.provider && options.model ? resolveModel(options) : resolveModel({
...options,
provider: storedMeta?.provider,
model: storedMeta?.model,
});
const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider);
const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model;
let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel });
// Override base URL if provided via options or environment variable
const baseUrl = resolveBaseUrl(model.provider, options.baseUrl);
if (baseUrl) {
model = { ...model, baseUrl };
}
// === Context Window Guard ===
this.contextWindowGuard = checkContextWindow({
@@ -133,7 +209,7 @@ export class Agent {
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
// 获取 API Key用于 summary 模式)
const apiKey = compactionMode === "summary" ? resolveApiKey(model.provider) : undefined;
const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined;
// 创建 SessionManager带 context window 配置)
this.session = new SessionManager({
@@ -147,7 +223,7 @@ export class Agent {
minKeepMessages: options.minKeepMessages,
// Summary 模式参数
model: compactionMode === "summary" ? model : undefined,
apiKey,
apiKey: summaryApiKey,
customInstructions: options.summaryInstructions,
});

View File

@@ -1,15 +1,15 @@
import { homedir } from "os";
import { join } from "path";
import { existsSync, mkdirSync, readFileSync } from "fs";
import { appendFile, writeFile } from "fs/promises";
import type { SessionEntry } from "./types.js";
import { DATA_DIR } from "../../shared/index.js";
export type SessionStorageOptions = {
baseDir?: string | undefined;
};
export function resolveBaseDir(options?: SessionStorageOptions) {
return options?.baseDir ?? join(homedir(), ".super-multica", "sessions");
return options?.baseDir ?? join(DATA_DIR, "sessions");
}
export function resolveSessionDir(sessionId: string, options?: SessionStorageOptions) {

View File

@@ -5,17 +5,17 @@
*/
import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { Skill, SkillSource, SkillManagerOptions } from "./types.js";
import { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js";
import { parseSkillFile } from "./parser.js";
import { DATA_DIR } from "../../shared/index.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Default profile base directory */
const DEFAULT_PROFILE_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles");
const DEFAULT_PROFILE_BASE_DIR = join(DATA_DIR, "agent-profiles");
/** Bundled skills directory (relative to package) */
const BUNDLED_DIR = join(__dirname, "../../../skills");

View File

@@ -17,6 +17,10 @@ export type AgentOptions = {
profileBaseDir?: string | undefined;
provider?: string | undefined;
model?: string | undefined;
/** Custom API key (overrides environment variable) */
apiKey?: string | undefined;
/** Custom base URL for the provider endpoint */
baseUrl?: string | undefined;
/** System prompt, if profileId is set will auto-construct from profile */
systemPrompt?: string | undefined;
thinkingLevel?: ThinkingLevel | undefined;

47
src/hub/agent-store.ts Normal file
View File

@@ -0,0 +1,47 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DATA_DIR } from "../shared/index.js";
export interface AgentRecord {
id: string;
createdAt: number;
}
const AGENTS_DIR = join(DATA_DIR, "agents");
const AGENTS_FILE = join(AGENTS_DIR, "agents.json");
function ensureDir(): void {
if (!existsSync(AGENTS_DIR)) {
mkdirSync(AGENTS_DIR, { recursive: true });
}
}
export function loadAgentRecords(): AgentRecord[] {
if (!existsSync(AGENTS_FILE)) return [];
try {
const content = readFileSync(AGENTS_FILE, "utf-8");
return JSON.parse(content) as AgentRecord[];
} catch {
return [];
}
}
export function saveAgentRecords(records: AgentRecord[]): void {
ensureDir();
writeFileSync(AGENTS_FILE, JSON.stringify(records, null, 2), "utf-8");
}
export function addAgentRecord(record: AgentRecord): void {
const records = loadAgentRecords();
if (records.some((r) => r.id === record.id)) return;
records.push(record);
saveAgentRecords(records);
}
export function removeAgentRecord(id: string): void {
const records = loadAgentRecords();
const filtered = records.filter((r) => r.id !== id);
if (filtered.length !== records.length) {
saveAgentRecords(filtered);
}
}

View File

@@ -1,10 +1,9 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { v7 as uuidv7 } from "uuid";
import { DATA_DIR } from "../shared/index.js";
const MULTICA_DIR = join(homedir(), ".multica");
const DEVICE_ID_FILE = join(MULTICA_DIR, "device-id");
const DEVICE_ID_FILE = join(DATA_DIR, "device-id");
/**
* 获取当前设备的 ID。
@@ -16,7 +15,7 @@ export function getDeviceId(): string {
return readFileSync(DEVICE_ID_FILE, "utf-8").trim();
} catch {
const id = uuidv7();
mkdirSync(MULTICA_DIR, { recursive: true });
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(DEVICE_ID_FILE, id, "utf-8");
return id;
}

View File

@@ -3,6 +3,7 @@ import type { ConnectionState } from "../shared/gateway-sdk/types.js";
import { AsyncAgent } from "../agent/async-agent.js";
import { getDeviceId } from "./device.js";
import { GatewayClient } from "../shared/gateway-sdk/client.js";
import { loadAgentRecords, addAgentRecord, removeAgentRecord } from "./agent-store.js";
export class Hub {
private readonly agents = new Map<string, AsyncAgent>();
@@ -23,6 +24,18 @@ export class Hub {
this.deviceId = getDeviceId();
this.client = this.createClient(this.url);
this.client.connect();
this.restoreAgents();
}
/** Restore agents from persistent storage */
private restoreAgents(): void {
const records = loadAgentRecords();
for (const record of records) {
this.createAgent(record.id, { persist: false });
}
if (records.length > 0) {
console.log(`[Hub] Restored ${records.length} agent(s)`);
}
}
private createClient(url: string): GatewayClient {
@@ -82,7 +95,7 @@ export class Hub {
}
/** Create new Agent, or rebuild with existing ID */
createAgent(id?: string): AsyncAgent {
createAgent(id?: string, options?: { persist?: boolean }): AsyncAgent {
if (id) {
const existing = this.agents.get(id);
if (existing && !existing.closed) {
@@ -93,6 +106,11 @@ export class Hub {
const agent = new AsyncAgent({ sessionId: id });
this.agents.set(agent.sessionId, agent);
// Persist to agent store (skip during restore to avoid duplicates)
if (options?.persist !== false) {
addAgentRecord({ id: agent.sessionId, createdAt: Date.now() });
}
// Internally consume messages produced by agent
void this.consumeAgent(agent);
@@ -130,6 +148,7 @@ export class Hub {
agent.close();
this.agents.delete(id);
this.agentSenders.delete(id);
removeAgentRecord(id);
return true;
}

View File

@@ -1,4 +1,5 @@
export * from "./types.js";
export * from "./paths.js";
export * from "./errors.js";
export * from "./retry.js";
export * from "./cancellation.js";

5
src/shared/paths.ts Normal file
View File

@@ -0,0 +1,5 @@
import { join } from "node:path";
import { homedir } from "node:os";
/** Root data directory: ~/.super-multica */
export const DATA_DIR = join(homedir(), ".super-multica");