fix(streaming): use per-message stream ids and oauth resolver
This commit is contained in:
commit
37ec8ff5e0
37 changed files with 1603 additions and 393 deletions
|
|
@ -11,7 +11,14 @@ import { Agent } from "../../runner.js";
|
|||
import type { AgentOptions } from "../../types.js";
|
||||
import { SkillManager } from "../../skills/index.js";
|
||||
import { autocompleteInput, type AutocompleteOption } from "../autocomplete.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray } from "../colors.js";
|
||||
import { colors, dim, cyan, brightCyan, yellow, green, gray, red } from "../colors.js";
|
||||
import {
|
||||
getProviderList,
|
||||
getCurrentProvider,
|
||||
getLoginInstructions,
|
||||
getProviderMeta,
|
||||
type ProviderInfo,
|
||||
} from "../../providers/index.js";
|
||||
|
||||
type ChatOptions = {
|
||||
profile?: string;
|
||||
|
|
@ -31,6 +38,8 @@ const COMMANDS = {
|
|||
session: "Show current session ID",
|
||||
new: "Start a new session",
|
||||
multiline: "Toggle multi-line input mode (end with a line containing only '.')",
|
||||
provider: "Show current provider and available options",
|
||||
model: "Show or switch model (usage: /model [model-name])",
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
|
|
@ -455,6 +464,14 @@ class InteractiveCLI {
|
|||
}
|
||||
return true;
|
||||
|
||||
case "provider":
|
||||
this.showProviderStatus();
|
||||
return true;
|
||||
|
||||
case "model":
|
||||
this.handleModelCommand(input);
|
||||
return true;
|
||||
|
||||
default:
|
||||
const invocation = this.skillManager.resolveCommand(input);
|
||||
if (invocation) {
|
||||
|
|
@ -468,6 +485,126 @@ class InteractiveCLI {
|
|||
}
|
||||
}
|
||||
|
||||
private handleModelCommand(input: string) {
|
||||
const parts = input.trim().split(/\s+/);
|
||||
const modelArg = parts.slice(1).join(" ").trim();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
const providerMeta = getProviderMeta(currentProvider);
|
||||
|
||||
if (!providerMeta) {
|
||||
console.log(`${red("Error:")} Unknown provider: ${currentProvider}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No argument - show current model and available models
|
||||
if (!modelArg) {
|
||||
console.log(`\n${cyan("🎯 Model Status")}\n`);
|
||||
console.log(`${dim("Provider:")} ${green(currentProvider)}`);
|
||||
console.log(`${dim("Current model:")} ${yellow(this.opts.model ?? providerMeta.defaultModel)}`);
|
||||
console.log(`${dim("Default model:")} ${gray(providerMeta.defaultModel)}`);
|
||||
|
||||
console.log(`\n${dim("Available models for")} ${green(currentProvider)}${dim(":")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
const isCurrent = model === (this.opts.model ?? providerMeta.defaultModel);
|
||||
const marker = isCurrent ? yellow(" (current)") : "";
|
||||
const modelDisplay = isCurrent ? yellow(model) : model;
|
||||
console.log(` • ${modelDisplay}${marker}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Switch model:")} ${yellow(`/model <model-name>`)}`);
|
||||
console.log(`${dim("Example:")} ${yellow(`/model ${providerMeta.models[0]}`)}`);
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if model is valid for current provider
|
||||
const normalizedModel = modelArg.toLowerCase();
|
||||
const matchedModel = providerMeta.models.find(
|
||||
(m) => m.toLowerCase() === normalizedModel
|
||||
);
|
||||
|
||||
if (!matchedModel) {
|
||||
console.log(`${red("Error:")} Model "${modelArg}" is not available for provider "${currentProvider}".`);
|
||||
console.log(`\n${dim("Available models:")}`);
|
||||
for (const model of providerMeta.models) {
|
||||
console.log(` • ${model}`);
|
||||
}
|
||||
console.log("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch model
|
||||
const oldModel = this.opts.model ?? providerMeta.defaultModel;
|
||||
this.opts.model = matchedModel;
|
||||
|
||||
// Recreate agent with new model
|
||||
this.agent = this.createAgent(this.agent.sessionId);
|
||||
this.updateStatusBar();
|
||||
|
||||
console.log(`${green("✓")} Model switched: ${gray(oldModel)} → ${yellow(matchedModel)}`);
|
||||
console.log(`${dim("Session preserved:")} ${gray(this.agent.sessionId.slice(0, 8))}...\n`);
|
||||
}
|
||||
|
||||
private showProviderStatus() {
|
||||
const providers = getProviderList();
|
||||
const currentProvider = this.opts.provider ?? getCurrentProvider();
|
||||
|
||||
console.log(`\n${cyan("🔌 Provider Status")}\n`);
|
||||
console.log(`${dim("Current:")} ${green(currentProvider)}`);
|
||||
if (this.opts.model) {
|
||||
console.log(`${dim("Model:")} ${yellow(this.opts.model)}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Available Providers:")}`);
|
||||
console.log(` ${dim("ID".padEnd(16))} ${dim("Name".padEnd(20))} ${dim("Auth".padEnd(12))} ${dim("Status")}`);
|
||||
console.log(` ${dim("─".repeat(70))}`);
|
||||
|
||||
// Group by auth method
|
||||
const apiKeyProviders = providers.filter(p => p.authMethod === "api-key");
|
||||
const oauthProviders = providers.filter(p => p.authMethod === "oauth");
|
||||
|
||||
// OAuth providers first (more interesting)
|
||||
for (const p of oauthProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider || (p.id === "claude-code" && currentProvider === "anthropic" && p.available);
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = cyan("OAuth");
|
||||
const statusLabel = p.available ? green("ready") : dim("not logged in");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
// API Key providers
|
||||
for (const p of apiKeyProviders) {
|
||||
const status = p.available ? green("✓") : red("✗");
|
||||
const isCurrent = p.id === currentProvider;
|
||||
const current = isCurrent ? yellow(" (current)") : "";
|
||||
const idDisplay = isCurrent ? yellow(p.id.padEnd(16)) : p.id.padEnd(16);
|
||||
const authLabel = dim("API Key");
|
||||
const statusLabel = p.available ? green("configured") : dim("not configured");
|
||||
console.log(` ${status} ${idDisplay} ${p.name.padEnd(20)} ${authLabel.padEnd(12)} ${statusLabel}${current}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dim("Usage:")}`);
|
||||
console.log(` ${yellow("multica --provider <id>")} ${dim("Start chat with specific provider")}`);
|
||||
console.log(` ${yellow("multica --provider <id> --model <model>")} ${dim("Specify model too")}`);
|
||||
|
||||
console.log(`\n${dim("Examples:")}`);
|
||||
console.log(` ${yellow("multica --provider claude-code")} ${dim("Use Claude Code OAuth")}`);
|
||||
console.log(` ${yellow("multica --provider openai")} ${dim("Use OpenAI with API Key")}`);
|
||||
|
||||
// If user hasn't logged into Claude Code, show instructions
|
||||
const claudeCode = providers.find(p => p.id === "claude-code");
|
||||
if (claudeCode && !claudeCode.available) {
|
||||
console.log(`\n${cyan("💡 Tip:")} To use Claude Code (free with Claude subscription):`);
|
||||
console.log(` 1. Install: ${yellow("npm install -g @anthropic-ai/claude-code")}`);
|
||||
console.log(` 2. Login: ${yellow("claude login")}`);
|
||||
console.log(` 3. Use: ${yellow("multica --provider claude-code")}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
|
||||
private async handleInput(input: string) {
|
||||
try {
|
||||
console.log("");
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ describe("output", () => {
|
|||
expect(extractResultDetails(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it("should prefer details when present", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "not json" }],
|
||||
details: { count: 3, truncated: false },
|
||||
};
|
||||
expect(extractResultDetails(result)).toEqual({ count: 3, truncated: false });
|
||||
});
|
||||
|
||||
it("should return direct object if no content array", () => {
|
||||
const result = { count: 10, truncated: true };
|
||||
expect(extractResultDetails(result)).toEqual({ count: 10, truncated: true });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { colors, createSpinner } from "./colors.js";
|
||||
import { extractText } from "../extract-text.js";
|
||||
|
||||
export type AgentOutputState = {
|
||||
lastAssistantText: string;
|
||||
|
|
@ -12,16 +13,6 @@ export type AgentOutput = {
|
|||
handleEvent: (event: AgentEvent) => void;
|
||||
};
|
||||
|
||||
function extractText(message: AgentMessage | undefined): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
||||
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||
}
|
||||
|
|
@ -118,6 +109,11 @@ export function extractResultDetails(result: unknown): Record<string, unknown> |
|
|||
}
|
||||
}
|
||||
|
||||
const withDetails = result as { details?: unknown };
|
||||
if (withDetails.details && typeof withDetails.details === "object") {
|
||||
return withDetails.details as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Try direct object access
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -252,8 +248,18 @@ export function createAgentOutput(params: {
|
|||
}
|
||||
case "tool_execution_end": {
|
||||
// Stop spinner and show final result with summary
|
||||
if (event.isError) {
|
||||
const errorText = extractText(event.result) || "Tool failed";
|
||||
const details = extractResultDetails(event.result);
|
||||
const errorField = details?.error;
|
||||
const hasError =
|
||||
event.isError ||
|
||||
Boolean(errorField) ||
|
||||
details?.success === false;
|
||||
if (hasError) {
|
||||
const errorText =
|
||||
(typeof details?.message === "string" && details.message) ||
|
||||
(typeof errorField === "string" && errorField) ||
|
||||
extractText(event.result) ||
|
||||
"Tool failed";
|
||||
const bullet = colors.toolError("✗");
|
||||
const title = colors.toolName(toolDisplayName(event.toolName));
|
||||
spinner.stop(`${bullet} ${title}: ${colors.toolError(errorText)}`);
|
||||
|
|
|
|||
12
src/agent/extract-text.ts
Normal file
12
src/agent/extract-text.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
/** Extract plain text content from an AgentMessage */
|
||||
export function extractText(message: AgentMessage | undefined): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) return "";
|
||||
const content = (message as { content?: Array<{ type: string; text?: string }> }).content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
34
src/agent/providers/index.ts
Normal file
34
src/agent/providers/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* Provider Management
|
||||
*
|
||||
* Unified exports for LLM provider management:
|
||||
* - Registry: Provider metadata, status checking, listing
|
||||
* - Resolver: API key resolution, model resolution
|
||||
*/
|
||||
|
||||
// Registry exports
|
||||
export {
|
||||
type AuthMethod,
|
||||
type ProviderInfo,
|
||||
type ProviderMeta,
|
||||
PROVIDER_ALIAS,
|
||||
isOAuthProvider,
|
||||
isProviderAvailable,
|
||||
getCurrentProvider,
|
||||
getProviderMeta,
|
||||
getDefaultModel,
|
||||
getProviderList,
|
||||
getAvailableProviders,
|
||||
formatProviderStatus,
|
||||
getLoginInstructions,
|
||||
} from "./registry.js";
|
||||
|
||||
// Resolver exports
|
||||
export {
|
||||
type ProviderConfig,
|
||||
resolveProviderConfig,
|
||||
resolveApiKey,
|
||||
resolveBaseUrl,
|
||||
resolveModelId,
|
||||
resolveModel,
|
||||
} from "./resolver.js";
|
||||
363
src/agent/providers/oauth/cli-credentials.ts
Normal file
363
src/agent/providers/oauth/cli-credentials.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* CLI Credentials Reader
|
||||
*
|
||||
* Read OAuth credentials from external CLI tools:
|
||||
* - Claude Code: ~/.claude/.credentials.json or macOS Keychain
|
||||
* - Codex: ~/.codex/auth.json or macOS Keychain
|
||||
*
|
||||
* Based on OpenClaw's implementation.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export type OAuthCredential = {
|
||||
type: "oauth";
|
||||
provider: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type TokenCredential = {
|
||||
type: "token";
|
||||
provider: string;
|
||||
token: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type ClaudeCliCredential = (OAuthCredential | TokenCredential) & {
|
||||
provider: "anthropic";
|
||||
};
|
||||
|
||||
export type CodexCliCredential = OAuthCredential & {
|
||||
provider: "openai-codex";
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Paths
|
||||
// ============================================================
|
||||
|
||||
const CLAUDE_CLI_CREDENTIALS_PATH = ".claude/.credentials.json";
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
||||
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const CODEX_CLI_KEYCHAIN_SERVICE = "Codex Auth";
|
||||
|
||||
function resolveHomePath(relativePath: string): string {
|
||||
const home = os.homedir();
|
||||
return path.join(home, relativePath);
|
||||
}
|
||||
|
||||
function resolveCodexHomePath(): string {
|
||||
const configured = process.env.CODEX_HOME;
|
||||
const home = configured ? configured.replace(/^~/, os.homedir()) : resolveHomePath(".codex");
|
||||
try {
|
||||
return fs.realpathSync(home);
|
||||
} catch {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
|
||||
function computeCodexKeychainAccount(codexHome: string): string {
|
||||
const hash = createHash("sha256").update(codexHome).digest("hex");
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Claude Code Credentials
|
||||
// ============================================================
|
||||
|
||||
function readClaudeCliKeychainCredentials(): ClaudeCliCredential | null {
|
||||
if (process.platform !== "darwin") return null;
|
||||
|
||||
try {
|
||||
const result = execSync(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readClaudeCliFileCredentials(): ClaudeCliCredential | null {
|
||||
const credPath = resolveHomePath(CLAUDE_CLI_CREDENTIALS_PATH);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(credPath, "utf8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const claudeOauth = raw.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Claude Code CLI credentials.
|
||||
* Priority: macOS Keychain > File (~/.claude/.credentials.json)
|
||||
*/
|
||||
export function readClaudeCliCredentials(): ClaudeCliCredential | null {
|
||||
// Try keychain first (macOS only)
|
||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||
if (keychainCreds) return keychainCreds;
|
||||
|
||||
// Fall back to file
|
||||
return readClaudeCliFileCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Claude Code credentials exist and are valid.
|
||||
*/
|
||||
export function hasValidClaudeCliCredentials(): boolean {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return false;
|
||||
// Check if not expired (with 5 minute buffer)
|
||||
return creds.expires > Date.now() + 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token from Claude Code credentials.
|
||||
*/
|
||||
export function getClaudeCliAccessToken(): string | null {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return null;
|
||||
if (creds.type === "oauth") return creds.access;
|
||||
if (creds.type === "token") return creds.token;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Codex CLI Credentials
|
||||
// ============================================================
|
||||
|
||||
function readCodexKeychainCredentials(): CodexCliCredential | null {
|
||||
if (process.platform !== "darwin") return null;
|
||||
|
||||
const codexHome = resolveCodexHomePath();
|
||||
const account = computeCodexKeychainAccount(codexHome);
|
||||
|
||||
try {
|
||||
const secret = execSync(
|
||||
`security find-generic-password -s "${CODEX_CLI_KEYCHAIN_SERVICE}" -a "${account}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
).trim();
|
||||
|
||||
const parsed = JSON.parse(secret);
|
||||
const tokens = parsed.tokens;
|
||||
const accessToken = tokens?.access_token;
|
||||
const refreshToken = tokens?.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
const lastRefreshRaw = parsed.last_refresh;
|
||||
const lastRefresh =
|
||||
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
|
||||
? new Date(lastRefreshRaw).getTime()
|
||||
: Date.now();
|
||||
const expires = Number.isFinite(lastRefresh)
|
||||
? lastRefresh + 60 * 60 * 1000
|
||||
: Date.now() + 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId: typeof tokens?.account_id === "string" ? tokens.account_id : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexFileCredentials(): CodexCliCredential | null {
|
||||
const authPath = path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(authPath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const tokens = raw.tokens;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
|
||||
const accessToken = tokens.access_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
|
||||
let expires: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires,
|
||||
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Codex CLI credentials.
|
||||
* Priority: macOS Keychain > File (~/.codex/auth.json)
|
||||
*/
|
||||
export function readCodexCliCredentials(): CodexCliCredential | null {
|
||||
// Try keychain first (macOS only)
|
||||
const keychainCreds = readCodexKeychainCredentials();
|
||||
if (keychainCreds) return keychainCreds;
|
||||
|
||||
// Fall back to file
|
||||
return readCodexFileCredentials();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Codex credentials exist and are valid.
|
||||
*/
|
||||
export function hasValidCodexCliCredentials(): boolean {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return false;
|
||||
return creds.expires > Date.now() + 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token from Codex credentials.
|
||||
*/
|
||||
export function getCodexCliAccessToken(): string | null {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return null;
|
||||
return creds.access;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Unified Interface
|
||||
// ============================================================
|
||||
|
||||
export type CliCredentialSource = "claude-code" | "codex";
|
||||
|
||||
export interface CliCredentialStatus {
|
||||
source: CliCredentialSource;
|
||||
available: boolean;
|
||||
expires?: number;
|
||||
expiresIn?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all CLI credential sources.
|
||||
*/
|
||||
export function getCliCredentialStatus(): CliCredentialStatus[] {
|
||||
const results: CliCredentialStatus[] = [];
|
||||
|
||||
// Claude Code
|
||||
const claudeCreds = readClaudeCliCredentials();
|
||||
if (claudeCreds) {
|
||||
const expiresIn = claudeCreds.expires - Date.now();
|
||||
results.push({
|
||||
source: "claude-code",
|
||||
available: expiresIn > 0,
|
||||
expires: claudeCreds.expires,
|
||||
expiresIn: formatDuration(expiresIn),
|
||||
});
|
||||
} else {
|
||||
results.push({ source: "claude-code", available: false });
|
||||
}
|
||||
|
||||
// Codex
|
||||
const codexCreds = readCodexCliCredentials();
|
||||
if (codexCreds) {
|
||||
const expiresIn = codexCreds.expires - Date.now();
|
||||
results.push({
|
||||
source: "codex",
|
||||
available: expiresIn > 0,
|
||||
expires: codexCreds.expires,
|
||||
expiresIn: formatDuration(expiresIn),
|
||||
});
|
||||
} else {
|
||||
results.push({ source: "codex", available: false });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms <= 0) return "expired";
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000));
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
7
src/agent/providers/oauth/index.ts
Normal file
7
src/agent/providers/oauth/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* OAuth Credential Reading
|
||||
*
|
||||
* Read OAuth credentials from external CLI tools (Claude Code, Codex).
|
||||
*/
|
||||
|
||||
export * from "./cli-credentials.js";
|
||||
276
src/agent/providers/registry.ts
Normal file
276
src/agent/providers/registry.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Central registry for all LLM providers with metadata,
|
||||
* status checking, and display formatting.
|
||||
*/
|
||||
|
||||
import { credentialManager } from "../credentials.js";
|
||||
import {
|
||||
hasValidClaudeCliCredentials,
|
||||
hasValidCodexCliCredentials,
|
||||
} from "./oauth/cli-credentials.js";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export type AuthMethod = "api-key" | "oauth";
|
||||
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
authMethod: AuthMethod;
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
current: boolean;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
loginUrl?: string | undefined;
|
||||
loginCommand?: string | undefined;
|
||||
}
|
||||
|
||||
/** Static provider metadata (without runtime status) */
|
||||
export interface ProviderMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
authMethod: AuthMethod;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
loginUrl?: string | undefined;
|
||||
loginCommand?: string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Registry
|
||||
// ============================================================
|
||||
|
||||
const PROVIDER_REGISTRY: Record<string, ProviderMeta> = {
|
||||
"claude-code": {
|
||||
id: "claude-code",
|
||||
name: "Claude Code (OAuth)",
|
||||
authMethod: "oauth",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
|
||||
loginCommand: "claude login",
|
||||
},
|
||||
"openai-codex": {
|
||||
id: "openai-codex",
|
||||
name: "Codex (OAuth)",
|
||||
authMethod: "oauth",
|
||||
defaultModel: "gpt-5.2",
|
||||
models: ["gpt-5.2", "gpt-5.2-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max"],
|
||||
loginCommand: "codex login",
|
||||
},
|
||||
"anthropic": {
|
||||
id: "anthropic",
|
||||
name: "Anthropic (API Key)",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"],
|
||||
loginUrl: "https://console.anthropic.com/",
|
||||
},
|
||||
"openai": {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "gpt-4o",
|
||||
models: ["gpt-4o", "gpt-4o-mini", "o1", "o1-mini"],
|
||||
loginUrl: "https://platform.openai.com/api-keys",
|
||||
},
|
||||
"kimi-coding": {
|
||||
id: "kimi-coding",
|
||||
name: "Kimi Code",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "kimi-k2-thinking",
|
||||
models: ["kimi-k2-thinking", "k2p5"],
|
||||
loginUrl: "https://kimi.moonshot.cn/",
|
||||
},
|
||||
"google": {
|
||||
id: "google",
|
||||
name: "Google AI",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
models: ["gemini-2.0-flash", "gemini-1.5-pro"],
|
||||
loginUrl: "https://aistudio.google.com/apikey",
|
||||
},
|
||||
"groq": {
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "llama-3.3-70b-versatile",
|
||||
models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
|
||||
loginUrl: "https://console.groq.com/keys",
|
||||
},
|
||||
"mistral": {
|
||||
id: "mistral",
|
||||
name: "Mistral",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "mistral-large-latest",
|
||||
models: ["mistral-large-latest", "codestral-latest"],
|
||||
loginUrl: "https://console.mistral.ai/api-keys",
|
||||
},
|
||||
"xai": {
|
||||
id: "xai",
|
||||
name: "xAI (Grok)",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "grok-beta",
|
||||
models: ["grok-beta", "grok-vision-beta"],
|
||||
loginUrl: "https://console.x.ai/",
|
||||
},
|
||||
"openrouter": {
|
||||
id: "openrouter",
|
||||
name: "OpenRouter",
|
||||
authMethod: "api-key",
|
||||
defaultModel: "anthropic/claude-3.5-sonnet",
|
||||
models: ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"],
|
||||
loginUrl: "https://openrouter.ai/keys",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider alias mapping for OAuth providers.
|
||||
* Maps friendly names to actual pi-ai provider names.
|
||||
*/
|
||||
export const PROVIDER_ALIAS: Record<string, string> = {
|
||||
"claude-code": "anthropic", // Claude Code OAuth uses anthropic API
|
||||
"openai-codex": "openai", // Codex OAuth uses OpenAI API
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Status Checking
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if a provider is configured with API key in credentials.json5
|
||||
*/
|
||||
function isApiKeyConfigured(providerId: string): boolean {
|
||||
const config = credentialManager.getLlmProviderConfig(providerId);
|
||||
return !!config?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth provider has valid credentials
|
||||
*/
|
||||
function isOAuthAvailable(providerId: string): boolean {
|
||||
if (providerId === "claude-code") {
|
||||
return hasValidClaudeCliCredentials();
|
||||
}
|
||||
if (providerId === "openai-codex") {
|
||||
return hasValidCodexCliCredentials();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider uses OAuth authentication
|
||||
*/
|
||||
export function isOAuthProvider(providerId: string): boolean {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
return info?.authMethod === "oauth";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is available (has valid credentials)
|
||||
*/
|
||||
export function isProviderAvailable(providerId: string): boolean {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
if (!info) return false;
|
||||
|
||||
if (info.authMethod === "oauth") {
|
||||
return isOAuthAvailable(providerId);
|
||||
}
|
||||
return isApiKeyConfigured(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current provider from credentials
|
||||
*/
|
||||
export function getCurrentProvider(): string {
|
||||
return credentialManager.getLlmProvider() ?? "kimi-coding";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Listing
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get static provider metadata
|
||||
*/
|
||||
export function getProviderMeta(providerId: string): ProviderMeta | undefined {
|
||||
return PROVIDER_REGISTRY[providerId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a provider
|
||||
*/
|
||||
export function getDefaultModel(providerId: string): string | undefined {
|
||||
return PROVIDER_REGISTRY[providerId]?.defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all providers with their runtime status
|
||||
*/
|
||||
export function getProviderList(): ProviderInfo[] {
|
||||
const currentProvider = getCurrentProvider();
|
||||
|
||||
return Object.values(PROVIDER_REGISTRY).map((meta) => {
|
||||
const isOAuth = meta.authMethod === "oauth";
|
||||
const available = isOAuth ? isOAuthAvailable(meta.id) : isApiKeyConfigured(meta.id);
|
||||
|
||||
// Check if this is the current provider
|
||||
// For claude-code, check if current is "anthropic" and OAuth is available
|
||||
let isCurrent = currentProvider === meta.id;
|
||||
if (meta.id === "claude-code" && currentProvider === "anthropic") {
|
||||
isCurrent = hasValidClaudeCliCredentials();
|
||||
}
|
||||
|
||||
return {
|
||||
...meta,
|
||||
available,
|
||||
configured: available,
|
||||
current: isCurrent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers only
|
||||
*/
|
||||
export function getAvailableProviders(): ProviderInfo[] {
|
||||
return getProviderList().filter((p) => p.available);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Display Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Format provider for display
|
||||
*/
|
||||
export function formatProviderStatus(provider: ProviderInfo): string {
|
||||
const status = provider.available ? "✓" : "✗";
|
||||
const current = provider.current ? " (current)" : "";
|
||||
const auth = provider.authMethod === "oauth" ? " [OAuth]" : "";
|
||||
return `${status} ${provider.name}${auth}${current}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get login instructions for a provider
|
||||
*/
|
||||
export function getLoginInstructions(providerId: string): string {
|
||||
const info = PROVIDER_REGISTRY[providerId];
|
||||
if (!info) return `Unknown provider: ${providerId}`;
|
||||
|
||||
if (info.authMethod === "oauth") {
|
||||
if (info.loginCommand) {
|
||||
return `Run: ${info.loginCommand}\nThen restart Super Multica to use the credentials.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.loginUrl) {
|
||||
return `Get your API key at: ${info.loginUrl}\nThen add it to ~/.super-multica/credentials.json5`;
|
||||
}
|
||||
|
||||
return "No login instructions available.";
|
||||
}
|
||||
166
src/agent/providers/resolver.ts
Normal file
166
src/agent/providers/resolver.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Provider Resolver
|
||||
*
|
||||
* Resolves provider configuration for making API calls,
|
||||
* including API keys, OAuth tokens, and model selection.
|
||||
*/
|
||||
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { credentialManager } from "../credentials.js";
|
||||
import {
|
||||
readClaudeCliCredentials,
|
||||
readCodexCliCredentials,
|
||||
} from "./oauth/cli-credentials.js";
|
||||
import {
|
||||
PROVIDER_ALIAS,
|
||||
getProviderMeta,
|
||||
getDefaultModel,
|
||||
isOAuthProvider,
|
||||
} from "./registry.js";
|
||||
import type { AgentOptions } from "../types.js";
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface ProviderConfig {
|
||||
provider: string;
|
||||
model?: string | undefined;
|
||||
apiKey?: string | undefined;
|
||||
baseUrl?: string | undefined;
|
||||
// OAuth specific
|
||||
accessToken?: string | undefined;
|
||||
refreshToken?: string | undefined;
|
||||
expires?: number | undefined;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider Config Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get provider config for making API calls.
|
||||
* Handles both OAuth and API Key authentication.
|
||||
*/
|
||||
export function resolveProviderConfig(providerId: string): ProviderConfig | null {
|
||||
const meta = getProviderMeta(providerId);
|
||||
if (!meta) return null;
|
||||
|
||||
if (meta.authMethod === "oauth") {
|
||||
if (providerId === "claude-code") {
|
||||
const creds = readClaudeCliCredentials();
|
||||
if (!creds) return null;
|
||||
|
||||
const accessToken = creds.type === "oauth" ? creds.access : creds.token;
|
||||
return {
|
||||
provider: "anthropic", // Use anthropic API
|
||||
apiKey: accessToken,
|
||||
accessToken,
|
||||
refreshToken: creds.type === "oauth" ? creds.refresh : undefined,
|
||||
expires: creds.expires,
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
const creds = readCodexCliCredentials();
|
||||
if (!creds) return null;
|
||||
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
accessToken: creds.access,
|
||||
refreshToken: creds.refresh,
|
||||
expires: creds.expires,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// API Key based
|
||||
const config = credentialManager.getLlmProviderConfig(providerId);
|
||||
if (!config?.apiKey) return null;
|
||||
|
||||
return {
|
||||
provider: providerId,
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Key Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get API Key based on provider.
|
||||
* Priority: explicit key > OAuth credentials > credentials.json5 config.
|
||||
*/
|
||||
export function resolveApiKey(provider: string, explicitKey?: string): string | undefined {
|
||||
if (explicitKey) return explicitKey;
|
||||
|
||||
// Try OAuth providers first (claude-code, openai-codex)
|
||||
const providerConfig = resolveProviderConfig(provider);
|
||||
if (providerConfig?.apiKey) {
|
||||
return providerConfig.apiKey;
|
||||
}
|
||||
if (providerConfig?.accessToken) {
|
||||
return providerConfig.accessToken;
|
||||
}
|
||||
|
||||
// Fall back to credentials.json5
|
||||
return credentialManager.getLlmProviderConfig(provider)?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Base URL based on provider.
|
||||
* Priority: explicit URL > credentials.json5 config.
|
||||
*/
|
||||
export function resolveBaseUrl(provider: string, explicitUrl?: string): string | undefined {
|
||||
if (explicitUrl) return explicitUrl;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Model ID based on provider.
|
||||
* Priority: explicit model > credentials.json5 config > default.
|
||||
*/
|
||||
export function resolveModelId(provider: string, explicitModel?: string): string | undefined {
|
||||
if (explicitModel) return explicitModel;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.model ?? getDefaultModel(provider);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Model Resolution
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Resolve model for pi-ai based on provider and options.
|
||||
*/
|
||||
export function resolveModel(options: AgentOptions) {
|
||||
if (options.provider && options.model) {
|
||||
// Map provider alias (e.g., claude-code -> anthropic)
|
||||
const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider;
|
||||
|
||||
// Type assertion needed because provider/model come from dynamic user config
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
actualProvider,
|
||||
options.model,
|
||||
);
|
||||
}
|
||||
|
||||
// If only provider specified, use default model for that provider
|
||||
if (options.provider) {
|
||||
const actualProvider = PROVIDER_ALIAS[options.provider] ?? options.provider;
|
||||
const defaultModel = getDefaultModel(options.provider) ?? getDefaultModel(actualProvider);
|
||||
if (defaultModel) {
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
actualProvider,
|
||||
defaultModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getModel("kimi-coding", "kimi-k2-thinking");
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { isOAuthProvider };
|
||||
|
|
@ -3,6 +3,7 @@ import { v7 as uuidv7 } from "uuid";
|
|||
import type { AgentOptions, AgentRunResult } from "./types.js";
|
||||
import { createAgentOutput } from "./cli/output.js";
|
||||
import { resolveModel, resolveTools } from "./tools.js";
|
||||
import { resolveApiKey, resolveBaseUrl, resolveModelId } from "./providers/index.js";
|
||||
import { SessionManager } from "./session/session-manager.js";
|
||||
import { ProfileManager } from "./profile/index.js";
|
||||
import { SkillManager } from "./skills/index.js";
|
||||
|
|
@ -14,33 +15,6 @@ import {
|
|||
} from "./context-window/index.js";
|
||||
import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js";
|
||||
|
||||
/**
|
||||
* Get API Key based on provider.
|
||||
* Priority: explicit key > provider-specific env var > generic env var format.
|
||||
*/
|
||||
function resolveApiKey(provider: string, explicitKey?: string): string | undefined {
|
||||
if (explicitKey) return explicitKey;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return credentialManager.getLlmProviderConfig(provider)?.model;
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
private readonly agent: PiAgentCore;
|
||||
private readonly output;
|
||||
|
|
@ -155,7 +129,9 @@ export class Agent {
|
|||
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
|
||||
|
||||
// 获取 API Key(用于 summary 模式)
|
||||
const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined;
|
||||
const summaryApiKey = compactionMode === "summary"
|
||||
? resolveApiKey(resolvedProvider, options.apiKey)
|
||||
: undefined;
|
||||
|
||||
// 创建 SessionManager(带 context window 配置)
|
||||
this.session = new SessionManager({
|
||||
|
|
|
|||
|
|
@ -1,32 +1,84 @@
|
|||
import type { AgentOptions } from "./types.js";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { createCodingTools } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { createExecTool } from "./tools/exec.js";
|
||||
import { createProcessTool } from "./tools/process.js";
|
||||
import { createGlobTool } from "./tools/glob.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js";
|
||||
import { createMemoryTools } from "./tools/memory/index.js";
|
||||
import { filterTools } from "./tools/policy.js";
|
||||
import { isMulticaError, isRetryableError } from "../shared/errors.js";
|
||||
|
||||
export function resolveModel(options: AgentOptions) {
|
||||
if (options.provider && options.model) {
|
||||
// Type assertion needed because provider/model come from dynamic user config
|
||||
return (getModel as (p: string, m: string) => ReturnType<typeof getModel>)(
|
||||
options.provider,
|
||||
options.model,
|
||||
);
|
||||
}
|
||||
return getModel("kimi-coding", "kimi-k2-thinking");
|
||||
}
|
||||
// Re-export resolveModel from providers for backwards compatibility
|
||||
export { resolveModel } from "./providers/index.js";
|
||||
|
||||
/** Options for creating tools */
|
||||
export interface CreateToolsOptions {
|
||||
cwd: string;
|
||||
/** Profile ID for memory tools (optional) */
|
||||
profileId?: string;
|
||||
profileId?: string | undefined;
|
||||
/** Base directory for profiles (optional) */
|
||||
profileBaseDir?: string;
|
||||
profileBaseDir?: string | undefined;
|
||||
}
|
||||
|
||||
type ToolErrorPayload = {
|
||||
error: true;
|
||||
message: string;
|
||||
name?: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function toToolErrorPayload(error: unknown): ToolErrorPayload {
|
||||
if (isMulticaError(error)) {
|
||||
return {
|
||||
error: true,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
retryable: error.retryable,
|
||||
details: error.details,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
error: true,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
retryable: isRetryableError(error),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
function toolErrorResult(error: unknown): AgentToolResult<ToolErrorPayload> {
|
||||
const payload = toToolErrorPayload(error);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapTool<TParams, TResult>(
|
||||
tool: AgentTool<TParams, TResult>,
|
||||
): AgentTool<TParams, TResult> {
|
||||
const execute = tool.execute;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (...args) => {
|
||||
try {
|
||||
return await execute(...args);
|
||||
} catch (error) {
|
||||
return toolErrorResult(error) as AgentToolResult<TResult>;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -95,7 +147,7 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
|
|||
isSubagent: options.isSubagent,
|
||||
});
|
||||
|
||||
return filtered;
|
||||
return filtered.map((tool) => wrapTool(tool));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export interface MemoryStorageOptions {
|
|||
/** Profile ID (required for storage path) */
|
||||
profileId: string;
|
||||
/** Base directory for profiles */
|
||||
baseDir?: string;
|
||||
baseDir?: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from memory_list */
|
||||
|
|
|
|||
|
|
@ -1,32 +1,6 @@
|
|||
/**
|
||||
* Tests for tool policy system.
|
||||
* Run with: npx tsx src/agent/tools/policy.test.ts
|
||||
*/
|
||||
|
||||
import { filterTools, type ToolsConfig } from "./policy.js";
|
||||
import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./groups.js";
|
||||
|
||||
// Simple test helper
|
||||
function test(name: string, fn: () => void) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(`✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, msg?: string) {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
throw new Error(
|
||||
`${msg || "Assertion failed"}\n Expected: ${expectedStr}\n Actual: ${actualStr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterTools } from "./policy.js";
|
||||
import { TOOL_PROFILES, expandToolGroups } from "./groups.js";
|
||||
|
||||
// Mock tools for testing
|
||||
const mockTools = [
|
||||
|
|
@ -40,177 +14,171 @@ const mockTools = [
|
|||
{ name: "web_search" },
|
||||
] as any[];
|
||||
|
||||
console.log("=== Tool Groups Tests ===\n");
|
||||
|
||||
test("expandToolGroups: group:fs", () => {
|
||||
const expanded = expandToolGroups(["group:fs"]);
|
||||
assertEqual(expanded.sort(), ["edit", "glob", "read", "write"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: group:runtime", () => {
|
||||
const expanded = expandToolGroups(["group:runtime"]);
|
||||
assertEqual(expanded.sort(), ["exec", "process"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: group:web", () => {
|
||||
const expanded = expandToolGroups(["group:web"]);
|
||||
assertEqual(expanded.sort(), ["web_fetch", "web_search"]);
|
||||
});
|
||||
|
||||
test("expandToolGroups: mixed groups and tools", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "web_fetch"]);
|
||||
assertEqual(expanded.sort(), ["exec", "process", "web_fetch"]);
|
||||
});
|
||||
|
||||
console.log("\n=== Tool Profiles Tests ===\n");
|
||||
|
||||
test("TOOL_PROFILES: minimal has empty allow", () => {
|
||||
assertEqual(TOOL_PROFILES.minimal.allow, []);
|
||||
});
|
||||
|
||||
test("TOOL_PROFILES: coding has fs and runtime", () => {
|
||||
assertEqual(TOOL_PROFILES.coding.allow, ["group:fs", "group:runtime"]);
|
||||
});
|
||||
|
||||
test("TOOL_PROFILES: full has no restrictions", () => {
|
||||
assertEqual(TOOL_PROFILES.full.allow, undefined);
|
||||
assertEqual(TOOL_PROFILES.full.deny, undefined);
|
||||
});
|
||||
|
||||
console.log("\n=== Filter Tests ===\n");
|
||||
|
||||
test("filterTools: no config returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, {});
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
test("filterTools: minimal profile returns no tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "minimal" } });
|
||||
assertEqual(filtered.length, 0);
|
||||
});
|
||||
|
||||
test("filterTools: coding profile returns fs and runtime", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "coding" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: web profile returns all", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "web" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, [
|
||||
"edit",
|
||||
"exec",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterTools: full profile returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "full" } });
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
test("filterTools: deny specific tool", () => {
|
||||
const filtered = filterTools(mockTools, { config: { deny: ["exec"] } });
|
||||
const names = filtered.map((t) => t.name);
|
||||
assertEqual(names.includes("exec"), false);
|
||||
assertEqual(names.length, mockTools.length - 1);
|
||||
});
|
||||
|
||||
test("filterTools: allow specific tools", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write"] },
|
||||
describe("tool groups", () => {
|
||||
it("expandToolGroups: group:fs", () => {
|
||||
const expanded = expandToolGroups(["group:fs"]);
|
||||
expect(expanded.sort()).toEqual(["edit", "glob", "read", "write"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: deny takes precedence over allow", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write", "exec"], deny: ["exec"] },
|
||||
it("expandToolGroups: group:runtime", () => {
|
||||
const expanded = expandToolGroups(["group:runtime"]);
|
||||
expect(expanded.sort()).toEqual(["exec", "process"]);
|
||||
});
|
||||
|
||||
it("expandToolGroups: group:web", () => {
|
||||
const expanded = expandToolGroups(["group:web"]);
|
||||
expect(expanded.sort()).toEqual(["web_fetch", "web_search"]);
|
||||
});
|
||||
|
||||
it("expandToolGroups: mixed groups and tools", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "web_fetch"]);
|
||||
expect(expanded.sort()).toEqual(["exec", "process", "web_fetch"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
assertEqual(names, ["read", "write"]);
|
||||
});
|
||||
|
||||
console.log("\n=== Provider-specific Tests ===\n");
|
||||
describe("tool profiles", () => {
|
||||
it("minimal has empty allow", () => {
|
||||
expect(TOOL_PROFILES.minimal.allow).toEqual([]);
|
||||
});
|
||||
|
||||
test("filterTools: provider-specific deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
it("coding has fs and runtime", () => {
|
||||
expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]);
|
||||
});
|
||||
|
||||
it("full has no restrictions", () => {
|
||||
expect(TOOL_PROFILES.full.allow).toBeUndefined();
|
||||
expect(TOOL_PROFILES.full.deny).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterTools", () => {
|
||||
it("no config returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, {});
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
|
||||
it("minimal profile returns no tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "minimal" } });
|
||||
expect(filtered.length).toBe(0);
|
||||
});
|
||||
|
||||
it("coding profile returns fs and runtime", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "coding" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
it("web profile returns all", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "web" } });
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
"edit",
|
||||
"exec",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
it("full profile returns all tools", () => {
|
||||
const filtered = filterTools(mockTools, { config: { profile: "full" } });
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
|
||||
it("deny specific tool", () => {
|
||||
const filtered = filterTools(mockTools, { config: { deny: ["exec"] } });
|
||||
const names = filtered.map((t) => t.name);
|
||||
expect(names.includes("exec")).toBe(false);
|
||||
expect(names.length).toBe(mockTools.length - 1);
|
||||
});
|
||||
|
||||
it("allow specific tools", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
|
||||
it("deny takes precedence over allow", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: { allow: ["read", "write", "exec"], deny: ["exec"] },
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-specific filtering", () => {
|
||||
it("provider-specific deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name);
|
||||
expect(names.includes("exec")).toBe(false);
|
||||
expect(names.includes("process")).toBe(false);
|
||||
expect(names.length).toBe(mockTools.length - 2);
|
||||
});
|
||||
const names = filtered.map((t) => t.name);
|
||||
assertEqual(names.includes("exec"), false);
|
||||
assertEqual(names.includes("process"), false);
|
||||
assertEqual(names.length, mockTools.length - 2);
|
||||
});
|
||||
|
||||
test("filterTools: provider not matching does not apply", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
it("provider not matching does not apply", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
byProvider: {
|
||||
google: { deny: ["exec", "process"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
provider: "openai",
|
||||
});
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
console.log("\n=== Subagent Tests ===\n");
|
||||
|
||||
test("filterTools: subagent restrictions apply", () => {
|
||||
// Currently DEFAULT_SUBAGENT_TOOL_DENY is empty, so no tools are denied
|
||||
const filtered = filterTools(mockTools, { isSubagent: true });
|
||||
// With empty deny list, all tools are allowed
|
||||
assertEqual(filtered.length, mockTools.length);
|
||||
});
|
||||
|
||||
console.log("\n=== Combined Tests ===\n");
|
||||
|
||||
test("filterTools: profile + deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "coding",
|
||||
deny: ["exec"],
|
||||
},
|
||||
describe("subagent restrictions", () => {
|
||||
it("subagent restrictions apply", () => {
|
||||
const filtered = filterTools(mockTools, { isSubagent: true });
|
||||
expect(filtered.length).toBe(mockTools.length);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
// coding = fs + runtime, minus exec
|
||||
assertEqual(names, ["edit", "glob", "process", "read", "write"]);
|
||||
});
|
||||
|
||||
test("filterTools: profile + provider deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "web",
|
||||
byProvider: {
|
||||
google: { deny: ["exec"] },
|
||||
describe("combined filtering", () => {
|
||||
it("profile + deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "coding",
|
||||
deny: ["exec"],
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["edit", "glob", "process", "read", "write"]);
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
// web profile - exec
|
||||
assertEqual(names, [
|
||||
"edit",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
|
||||
console.log("\n=== All tests passed! ===\n");
|
||||
it("profile + provider deny", () => {
|
||||
const filtered = filterTools(mockTools, {
|
||||
config: {
|
||||
profile: "web",
|
||||
byProvider: {
|
||||
google: { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
provider: "google",
|
||||
});
|
||||
const names = filtered.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
"process",
|
||||
"read",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,11 +183,11 @@ export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy {
|
|||
|
||||
export interface FilterToolsOptions {
|
||||
/** Tool configuration */
|
||||
config?: ToolsConfig;
|
||||
config?: ToolsConfig | undefined;
|
||||
/** Current LLM provider (for provider-specific rules) */
|
||||
provider?: string;
|
||||
provider?: string | undefined;
|
||||
/** Whether this is a subagent (applies subagent restrictions) */
|
||||
isSubagent?: boolean;
|
||||
isSubagent?: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
type RequestPayload,
|
||||
type ResponseSuccessPayload,
|
||||
type ResponseErrorPayload,
|
||||
type StreamPayload,
|
||||
} from "@multica/sdk";
|
||||
import { AsyncAgent } from "../agent/async-agent.js";
|
||||
import { getHubId } from "./hub-identity.js";
|
||||
|
|
@ -23,6 +22,8 @@ import { createUpdateGatewayHandler } from "./rpc/handlers/update-gateway.js";
|
|||
export class Hub {
|
||||
private readonly agents = new Map<string, AsyncAgent>();
|
||||
private readonly agentSenders = new Map<string, string>();
|
||||
private readonly agentStreamIds = new Map<string, string>();
|
||||
private readonly agentStreamCounters = new Map<string, number>();
|
||||
private readonly rpc: RpcDispatcher;
|
||||
private client: GatewayClient;
|
||||
url: string;
|
||||
|
|
@ -145,13 +146,42 @@ export class Hub {
|
|||
addAgentRecord({ id: agent.sessionId, createdAt: Date.now() });
|
||||
}
|
||||
|
||||
// Internally consume messages produced by agent
|
||||
// Internally consume agent output (AgentEvent stream + error Messages)
|
||||
void this.consumeAgent(agent);
|
||||
|
||||
console.log(`Agent created: ${agent.sessionId}`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
private getMessageIdFromEvent(event: unknown): string | undefined {
|
||||
if (!event || typeof event !== "object") return undefined;
|
||||
const maybeMsg = (event as { message?: unknown }).message;
|
||||
if (!maybeMsg || typeof maybeMsg !== "object") return undefined;
|
||||
const id = (maybeMsg as { id?: unknown }).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
private beginStream(agentId: string, event: unknown): string {
|
||||
const explicitId = this.getMessageIdFromEvent(event);
|
||||
if (explicitId) {
|
||||
this.agentStreamIds.set(agentId, explicitId);
|
||||
return explicitId;
|
||||
}
|
||||
const next = (this.agentStreamCounters.get(agentId) ?? 0) + 1;
|
||||
this.agentStreamCounters.set(agentId, next);
|
||||
const fallback = `${agentId}:${next}`;
|
||||
this.agentStreamIds.set(agentId, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private getActiveStreamId(agentId: string, event: unknown): string {
|
||||
return this.agentStreamIds.get(agentId) ?? this.getMessageIdFromEvent(event) ?? agentId;
|
||||
}
|
||||
|
||||
private endStream(agentId: string): void {
|
||||
this.agentStreamIds.delete(agentId);
|
||||
}
|
||||
|
||||
/** Internally read agent output and send via Gateway */
|
||||
private async consumeAgent(agent: AsyncAgent): Promise<void> {
|
||||
for await (const item of agent.read()) {
|
||||
|
|
@ -166,11 +196,21 @@ export class Hub {
|
|||
content: item.content,
|
||||
});
|
||||
} else {
|
||||
const maybeMessage = (item as { message?: { role?: string } }).message;
|
||||
const isAssistantMessage = maybeMessage?.role === "assistant";
|
||||
if (item.type === "message_start" && isAssistantMessage) {
|
||||
this.beginStream(agent.sessionId, item);
|
||||
}
|
||||
const streamId = this.getActiveStreamId(agent.sessionId, item);
|
||||
// Raw AgentEvent — forward via StreamAction
|
||||
this.client.send<StreamPayload>(targetDeviceId, StreamAction, {
|
||||
streamId: agent.sessionId,
|
||||
data: item,
|
||||
this.client.send(targetDeviceId, StreamAction, {
|
||||
streamId,
|
||||
agentId: agent.sessionId,
|
||||
event: item,
|
||||
});
|
||||
if (item.type === "message_end" && isAssistantMessage) {
|
||||
this.endStream(agent.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -213,6 +253,8 @@ export class Hub {
|
|||
agent.close();
|
||||
this.agents.delete(id);
|
||||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
removeAgentRecord(id);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -221,6 +263,9 @@ export class Hub {
|
|||
for (const [id, agent] of this.agents) {
|
||||
agent.close();
|
||||
this.agents.delete(id);
|
||||
this.agentSenders.delete(id);
|
||||
this.agentStreamIds.delete(id);
|
||||
this.agentStreamCounters.delete(id);
|
||||
}
|
||||
this.client.disconnect();
|
||||
console.log("Hub shut down");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue