mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
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:
52
.env.example
Normal file
52
.env.example
Normal 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
1
.gitignore
vendored
@@ -14,6 +14,7 @@ release
|
||||
|
||||
# env
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
|
||||
43
README.md
43
README.md
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
47
src/hub/agent-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
5
src/shared/paths.ts
Normal 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");
|
||||
Reference in New Issue
Block a user