feat(credentials): add JSON5 credential system

This commit is contained in:
Jiayuan 2026-02-01 02:28:27 +08:00
parent b1d80f29ae
commit 3ee8946e29
10 changed files with 454 additions and 110 deletions

View file

@ -8,17 +8,19 @@
"multica": "./bin/multica-interactive.mjs",
"multica-interactive": "./bin/multica-interactive.mjs",
"multica-cli": "./bin/multica-cli.mjs",
"multica-profile": "./bin/multica-profile.mjs"
"multica-profile": "./bin/multica-profile.mjs",
"multica-credentials": "./bin/multica-credentials.mjs"
},
"scripts": {
"dev": "concurrently -n gateway,console,web -c blue,yellow,green \"pnpm dev:gateway\" \"pnpm dev:console\" \"pnpm dev:web\"",
"agent:cli": "tsx --env-file=.env src/agent/cli/non-interactive.ts",
"agent:interactive": "tsx --env-file=.env src/agent/cli/interactive.ts",
"agent:profile": "tsx --env-file=.env src/agent/cli/profile.ts",
"skills:cli": "tsx --env-file=.env src/agent/cli/skills.ts",
"tools:cli": "tsx --env-file=.env src/agent/cli/tools.ts",
"dev:gateway": "tsx --env-file=.env --watch src/gateway/main.ts",
"dev:console": "tsx --env-file=.env --watch src/console/main.ts",
"agent:cli": "tsx src/agent/cli/non-interactive.ts",
"agent:interactive": "tsx src/agent/cli/interactive.ts",
"agent:profile": "tsx src/agent/cli/profile.ts",
"credentials:cli": "tsx src/agent/credentials-cli.ts",
"skills:cli": "tsx src/agent/cli/skills.ts",
"tools:cli": "tsx src/agent/cli/tools.ts",
"dev:gateway": "tsx --watch src/gateway/main.ts",
"dev:console": "tsx --watch src/console/main.ts",
"dev:web": "pnpm --filter @multica/web dev",
"dev:desktop": "pnpm --filter @multica/desktop dev",
"build": "turbo build",
@ -59,6 +61,7 @@
"@nestjs/websockets": "^11.1.12",
"@sinclair/typebox": "^0.34.41",
"fast-glob": "^3.3.3",
"json5": "^2.2.3",
"linkedom": "^0.18.12",
"nestjs-pino": "^4.5.0",
"pino": "^10.3.0",

68
pnpm-lock.yaml generated
View file

@ -34,13 +34,13 @@ importers:
dependencies:
'@mariozechner/pi-agent-core':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
specifier: ^0.50.3
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -68,6 +68,9 @@ importers:
fast-glob:
specifier: ^3.3.3
version: 3.3.3
json5:
specifier: ^2.2.3
version: 2.2.3
linkedom:
specifier: ^0.18.12
version: 0.18.12
@ -6397,11 +6400,11 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
zod: 4.3.6
'@aws-crypto/crc32@5.2.0':
dependencies:
@ -7424,12 +7427,12 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
dependencies:
google-auth-library: 10.5.0
ws: 8.18.3
optionalDependencies:
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -7684,9 +7687,9 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-tui': 0.50.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -7697,21 +7700,21 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.978.0
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.48
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
chalk: 5.6.2
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
openai: 6.10.0(ws@8.18.3)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.19.2
zod-to-json-schema: 3.25.1(zod@3.25.76)
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@ -7721,12 +7724,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
'@mariozechner/pi-tui': 0.50.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@ -7784,6 +7787,29 @@ snapshots:
- hono
- supports-color
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.7)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 7.5.1(express@5.2.1)
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- hono
- supports-color
optional: true
'@mozilla/readability@0.6.0': {}
'@mswjs/interceptors@0.40.0':
@ -12023,10 +12049,10 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
optionalDependencies:
ws: 8.18.3
zod: 3.25.76
zod: 4.3.6
optionator@0.9.4:
dependencies:
@ -13642,6 +13668,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6

View file

@ -32,6 +32,7 @@ async function build() {
{ entry: "src/agent/cli/interactive.ts", outfile: "bin/multica-interactive.mjs" },
{ entry: "src/agent/cli/non-interactive.ts", outfile: "bin/multica-cli.mjs" },
{ entry: "src/agent/cli/profile.ts", outfile: "bin/multica-profile.mjs" },
{ entry: "src/agent/credentials-cli.ts", outfile: "bin/multica-credentials.mjs" },
];
for (const { entry, outfile } of entryPoints) {

View file

@ -22,6 +22,7 @@ import {
checkEligibilityDetailed,
type DiagnosticItem,
} from "../skills/index.js";
import { credentialManager } from "../credentials.js";
// ============================================================================
// Types
@ -365,7 +366,7 @@ function checkBinaries(bins: string[]): Map<string, boolean> {
function checkEnvVars(envs: string[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const env of envs) {
result.set(env, env in process.env);
result.set(env, credentialManager.hasEnv(env));
}
return result;
}

View file

@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Credentials CLI
*
* Commands:
* init Create credentials.json5 and skills.env.json5
*/
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
import { dirname } from "node:path";
import { getCredentialsPath, getSkillsEnvPath } from "./credentials.js";
type Command = "init" | "help";
function printUsage(): void {
console.log("Usage: pnpm credentials:cli <command> [options]");
console.log("");
console.log("Commands:");
console.log(" init Create credentials.json5 and skills.env.json5 (empty templates)");
console.log(" help Show this help");
console.log("");
console.log("Options:");
console.log(" --force Overwrite existing files");
console.log(" --core-only Only create credentials.json5");
console.log(" --skills-only Only create skills.env.json5");
console.log(" --path Override credentials path (SMC_CREDENTIALS_PATH)");
console.log(" --skills-path Override skills env path (SMC_SKILLS_ENV_PATH)");
console.log("");
console.log("Examples:");
console.log(" pnpm credentials:cli init");
console.log(" pnpm credentials:cli init --force");
console.log(" pnpm credentials:cli init --core-only");
console.log(" pnpm credentials:cli init --skills-only");
}
function buildCoreTemplate(): string {
return `{
version: 1,
llm: {
// provider: "openai",
providers: {
// openai: { apiKey: "sk-...", baseUrl: "https://api.openai.com/v1", model: "gpt-4.1" }
}
},
tools: {
// brave: { apiKey: "brv-..." },
// perplexity: { apiKey: "pplx-...", baseUrl: "https://api.perplexity.ai", model: "perplexity/sonar-pro" }
}
}
`;
}
function buildSkillsTemplate(): string {
return `{
env: {
// Dynamic keys (skills, plugins, integrations)
// LINEAR_API_KEY: "lin-..."
}
}
`;
}
function parseArgs(argv: string[]) {
const args = [...argv];
let force = false;
let pathOverride: string | undefined;
let skillsPathOverride: string | undefined;
let coreOnly = false;
let skillsOnly = false;
const positional: string[] = [];
while (args.length > 0) {
const arg = args.shift();
if (!arg) break;
if (arg === "--force" || arg === "-f") {
force = true;
continue;
}
if (arg === "--core-only") {
coreOnly = true;
continue;
}
if (arg === "--skills-only") {
skillsOnly = true;
continue;
}
if (arg === "--path") {
pathOverride = args.shift();
continue;
}
if (arg === "--skills-path") {
skillsPathOverride = args.shift();
continue;
}
if (arg === "--help" || arg === "-h") {
return { command: "help" as Command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly };
}
positional.push(arg);
}
const command = (positional[0] || "help") as Command;
return { command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly };
}
function cmdInit(force: boolean, pathOverride?: string, skillsPathOverride?: string, coreOnly?: boolean, skillsOnly?: boolean): void {
const createCore = skillsOnly ? false : true;
const createSkills = coreOnly ? false : true;
if (!createCore && !createSkills) {
console.error("Error: both --core-only and --skills-only were provided.");
process.exit(1);
}
if (createCore) {
const path = pathOverride ?? getCredentialsPath();
if (existsSync(path) && !force) {
console.error(`Error: credentials file already exists at ${path}`);
console.error("Use --force to overwrite.");
process.exit(1);
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildCoreTemplate(), "utf8");
chmodSync(path, 0o600);
console.log(`Created: ${path}`);
}
if (createSkills) {
const skillsPath = skillsPathOverride ?? getSkillsEnvPath();
if (existsSync(skillsPath) && !force) {
console.error(`Error: skills env file already exists at ${skillsPath}`);
console.error("Use --force to overwrite.");
process.exit(1);
}
mkdirSync(dirname(skillsPath), { recursive: true });
writeFileSync(skillsPath, buildSkillsTemplate(), "utf8");
chmodSync(skillsPath, 0o600);
console.log(`Created: ${skillsPath}`);
}
console.log("Edit these files to add your credentials.");
}
async function main() {
const { command, force, pathOverride, skillsPathOverride, coreOnly, skillsOnly } = parseArgs(process.argv.slice(2));
switch (command) {
case "init":
cmdInit(force, pathOverride, skillsPathOverride, coreOnly, skillsOnly);
break;
case "help":
default:
printUsage();
break;
}
}
main().catch((err) => {
console.error(err?.stack || String(err));
process.exit(1);
});

202
src/agent/credentials.ts Normal file
View file

@ -0,0 +1,202 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import JSON5 from "json5";
import { DATA_DIR } from "../shared/paths.js";
type ProviderConfig = {
apiKey?: string | undefined;
baseUrl?: string | undefined;
model?: string | undefined;
};
type ToolConfig = {
apiKey?: string | undefined;
baseUrl?: string | undefined;
model?: string | undefined;
};
export type CredentialsConfig = {
version?: number | undefined;
llm?: {
provider?: string | undefined;
providers?: Record<string, ProviderConfig> | undefined;
} | undefined;
tools?: Record<string, ToolConfig> | undefined;
};
type SkillsEnvConfig = {
env?: Record<string, string> | undefined;
};
const DEFAULT_CREDENTIALS_PATH = join(DATA_DIR, "credentials.json5");
const DEFAULT_SKILLS_ENV_PATH = join(DATA_DIR, "skills.env.json5");
function expandHome(value: string): string {
if (value === "~") return homedir();
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
function isTestEnv(): boolean {
return (
process.env.NODE_ENV === "test" ||
process.env.VITEST !== undefined ||
process.env.VITEST_WORKER_ID !== undefined
);
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
function setEnvValue(target: Record<string, string>, key: string, value: unknown): void {
if (isString(value)) {
target[key] = value;
}
}
function applyEnvMap(target: Record<string, string>, env?: Record<string, string>): void {
if (!env) return;
for (const [key, value] of Object.entries(env)) {
setEnvValue(target, key, value);
}
}
export function getCredentialsPath(): string {
const raw = process.env.SMC_CREDENTIALS_PATH ?? DEFAULT_CREDENTIALS_PATH;
return expandHome(raw);
}
export function getSkillsEnvPath(): string {
const raw = process.env.SMC_SKILLS_ENV_PATH ?? DEFAULT_SKILLS_ENV_PATH;
return expandHome(raw);
}
export class CredentialManager {
private corePath: string | null = null;
private skillsPath: string | null = null;
private disabledState: boolean | null = null;
private coreConfig: CredentialsConfig | null = null;
private skillsConfig: SkillsEnvConfig | null = null;
private resolvedSkillsEnv: Record<string, string> | null = null;
private isDisabled(): boolean {
if (process.env.SMC_CREDENTIALS_DISABLE === "1") return true;
return isTestEnv();
}
private loadCore(): void {
const path = getCredentialsPath();
const disabled = this.isDisabled();
if (this.corePath === path && this.disabledState === disabled && this.coreConfig) {
return;
}
this.corePath = path;
this.disabledState = disabled;
this.coreConfig = null;
if (disabled) return;
if (!existsSync(path)) return;
const raw = readFileSync(path, "utf8");
try {
this.coreConfig = JSON5.parse(raw) as CredentialsConfig;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse credentials file (${path}): ${message}`);
}
}
private loadSkillsEnv(): void {
const path = getSkillsEnvPath();
const disabled = this.isDisabled();
if (this.skillsPath === path && this.disabledState === disabled && this.resolvedSkillsEnv) {
return;
}
this.skillsPath = path;
this.disabledState = disabled;
this.skillsConfig = null;
this.resolvedSkillsEnv = null;
if (disabled) return;
if (!existsSync(path)) return;
const raw = readFileSync(path, "utf8");
try {
this.skillsConfig = JSON5.parse(raw) as SkillsEnvConfig;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse skills env file (${path}): ${message}`);
}
}
private buildSkillsEnv(): Record<string, string> {
const env: Record<string, string> = {};
if (!this.skillsConfig) return env;
applyEnvMap(env, this.skillsConfig.env);
return env;
}
private getResolvedSkillsEnv(): Record<string, string> {
this.loadSkillsEnv();
if (!this.resolvedSkillsEnv) {
this.resolvedSkillsEnv = this.buildSkillsEnv();
}
return this.resolvedSkillsEnv;
}
getLlmProvider(): string | undefined {
this.loadCore();
return this.coreConfig?.llm?.provider;
}
getLlmProviderConfig(provider: string): ProviderConfig | undefined {
this.loadCore();
return this.coreConfig?.llm?.providers?.[provider];
}
getToolConfig(toolName: string): ToolConfig | undefined {
this.loadCore();
return this.coreConfig?.tools?.[toolName];
}
getEnv(name: string): string | undefined {
const resolved = this.getResolvedSkillsEnv();
if (Object.prototype.hasOwnProperty.call(resolved, name)) {
return resolved[name];
}
return process.env[name];
}
hasEnv(name: string): boolean {
const resolved = this.getResolvedSkillsEnv();
if (Object.prototype.hasOwnProperty.call(resolved, name)) {
return true;
}
return name in process.env;
}
getResolvedEnvSnapshot(): Record<string, string> {
return { ...this.getResolvedSkillsEnv() };
}
reset(): void {
this.corePath = null;
this.skillsPath = null;
this.disabledState = null;
this.coreConfig = null;
this.skillsConfig = null;
this.resolvedSkillsEnv = null;
}
}
export const credentialManager = new CredentialManager();

View file

@ -6,6 +6,7 @@ import { resolveModel, resolveTools } from "./tools.js";
import { SessionManager } from "./session/session-manager.js";
import { ProfileManager } from "./profile/index.js";
import { SkillManager } from "./skills/index.js";
import { credentialManager, getCredentialsPath } from "./credentials.js";
import {
checkContextWindow,
DEFAULT_CONTEXT_TOKENS,
@ -19,28 +20,7 @@ import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js";
*/
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",
google: "GOOGLE_API_KEY",
"google-genai": "GOOGLE_API_KEY",
kimi: "MOONSHOT_API_KEY",
"kimi-coding": "MOONSHOT_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
groq: "GROQ_API_KEY",
mistral: "MISTRAL_API_KEY",
together: "TOGETHER_API_KEY",
};
const envVar = providerEnvMap[provider];
if (envVar) {
return process.env[envVar];
}
// Try generic format: PROVIDER_API_KEY
const normalizedProvider = provider.toUpperCase().replace(/-/g, "_");
return process.env[`${normalizedProvider}_API_KEY`];
return credentialManager.getLlmProviderConfig(provider)?.apiKey;
}
/**
@ -49,28 +29,7 @@ function resolveApiKey(provider: string, explicitKey?: string): string | undefin
*/
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`];
return credentialManager.getLlmProviderConfig(provider)?.baseUrl;
}
/**
@ -79,28 +38,7 @@ function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefi
*/
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`];
return credentialManager.getLlmProviderConfig(provider)?.model;
}
export class Agent {
@ -122,7 +60,7 @@ export class Agent {
this.debug = options.debug ?? false;
// Resolve provider and model from options > env vars > defaults
const resolvedProvider = options.provider ?? process.env.LLM_PROVIDER ?? "kimi-coding";
const resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding";
const resolvedModel = resolveModelId(resolvedProvider, options.model);
const apiKey = resolveApiKey(resolvedProvider, options.apiKey);
@ -181,8 +119,7 @@ export class Agent {
if (!model) {
throw new Error(
`Unknown model: provider="${effectiveProvider}", model="${effectiveModel}". ` +
`Check your LLM_PROVIDER and model env vars (e.g. OPENAI_MODEL). ` +
`For OpenRouter, use LLM_PROVIDER=openrouter.`,
`Check ${getCredentialsPath()} for llm.provider and llm.providers.${effectiveProvider}.model.`,
);
}

View file

@ -3,6 +3,7 @@ import { getModel, type Model } from "@mariozechner/pi-ai";
import type { SessionEntry, SessionMeta } from "./types.js";
import { appendEntry, readEntries, writeEntries } from "./storage.js";
import { compactMessages, compactMessagesAsync } from "./compaction.js";
import { credentialManager } from "../credentials.js";
/** Get Kimi model for summarization (use a cheaper model than k2-thinking) */
function getSummaryModel(): Model<any> {
@ -11,7 +12,12 @@ function getSummaryModel(): Model<any> {
/** Get Kimi API key */
function getSummaryApiKey(): string | undefined {
return process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY;
const providers = ["kimi", "moonshot", "kimi-coding"];
for (const provider of providers) {
const apiKey = credentialManager.getLlmProviderConfig(provider)?.apiKey;
if (apiKey) return apiKey;
}
return undefined;
}
export type SessionManagerOptions = {

View file

@ -19,6 +19,7 @@ import {
normalizeRequirements,
normalizePlatforms,
} from "./types.js";
import { credentialManager, getSkillsEnvPath } from "../credentials.js";
// ============================================================================
// Diagnostic Types
@ -77,7 +78,7 @@ export function binaryExists(binary: string): boolean {
* @returns True if set (even if empty string)
*/
function envExists(envVar: string): boolean {
return envVar in process.env;
return credentialManager.hasEnv(envVar);
}
// ============================================================================
@ -450,7 +451,7 @@ function generateEnvHint(envVars: string[], skill: Skill): string {
// Check for well-known API key patterns
if (envVar.endsWith("_API_KEY") || envVar.endsWith("_KEY")) {
const service = envVar.replace(/_API_KEY$|_KEY$/, "").toLowerCase();
hints.push(`Set ${envVar} in your environment or add to .env file`);
hints.push(`Set ${envVar} in your environment or add to ${getSkillsEnvPath()}`);
// Add provider-specific hints
const providerHint = getApiKeyHint(envVar);

View file

@ -14,6 +14,7 @@ import {
} from "./cache.js";
import type { CacheEntry } from "./cache.js";
import { jsonResult, readNumberParam, readStringParam } from "./param-helpers.js";
import { credentialManager } from "../../credentials.js";
const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
type SearchProvider = (typeof SEARCH_PROVIDERS)[number];
@ -178,21 +179,21 @@ function inferPerplexityBaseUrl(apiKey: string): string {
}
function resolvePerplexityApiKey(): { apiKey: string; source: string } | { apiKey: null; source: "none" } {
const perplexityKey = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const perplexityKey = (credentialManager.getToolConfig("perplexity")?.apiKey ?? "").trim();
if (perplexityKey) {
return { apiKey: perplexityKey, source: "PERPLEXITY_API_KEY" };
return { apiKey: perplexityKey, source: "perplexity" };
}
const openrouterKey = (process.env.OPENROUTER_API_KEY ?? "").trim();
const openrouterKey = (credentialManager.getToolConfig("openrouter")?.apiKey ?? "").trim();
if (openrouterKey) {
return { apiKey: openrouterKey, source: "OPENROUTER_API_KEY" };
return { apiKey: openrouterKey, source: "openrouter" };
}
return { apiKey: null, source: "none" };
}
function resolveBraveApiKey(): string | undefined {
return (process.env.BRAVE_API_KEY ?? "").trim() || undefined;
return (credentialManager.getToolConfig("brave")?.apiKey ?? "").trim() || undefined;
}
function resolveProvider(requested?: string): SearchProvider {
@ -340,24 +341,26 @@ async function runWebSearch(params: {
return {
error: "missing_api_key",
message:
"Perplexity search requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY environment variable.",
"Perplexity search requires tools.perplexity.apiKey (or tools.openrouter.apiKey) in credentials.json5.",
};
}
const apiKey = perplexityResult.apiKey;
const baseUrl = inferPerplexityBaseUrl(apiKey);
const perplexityConfig = credentialManager.getToolConfig("perplexity");
const baseUrl = (perplexityConfig?.baseUrl ?? "").trim() || inferPerplexityBaseUrl(apiKey);
const model = (perplexityConfig?.model ?? "").trim() || DEFAULT_PERPLEXITY_MODEL;
const { content, citations } = await runPerplexitySearch({
query: params.query,
apiKey,
baseUrl,
model: DEFAULT_PERPLEXITY_MODEL,
model,
timeoutSeconds: params.timeoutSeconds,
});
const payload = {
query: params.query,
provider: params.provider,
model: DEFAULT_PERPLEXITY_MODEL,
model,
tookMs: Date.now() - start,
content,
citations,
@ -371,7 +374,7 @@ async function runWebSearch(params: {
if (!apiKey) {
return {
error: "missing_api_key",
message: "Brave search requires BRAVE_API_KEY environment variable.",
message: "Brave search requires tools.brave.apiKey in credentials.json5.",
};
}