mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 14:38:34 +00:00
Merge branch 'main' into fix/1897-session-status-time-hint
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
|
||||
describe("resolveMoltbotAgentDir", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
describe("resolveOpenClawAgentDir", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
@@ -18,14 +16,14 @@ describe("resolveMoltbotAgentDir", () => {
|
||||
tempStateDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
@@ -35,23 +33,23 @@ describe("resolveMoltbotAgentDir", () => {
|
||||
});
|
||||
|
||||
it("defaults to the multi-agent path when no overrides are set", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-agent-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
const resolved = resolveMoltbotAgentDir();
|
||||
const resolved = resolveOpenClawAgentDir();
|
||||
|
||||
expect(resolved).toBe(path.join(tempStateDir, "agents", "main", "agent"));
|
||||
});
|
||||
|
||||
it("honors CLAWDBOT_AGENT_DIR overrides", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-agent-"));
|
||||
it("honors OPENCLAW_AGENT_DIR overrides", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
|
||||
const override = path.join(tempStateDir, "agent");
|
||||
process.env.CLAWDBOT_AGENT_DIR = override;
|
||||
process.env.OPENCLAW_AGENT_DIR = override;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
const resolved = resolveMoltbotAgentDir();
|
||||
const resolved = resolveOpenClawAgentDir();
|
||||
|
||||
expect(resolved).toBe(path.resolve(override));
|
||||
});
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function resolveMoltbotAgentDir(): string {
|
||||
export function resolveOpenClawAgentDir(): string {
|
||||
const override =
|
||||
process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (override) return resolveUserPath(override);
|
||||
process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (override) {
|
||||
return resolveUserPath(override);
|
||||
}
|
||||
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
|
||||
return resolveUserPath(defaultAgentDir);
|
||||
}
|
||||
|
||||
export function ensureMoltbotAgentEnv(): string {
|
||||
const dir = resolveMoltbotAgentDir();
|
||||
if (!process.env.CLAWDBOT_AGENT_DIR) process.env.CLAWDBOT_AGENT_DIR = dir;
|
||||
if (!process.env.PI_CODING_AGENT_DIR) process.env.PI_CODING_AGENT_DIR = dir;
|
||||
export function ensureOpenClawAgentEnv(): string {
|
||||
const dir = resolveOpenClawAgentDir();
|
||||
if (!process.env.OPENCLAW_AGENT_DIR) {
|
||||
process.env.OPENCLAW_AGENT_DIR = dir;
|
||||
}
|
||||
if (!process.env.PI_CODING_AGENT_DIR) {
|
||||
process.env.PI_CODING_AGENT_DIR = dir;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
|
||||
describe("resolveAgentConfig", () => {
|
||||
it("should return undefined when no agents config exists", () => {
|
||||
const cfg: MoltbotConfig = {};
|
||||
const cfg: OpenClawConfig = {};
|
||||
const result = resolveAgentConfig(cfg, "main");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when agent id does not exist", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace: "~/clawd" }],
|
||||
list: [{ id: "main", workspace: "~/openclaw" }],
|
||||
},
|
||||
};
|
||||
const result = resolveAgentConfig(cfg, "nonexistent");
|
||||
@@ -24,14 +24,14 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("should return basic agent config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
name: "Main Agent",
|
||||
workspace: "~/clawd",
|
||||
agentDir: "~/.clawdbot/agents/main",
|
||||
workspace: "~/openclaw",
|
||||
agentDir: "~/.openclaw/agents/main",
|
||||
model: "anthropic/claude-opus-4",
|
||||
},
|
||||
],
|
||||
@@ -40,8 +40,8 @@ describe("resolveAgentConfig", () => {
|
||||
const result = resolveAgentConfig(cfg, "main");
|
||||
expect(result).toEqual({
|
||||
name: "Main Agent",
|
||||
workspace: "~/clawd",
|
||||
agentDir: "~/.clawdbot/agents/main",
|
||||
workspace: "~/openclaw",
|
||||
agentDir: "~/.openclaw/agents/main",
|
||||
model: "anthropic/claude-opus-4",
|
||||
identity: undefined,
|
||||
groupChat: undefined,
|
||||
@@ -52,7 +52,7 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("supports per-agent model primary+fallbacks", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
@@ -76,7 +76,7 @@ describe("resolveAgentConfig", () => {
|
||||
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual(["openai/gpt-5.2"]);
|
||||
|
||||
// If fallbacks isn't present, we don't override the global fallbacks.
|
||||
const cfgNoOverride: MoltbotConfig = {
|
||||
const cfgNoOverride: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
@@ -91,7 +91,7 @@ describe("resolveAgentConfig", () => {
|
||||
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(undefined);
|
||||
|
||||
// Explicit empty list disables global fallbacks for that agent.
|
||||
const cfgDisable: MoltbotConfig = {
|
||||
const cfgDisable: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
@@ -108,12 +108,12 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("should return agent-specific sandbox config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
workspace: "~/clawd-work",
|
||||
workspace: "~/openclaw-work",
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "agent",
|
||||
@@ -136,12 +136,12 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("should return agent-specific tools config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "restricted",
|
||||
workspace: "~/clawd-restricted",
|
||||
workspace: "~/openclaw-restricted",
|
||||
tools: {
|
||||
allow: ["read"],
|
||||
deny: ["exec", "write", "edit"],
|
||||
@@ -166,12 +166,12 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("should return both sandbox and tools config", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "family",
|
||||
workspace: "~/clawd-family",
|
||||
workspace: "~/openclaw-family",
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
scope: "agent",
|
||||
@@ -190,14 +190,14 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
|
||||
it("should normalize agent id", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace: "~/clawd" }],
|
||||
list: [{ id: "main", workspace: "~/openclaw" }],
|
||||
},
|
||||
};
|
||||
// Should normalize to "main" (default)
|
||||
const result = resolveAgentConfig(cfg, "");
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.workspace).toBe("~/clawd");
|
||||
expect(result?.workspace).toBe("~/openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
@@ -13,7 +12,7 @@ import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
|
||||
|
||||
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<MoltbotConfig["agents"]>["list"]>[number];
|
||||
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
|
||||
type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
@@ -32,29 +31,37 @@ type ResolvedAgentConfig = {
|
||||
|
||||
let defaultAgentWarned = false;
|
||||
|
||||
function listAgents(cfg: MoltbotConfig): AgentEntry[] {
|
||||
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) return [];
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
export function listAgentIds(cfg: MoltbotConfig): string[] {
|
||||
export function listAgentIds(cfg: OpenClawConfig): string[] {
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return [DEFAULT_AGENT_ID];
|
||||
if (agents.length === 0) {
|
||||
return [DEFAULT_AGENT_ID];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const entry of agents) {
|
||||
const id = normalizeAgentId(entry?.id);
|
||||
if (seen.has(id)) continue;
|
||||
if (seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentId(cfg: MoltbotConfig): string {
|
||||
export function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
||||
const agents = listAgents(cfg);
|
||||
if (agents.length === 0) return DEFAULT_AGENT_ID;
|
||||
if (agents.length === 0) {
|
||||
return DEFAULT_AGENT_ID;
|
||||
}
|
||||
const defaults = agents.filter((agent) => agent?.default);
|
||||
if (defaults.length > 1 && !defaultAgentWarned) {
|
||||
defaultAgentWarned = true;
|
||||
@@ -64,7 +71,7 @@ export function resolveDefaultAgentId(cfg: MoltbotConfig): string {
|
||||
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
export function resolveSessionAgentIds(params: { sessionKey?: string; config?: MoltbotConfig }): {
|
||||
export function resolveSessionAgentIds(params: { sessionKey?: string; config?: OpenClawConfig }): {
|
||||
defaultAgentId: string;
|
||||
sessionAgentId: string;
|
||||
} {
|
||||
@@ -78,23 +85,25 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: M
|
||||
|
||||
export function resolveSessionAgentId(params: {
|
||||
sessionKey?: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
}): string {
|
||||
return resolveSessionAgentIds(params).sessionAgentId;
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: MoltbotConfig, agentId: string): AgentEntry | undefined {
|
||||
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
export function resolveAgentConfig(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): ResolvedAgentConfig | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const entry = resolveAgentEntry(cfg, id);
|
||||
if (!entry) return undefined;
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
workspace: typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
@@ -114,42 +123,56 @@ export function resolveAgentConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentModelPrimary(cfg: MoltbotConfig, agentId: string): string | undefined {
|
||||
export function resolveAgentModelPrimary(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
||||
if (!raw) return undefined;
|
||||
if (typeof raw === "string") return raw.trim() || undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim() || undefined;
|
||||
}
|
||||
const primary = raw.primary?.trim();
|
||||
return primary || undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentModelFallbacksOverride(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): string[] | undefined {
|
||||
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
||||
if (!raw || typeof raw === "string") return undefined;
|
||||
if (!raw || typeof raw === "string") {
|
||||
return undefined;
|
||||
}
|
||||
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
|
||||
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
|
||||
if (!Object.hasOwn(raw, "fallbacks")) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: MoltbotConfig, agentId: string) {
|
||||
export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
if (configured) return resolveUserPath(configured);
|
||||
if (configured) {
|
||||
return resolveUserPath(configured);
|
||||
}
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
if (id === defaultAgentId) {
|
||||
const fallback = cfg.agents?.defaults?.workspace?.trim();
|
||||
if (fallback) return resolveUserPath(fallback);
|
||||
if (fallback) {
|
||||
return resolveUserPath(fallback);
|
||||
}
|
||||
return DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
}
|
||||
return path.join(os.homedir(), `clawd-${id}`);
|
||||
return path.join(os.homedir(), ".openclaw", `workspace-${id}`);
|
||||
}
|
||||
|
||||
export function resolveAgentDir(cfg: MoltbotConfig, agentId: string) {
|
||||
export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
|
||||
if (configured) return resolveUserPath(configured);
|
||||
if (configured) {
|
||||
return resolveUserPath(configured);
|
||||
}
|
||||
const root = resolveStateDir(process.env, os.homedir);
|
||||
return path.join(root, "agents", id, "agent");
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
|
||||
type PayloadLogStage = "request" | "usage";
|
||||
|
||||
@@ -42,8 +40,8 @@ const writers = new Map<string, PayloadLogWriter>();
|
||||
const log = createSubsystemLogger("agent/anthropic-payload");
|
||||
|
||||
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
|
||||
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
|
||||
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const enabled = parseBooleanValue(env.OPENCLAW_ANTHROPIC_PAYLOAD_LOG) ?? false;
|
||||
const fileOverride = env.OPENCLAW_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
|
||||
@@ -52,7 +50,9 @@ function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
|
||||
|
||||
function getWriter(filePath: string): PayloadLogWriter {
|
||||
const existing = writers.get(filePath);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
||||
@@ -75,8 +75,12 @@ function getWriter(filePath: string): PayloadLogWriter {
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (typeof val === "bigint") {
|
||||
return val.toString();
|
||||
}
|
||||
if (typeof val === "function") {
|
||||
return "[Function]";
|
||||
}
|
||||
if (val instanceof Error) {
|
||||
return { name: val.name, message: val.message, stack: val.stack };
|
||||
}
|
||||
@@ -91,8 +95,12 @@ function safeJsonStringify(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
||||
return String(error);
|
||||
}
|
||||
@@ -104,7 +112,9 @@ function formatError(error: unknown): string | undefined {
|
||||
|
||||
function digest(value: unknown): string | undefined {
|
||||
const serialized = safeJsonStringify(value);
|
||||
if (!serialized) return undefined;
|
||||
if (!serialized) {
|
||||
return undefined;
|
||||
}
|
||||
return crypto.createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
@@ -140,7 +150,9 @@ export function createAnthropicPayloadLogger(params: {
|
||||
}): AnthropicPayloadLogger | null {
|
||||
const env = params.env ?? process.env;
|
||||
const cfg = resolvePayloadLogConfig(env);
|
||||
if (!cfg.enabled) return null;
|
||||
if (!cfg.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const writer = getWriter(cfg.filePath);
|
||||
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
|
||||
@@ -155,13 +167,15 @@ export function createAnthropicPayloadLogger(params: {
|
||||
|
||||
const record = (event: PayloadLogEvent) => {
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) return;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
writer.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
|
||||
const wrapped: StreamFn = (model, context, options) => {
|
||||
if (!isAnthropicModel(model as Model<Api>)) {
|
||||
if (!isAnthropicModel(model)) {
|
||||
return streamFn(model, context, options);
|
||||
}
|
||||
const nextOnPayload = (payload: unknown) => {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
ANTHROPIC_SETUP_TOKEN_PREFIX,
|
||||
validateAnthropicSetupToken,
|
||||
} from "../commands/auth-token.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
type AuthProfileCredential,
|
||||
ensureAuthProfileStore,
|
||||
@@ -20,13 +18,14 @@ import {
|
||||
} from "./auth-profiles.js";
|
||||
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
|
||||
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
|
||||
import { ensureMoltbotModelsJson } from "./models-config.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
||||
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
const SETUP_TOKEN_MODEL = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
|
||||
const SETUP_TOKEN_RAW = process.env.OPENCLAW_LIVE_SETUP_TOKEN?.trim() ?? "";
|
||||
const SETUP_TOKEN_VALUE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
|
||||
const SETUP_TOKEN_PROFILE = process.env.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
|
||||
const SETUP_TOKEN_MODEL = process.env.OPENCLAW_LIVE_SETUP_TOKEN_MODEL?.trim() ?? "";
|
||||
|
||||
const ENABLED = LIVE && Boolean(SETUP_TOKEN_RAW || SETUP_TOKEN_VALUE || SETUP_TOKEN_PROFILE);
|
||||
const describeLive = ENABLED ? describe : describe.skip;
|
||||
@@ -46,8 +45,12 @@ function listSetupTokenProfiles(store: {
|
||||
}): string[] {
|
||||
return Object.entries(store.profiles)
|
||||
.filter(([, cred]) => {
|
||||
if (cred.type !== "token") return false;
|
||||
if (normalizeProviderId(cred.provider) !== "anthropic") return false;
|
||||
if (cred.type !== "token") {
|
||||
return false;
|
||||
}
|
||||
if (normalizeProviderId(cred.provider) !== "anthropic") {
|
||||
return false;
|
||||
}
|
||||
return isSetupToken(cred.token);
|
||||
})
|
||||
.map(([id]) => id);
|
||||
@@ -56,7 +59,9 @@ function listSetupTokenProfiles(store: {
|
||||
function pickSetupTokenProfile(candidates: string[]): string {
|
||||
const preferred = ["anthropic:setup-token-test", "anthropic:setup-token", "anthropic:default"];
|
||||
for (const id of preferred) {
|
||||
if (candidates.includes(id)) return id;
|
||||
if (candidates.includes(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return candidates[0] ?? "";
|
||||
}
|
||||
@@ -70,7 +75,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
|
||||
if (error) {
|
||||
throw new Error(`Invalid setup-token: ${error}`);
|
||||
}
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-setup-token-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-token-"));
|
||||
const profileId = `anthropic:setup-token-live-${randomUUID()}`;
|
||||
const store = ensureAuthProfileStore(tempDir, {
|
||||
allowKeychainPrompt: false,
|
||||
@@ -90,7 +95,7 @@ async function resolveTokenSource(): Promise<TokenSource> {
|
||||
};
|
||||
}
|
||||
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@@ -108,13 +113,13 @@ async function resolveTokenSource(): Promise<TokenSource> {
|
||||
|
||||
if (SETUP_TOKEN_RAW && SETUP_TOKEN_RAW !== "1" && SETUP_TOKEN_RAW !== "auto") {
|
||||
throw new Error(
|
||||
"CLAWDBOT_LIVE_SETUP_TOKEN did not look like a setup-token. Use CLAWDBOT_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
|
||||
"OPENCLAW_LIVE_SETUP_TOKEN did not look like a setup-token. Use OPENCLAW_LIVE_SETUP_TOKEN_VALUE for raw tokens.",
|
||||
);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
"No Anthropics setup-token profiles found. Set CLAWDBOT_LIVE_SETUP_TOKEN_VALUE or CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE.",
|
||||
"No Anthropics setup-token profiles found. Set OPENCLAW_LIVE_SETUP_TOKEN_VALUE or OPENCLAW_LIVE_SETUP_TOKEN_PROFILE.",
|
||||
);
|
||||
}
|
||||
return { agentDir, profileId: pickSetupTokenProfile(candidates) };
|
||||
@@ -124,7 +129,9 @@ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
|
||||
const normalized = raw?.trim() ?? "";
|
||||
if (normalized) {
|
||||
const parsed = parseModelRef(normalized, "anthropic");
|
||||
if (!parsed) return null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
models.find(
|
||||
(model) =>
|
||||
@@ -141,7 +148,9 @@ function pickModel(models: Array<Model<Api>>, raw?: string): Model<Api> | null {
|
||||
];
|
||||
for (const id of preferred) {
|
||||
const match = models.find((model) => model.id === id);
|
||||
if (match) return match;
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return models[0] ?? null;
|
||||
}
|
||||
@@ -153,7 +162,7 @@ describeLive("live anthropic setup-token", () => {
|
||||
const tokenSource = await resolveTokenSource();
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
await ensureMoltbotModelsJson(cfg, tokenSource.agentDir);
|
||||
await ensureOpenClawModelsJson(cfg, tokenSource.agentDir);
|
||||
|
||||
const authStorage = discoverAuthStorage(tokenSource.agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, tokenSource.agentDir);
|
||||
|
||||
@@ -85,7 +85,7 @@ function applyReplacements(
|
||||
replacements: Array<[number, number, string[]]>,
|
||||
): string[] {
|
||||
const result = [...lines];
|
||||
for (const [startIndex, oldLen, newLines] of [...replacements].reverse()) {
|
||||
for (const [startIndex, oldLen, newLines] of [...replacements].toReversed()) {
|
||||
for (let i = 0; i < oldLen; i += 1) {
|
||||
if (startIndex < result.length) {
|
||||
result.splice(startIndex, 1);
|
||||
@@ -104,21 +104,33 @@ function seekSequence(
|
||||
start: number,
|
||||
eof: boolean,
|
||||
): number | null {
|
||||
if (pattern.length === 0) return start;
|
||||
if (pattern.length > lines.length) return null;
|
||||
if (pattern.length === 0) {
|
||||
return start;
|
||||
}
|
||||
if (pattern.length > lines.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxStart = lines.length - pattern.length;
|
||||
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
|
||||
if (searchStart > maxStart) return null;
|
||||
if (searchStart > maxStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = searchStart; i <= maxStart; i += 1) {
|
||||
if (linesMatch(lines, pattern, i, (value) => value)) return i;
|
||||
if (linesMatch(lines, pattern, i, (value) => value)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (let i = searchStart; i <= maxStart; i += 1) {
|
||||
if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) return i;
|
||||
if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (let i = searchStart; i <= maxStart; i += 1) {
|
||||
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
|
||||
if (linesMatch(lines, pattern, i, (value) => value.trim())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
for (let i = searchStart; i <= maxStart; i += 1) {
|
||||
if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
|
||||
|
||||
@@ -2,11 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyPatch } from "./apply-patch.js";
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-patch-"));
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-"));
|
||||
try {
|
||||
return await fn(dir);
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { applyUpdateHunk } from "./apply-patch-update.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
|
||||
@@ -183,22 +183,32 @@ function recordSummary(
|
||||
bucket: keyof ApplyPatchSummary,
|
||||
value: string,
|
||||
) {
|
||||
if (seen[bucket].has(value)) return;
|
||||
if (seen[bucket].has(value)) {
|
||||
return;
|
||||
}
|
||||
seen[bucket].add(value);
|
||||
summary[bucket].push(value);
|
||||
}
|
||||
|
||||
function formatSummary(summary: ApplyPatchSummary): string {
|
||||
const lines = ["Success. Updated the following files:"];
|
||||
for (const file of summary.added) lines.push(`A ${file}`);
|
||||
for (const file of summary.modified) lines.push(`M ${file}`);
|
||||
for (const file of summary.deleted) lines.push(`D ${file}`);
|
||||
for (const file of summary.added) {
|
||||
lines.push(`A ${file}`);
|
||||
}
|
||||
for (const file of summary.modified) {
|
||||
lines.push(`M ${file}`);
|
||||
}
|
||||
for (const file of summary.deleted) {
|
||||
lines.push(`D ${file}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function ensureDir(filePath: string) {
|
||||
const parent = path.dirname(filePath);
|
||||
if (!parent || parent === ".") return;
|
||||
if (!parent || parent === ".") {
|
||||
return;
|
||||
}
|
||||
await fs.mkdir(parent, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -231,21 +241,31 @@ function normalizeUnicodeSpaces(value: string): string {
|
||||
|
||||
function expandPath(filePath: string): string {
|
||||
const normalized = normalizeUnicodeSpaces(filePath);
|
||||
if (normalized === "~") return os.homedir();
|
||||
if (normalized.startsWith("~/")) return os.homedir() + normalized.slice(1);
|
||||
if (normalized === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (normalized.startsWith("~/")) {
|
||||
return os.homedir() + normalized.slice(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePathFromCwd(filePath: string, cwd: string): string {
|
||||
const expanded = expandPath(filePath);
|
||||
if (path.isAbsolute(expanded)) return path.normalize(expanded);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return path.normalize(expanded);
|
||||
}
|
||||
return path.resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
function toDisplayPath(resolved: string, cwd: string): string {
|
||||
const relative = path.relative(cwd, resolved);
|
||||
if (!relative || relative === "") return path.basename(resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return resolved;
|
||||
if (!relative || relative === "") {
|
||||
return path.basename(resolved);
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return resolved;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
@@ -275,7 +295,9 @@ function parsePatchText(input: string): { hunks: Hunk[]; patch: string } {
|
||||
|
||||
function checkPatchBoundariesLenient(lines: string[]): string[] {
|
||||
const strictError = checkPatchBoundariesStrict(lines);
|
||||
if (!strictError) return lines;
|
||||
if (!strictError) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (lines.length < 4) {
|
||||
throw new Error(strictError);
|
||||
@@ -285,7 +307,9 @@ function checkPatchBoundariesLenient(lines: string[]): string[] {
|
||||
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) {
|
||||
const inner = lines.slice(1, lines.length - 1);
|
||||
const innerError = checkPatchBoundariesStrict(inner);
|
||||
if (!innerError) return inner;
|
||||
if (!innerError) {
|
||||
return inner;
|
||||
}
|
||||
throw new Error(innerError);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildAuthHealthSummary, DEFAULT_OAUTH_WARN_MS } from "./auth-health.js";
|
||||
|
||||
describe("buildAuthHealthSummary", () => {
|
||||
@@ -52,11 +51,39 @@ describe("buildAuthHealthSummary", () => {
|
||||
);
|
||||
|
||||
expect(statuses["anthropic:ok"]).toBe("ok");
|
||||
expect(statuses["anthropic:expiring"]).toBe("expiring");
|
||||
expect(statuses["anthropic:expired"]).toBe("expired");
|
||||
// OAuth credentials with refresh tokens are auto-renewable, so they report "ok"
|
||||
expect(statuses["anthropic:expiring"]).toBe("ok");
|
||||
expect(statuses["anthropic:expired"]).toBe("ok");
|
||||
expect(statuses["anthropic:api"]).toBe("static");
|
||||
|
||||
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
|
||||
expect(provider?.status).toBe("expired");
|
||||
expect(provider?.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("reports expired for OAuth without a refresh token", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"google:no-refresh": {
|
||||
type: "oauth" as const,
|
||||
provider: "google-antigravity",
|
||||
access: "access",
|
||||
refresh: "",
|
||||
expires: now - 10_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const summary = buildAuthHealthSummary({
|
||||
store,
|
||||
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||
});
|
||||
|
||||
const statuses = Object.fromEntries(
|
||||
summary.profiles.map((profile) => [profile.profileId, profile.status]),
|
||||
);
|
||||
|
||||
expect(statuses["google:no-refresh"]).toBe("expired");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
type AuthProfileCredential,
|
||||
type AuthProfileStore,
|
||||
@@ -44,12 +44,20 @@ export function resolveAuthProfileSource(_profileId: string): AuthProfileSource
|
||||
}
|
||||
|
||||
export function formatRemainingShort(remainingMs?: number): string {
|
||||
if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown";
|
||||
if (remainingMs <= 0) return "0m";
|
||||
if (remainingMs === undefined || Number.isNaN(remainingMs)) {
|
||||
return "unknown";
|
||||
}
|
||||
if (remainingMs <= 0) {
|
||||
return "0m";
|
||||
}
|
||||
const minutes = Math.max(1, Math.round(remainingMs / 60_000));
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h`;
|
||||
if (hours < 48) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
@@ -76,7 +84,7 @@ function buildProfileHealth(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
store: AuthProfileStore;
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
now: number;
|
||||
warnAfterMs: number;
|
||||
}): AuthProfileHealth {
|
||||
@@ -123,7 +131,16 @@ function buildProfileHealth(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs);
|
||||
const hasRefreshToken = typeof credential.refresh === "string" && credential.refresh.length > 0;
|
||||
const { status: rawStatus, remainingMs } = resolveOAuthStatus(
|
||||
credential.expires,
|
||||
now,
|
||||
warnAfterMs,
|
||||
);
|
||||
// OAuth credentials with a valid refresh token auto-renew on first API call,
|
||||
// so don't warn about access token expiration.
|
||||
const status =
|
||||
hasRefreshToken && (rawStatus === "expired" || rawStatus === "expiring") ? "ok" : rawStatus;
|
||||
return {
|
||||
profileId,
|
||||
provider: credential.provider,
|
||||
@@ -138,7 +155,7 @@ function buildProfileHealth(params: {
|
||||
|
||||
export function buildAuthHealthSummary(params: {
|
||||
store: AuthProfileStore;
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
warnAfterMs?: number;
|
||||
providers?: string[];
|
||||
}): AuthHealthSummary {
|
||||
@@ -160,7 +177,7 @@ export function buildAuthHealthSummary(params: {
|
||||
warnAfterMs,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
.toSorted((a, b) => {
|
||||
if (a.provider !== b.provider) {
|
||||
return a.provider.localeCompare(b.provider);
|
||||
}
|
||||
@@ -217,17 +234,17 @@ export function buildAuthHealthSummary(params: {
|
||||
provider.remainingMs = provider.expiresAt - now;
|
||||
}
|
||||
|
||||
const statuses = expirable.map((p) => p.status);
|
||||
if (statuses.includes("expired") || statuses.includes("missing")) {
|
||||
const statuses = new Set(expirable.map((p) => p.status));
|
||||
if (statuses.has("expired") || statuses.has("missing")) {
|
||||
provider.status = "expired";
|
||||
} else if (statuses.includes("expiring")) {
|
||||
} else if (statuses.has("expiring")) {
|
||||
provider.status = "expiring";
|
||||
} else {
|
||||
provider.status = "ok";
|
||||
}
|
||||
}
|
||||
|
||||
const providers = Array.from(providersMap.values()).sort((a, b) =>
|
||||
const providers = Array.from(providersMap.values()).toSorted((a, b) =>
|
||||
a.provider.localeCompare(b.provider),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
@@ -11,8 +10,8 @@ import {
|
||||
import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth } from "./chutes-oauth.js";
|
||||
|
||||
describe("auth-profiles (chutes)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
|
||||
let tempDir: string | null = null;
|
||||
@@ -23,21 +22,33 @@ describe("auth-profiles (chutes)", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
|
||||
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
if (previousChutesClientId === undefined) {
|
||||
delete process.env.CHUTES_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-chutes-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chutes-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
|
||||
|
||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
@@ -59,7 +70,9 @@ describe("auth-profiles (chutes)", () => {
|
||||
|
||||
const fetchSpy = vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-profiles-"));
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-profiles-"));
|
||||
try {
|
||||
const legacyPath = path.join(agentDir, "auth.json");
|
||||
fs.writeFileSync(
|
||||
@@ -48,8 +48,8 @@ describe("ensureAuthProfileStore", () => {
|
||||
});
|
||||
|
||||
it("merges main auth profiles into agent store and keeps agent overrides", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-merge-"));
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-merge-"));
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
try {
|
||||
const mainDir = path.join(root, "main-agent");
|
||||
@@ -57,7 +57,7 @@ describe("ensureAuthProfileStore", () => {
|
||||
fs.mkdirSync(mainDir, { recursive: true });
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
process.env.CLAWDBOT_AGENT_DIR = mainDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = mainDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainDir;
|
||||
|
||||
const mainStore = {
|
||||
@@ -110,9 +110,9 @@ describe("ensureAuthProfileStore", () => {
|
||||
});
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ensureAuthProfileStore, markAuthProfileFailure } from "./auth-profiles.
|
||||
|
||||
describe("markAuthProfileFailure", () => {
|
||||
it("disables billing failures for ~5 hours by default", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
@@ -42,7 +42,7 @@ describe("markAuthProfileFailure", () => {
|
||||
}
|
||||
});
|
||||
it("honors per-provider billing backoff overrides", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
fs.writeFileSync(
|
||||
@@ -86,7 +86,7 @@ describe("markAuthProfileFailure", () => {
|
||||
}
|
||||
});
|
||||
it("resets backoff counters outside the failure window", async () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const now = Date.now();
|
||||
|
||||
@@ -7,6 +7,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
|
||||
export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
|
||||
export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli";
|
||||
|
||||
export const AUTH_STORE_LOCK_OPTIONS = {
|
||||
retries: {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
export function resolveAuthProfileDisplayLabel(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
}): string {
|
||||
const { cfg, store, profileId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
|
||||
const email =
|
||||
configEmail ||
|
||||
(profile && "email" in profile ? (profile.email as string | undefined)?.trim() : undefined);
|
||||
if (email) return `${profileId} (${email})`;
|
||||
const email = configEmail || (profile && "email" in profile ? profile.email?.trim() : undefined);
|
||||
if (email) {
|
||||
return `${profileId} (${email})`;
|
||||
}
|
||||
return profileId;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { listProfilesForProvider } from "./profiles.js";
|
||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
export function formatAuthDoctorHint(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
profileId?: string;
|
||||
}): string {
|
||||
const providerKey = normalizeProviderId(params.provider);
|
||||
if (providerKey !== "anthropic") return "";
|
||||
if (providerKey !== "anthropic") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const legacyProfileId = params.profileId ?? "anthropic:default";
|
||||
const suggested = suggestOAuthProfileIdForLegacyDefault({
|
||||
@@ -21,7 +23,9 @@ export function formatAuthDoctorHint(params: {
|
||||
provider: providerKey,
|
||||
legacyProfileId,
|
||||
});
|
||||
if (!suggested || suggested === legacyProfileId) return "";
|
||||
if (!suggested || suggested === legacyProfileId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const storeOauthProfiles = listProfilesForProvider(params.store, providerKey)
|
||||
.filter((id) => params.store.profiles[id]?.type === "oauth")
|
||||
@@ -38,6 +42,6 @@ export function formatAuthDoctorHint(params: {
|
||||
}`,
|
||||
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
|
||||
`- suggested profile: ${suggested}`,
|
||||
`Fix: run "${formatCliCommand("moltbot doctor --yes")}"`,
|
||||
`Fix: run "${formatCliCommand("openclaw doctor --yes")}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { readQwenCliCredentialsCached } from "../cli-credentials.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
import {
|
||||
readQwenCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached,
|
||||
} from "../cli-credentials.js";
|
||||
import {
|
||||
EXTERNAL_CLI_NEAR_EXPIRY_MS,
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
||||
if (!a) return false;
|
||||
if (a.type !== "oauth") return false;
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
if (a.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.provider === b.provider &&
|
||||
a.access === b.access &&
|
||||
@@ -23,17 +31,58 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr
|
||||
}
|
||||
|
||||
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
|
||||
if (!cred) return false;
|
||||
if (cred.type !== "oauth" && cred.type !== "token") return false;
|
||||
if (cred.provider !== "qwen-portal") {
|
||||
if (!cred) {
|
||||
return false;
|
||||
}
|
||||
if (typeof cred.expires !== "number") return true;
|
||||
if (cred.type !== "oauth" && cred.type !== "token") {
|
||||
return false;
|
||||
}
|
||||
if (cred.provider !== "qwen-portal" && cred.provider !== "minimax-portal") {
|
||||
return false;
|
||||
}
|
||||
if (typeof cred.expires !== "number") {
|
||||
return true;
|
||||
}
|
||||
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
|
||||
}
|
||||
|
||||
/** Sync external CLI credentials into the store for a given provider. */
|
||||
function syncExternalCliCredentialsForProvider(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
provider: string,
|
||||
readCredentials: () => OAuthCredential | null,
|
||||
now: number,
|
||||
): boolean {
|
||||
const existing = store.profiles[profileId];
|
||||
const shouldSync =
|
||||
!existing || existing.provider !== provider || !isExternalProfileFresh(existing, now);
|
||||
const creds = shouldSync ? readCredentials() : null;
|
||||
if (!creds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== provider ||
|
||||
existingOAuth.expires <= now ||
|
||||
creds.expires > existingOAuth.expires;
|
||||
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
||||
store.profiles[profileId] = creds;
|
||||
log.info(`synced ${provider} credentials from external cli`, {
|
||||
profileId,
|
||||
expires: new Date(creds.expires).toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store.
|
||||
* Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI) into the store.
|
||||
*
|
||||
* Returns true if any credentials were updated.
|
||||
*/
|
||||
@@ -69,5 +118,18 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync from MiniMax Portal CLI
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
"minimax-portal",
|
||||
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
now,
|
||||
)
|
||||
) {
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { resolveApiKeyForProfile } from "./oauth.js";
|
||||
import { ensureAuthProfileStore } from "./store.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tmpDir: string;
|
||||
let mainAgentDir: string;
|
||||
@@ -21,9 +21,9 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await fs.mkdir(secondaryAgentDir, { recursive: true });
|
||||
|
||||
// Set environment variables so resolveMoltbotAgentDir() returns mainAgentDir
|
||||
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = mainAgentDir;
|
||||
// Set environment variables so resolveOpenClawAgentDir() returns mainAgentDir
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = mainAgentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
|
||||
});
|
||||
|
||||
@@ -31,12 +31,21 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Restore original environment
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { getOAuthApiKey, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||
import { formatAuthDoctorHint } from "./doctor.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
||||
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
|
||||
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
|
||||
@@ -36,7 +35,9 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
const cred = store.profiles[params.profileId];
|
||||
if (!cred || cred.type !== "oauth") return null;
|
||||
if (!cred || cred.type !== "oauth") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
@@ -62,8 +63,10 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const newCredentials = await refreshQwenPortalCredentials(cred);
|
||||
return { apiKey: newCredentials.access, newCredentials };
|
||||
})()
|
||||
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
|
||||
if (!result) return null;
|
||||
: await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
store.profiles[params.profileId] = {
|
||||
...cred,
|
||||
...result.newCredentials,
|
||||
@@ -84,17 +87,23 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
}
|
||||
|
||||
async function tryResolveOAuthProfile(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred || cred.type !== "oauth") return null;
|
||||
if (!cred || cred.type !== "oauth") {
|
||||
return null;
|
||||
}
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) {
|
||||
return null;
|
||||
}
|
||||
if (profileConfig && profileConfig.mode !== cred.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() < cred.expires) {
|
||||
return {
|
||||
@@ -108,7 +117,9 @@ async function tryResolveOAuthProfile(params: {
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!refreshed) return null;
|
||||
if (!refreshed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
apiKey: refreshed.apiKey,
|
||||
provider: cred.provider,
|
||||
@@ -117,19 +128,25 @@ async function tryResolveOAuthProfile(params: {
|
||||
}
|
||||
|
||||
export async function resolveApiKeyForProfile(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred) return null;
|
||||
if (!cred) {
|
||||
return null;
|
||||
}
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) {
|
||||
return null;
|
||||
}
|
||||
if (profileConfig && profileConfig.mode !== cred.type) {
|
||||
// Compatibility: treat "oauth" config as compatible with stored token profiles.
|
||||
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
|
||||
if (!(profileConfig.mode === "oauth" && cred.type === "token")) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (cred.type === "api_key") {
|
||||
@@ -137,7 +154,9 @@ export async function resolveApiKeyForProfile(params: {
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
const token = cred.token?.trim();
|
||||
if (!token) return null;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof cred.expires === "number" &&
|
||||
Number.isFinite(cred.expires) &&
|
||||
@@ -161,7 +180,9 @@ export async function resolveApiKeyForProfile(params: {
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!result) return null;
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
apiKey: result.apiKey,
|
||||
provider: cred.provider,
|
||||
@@ -191,7 +212,9 @@ export async function resolveApiKeyForProfile(params: {
|
||||
profileId: fallbackProfileId,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (fallbackResolved) return fallbackResolved;
|
||||
if (fallbackResolved) {
|
||||
return fallbackResolved;
|
||||
}
|
||||
} catch {
|
||||
// keep original error
|
||||
}
|
||||
@@ -233,6 +256,7 @@ export async function resolveApiKeyForProfile(params: {
|
||||
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
|
||||
"Please try again or re-authenticate." +
|
||||
(hint ? `\n\n${hint}` : ""),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { listProfilesForProvider } from "./profiles.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { isProfileInCooldown } from "./usage.js";
|
||||
|
||||
function resolveProfileUnusableUntil(stats: {
|
||||
@@ -11,12 +11,14 @@ function resolveProfileUnusableUntil(stats: {
|
||||
const values = [stats.cooldownUntil, stats.disabledUntil]
|
||||
.filter((value): value is number => typeof value === "number")
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (values.length === 0) return null;
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileOrder(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
preferredProfile?: string;
|
||||
@@ -26,17 +28,25 @@ export function resolveAuthProfileOrder(params: {
|
||||
const now = Date.now();
|
||||
const storedOrder = (() => {
|
||||
const order = store.order;
|
||||
if (!order) return undefined;
|
||||
if (!order) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(order)) {
|
||||
if (normalizeProviderId(key) === providerKey) return value;
|
||||
if (normalizeProviderId(key) === providerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const configuredOrder = (() => {
|
||||
const order = cfg?.auth?.order;
|
||||
if (!order) return undefined;
|
||||
if (!order) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(order)) {
|
||||
if (normalizeProviderId(key) === providerKey) return value;
|
||||
if (normalizeProviderId(key) === providerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
@@ -49,12 +59,18 @@ export function resolveAuthProfileOrder(params: {
|
||||
const baseOrder =
|
||||
explicitOrder ??
|
||||
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
|
||||
if (baseOrder.length === 0) return [];
|
||||
if (baseOrder.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filtered = baseOrder.filter((profileId) => {
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred) return false;
|
||||
if (normalizeProviderId(cred.provider) !== providerKey) return false;
|
||||
if (!cred) {
|
||||
return false;
|
||||
}
|
||||
if (normalizeProviderId(cred.provider) !== providerKey) {
|
||||
return false;
|
||||
}
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig) {
|
||||
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
|
||||
@@ -62,12 +78,18 @@ export function resolveAuthProfileOrder(params: {
|
||||
}
|
||||
if (profileConfig.mode !== cred.type) {
|
||||
const oauthCompatible = profileConfig.mode === "oauth" && cred.type === "token";
|
||||
if (!oauthCompatible) return false;
|
||||
if (!oauthCompatible) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cred.type === "api_key") return Boolean(cred.key?.trim());
|
||||
if (cred.type === "api_key") {
|
||||
return Boolean(cred.key?.trim());
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
if (!cred.token?.trim()) return false;
|
||||
if (!cred.token?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof cred.expires === "number" &&
|
||||
Number.isFinite(cred.expires) &&
|
||||
@@ -85,7 +107,9 @@ export function resolveAuthProfileOrder(params: {
|
||||
});
|
||||
const deduped: string[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
if (!deduped.includes(entry)) {
|
||||
deduped.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// If user specified explicit order (store override or config), respect it
|
||||
@@ -112,7 +136,7 @@ export function resolveAuthProfileOrder(params: {
|
||||
}
|
||||
|
||||
const cooldownSorted = inCooldown
|
||||
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
|
||||
.toSorted((a, b) => a.cooldownUntil - b.cooldownUntil)
|
||||
.map((entry) => entry.profileId);
|
||||
|
||||
const ordered = [...available, ...cooldownSorted];
|
||||
@@ -163,9 +187,11 @@ function orderProfilesByMode(order: string[], store: AuthProfileStore): string[]
|
||||
// Primary sort: type preference (oauth > token > api_key).
|
||||
// Secondary sort: lastUsed (oldest first for round-robin within type).
|
||||
const sorted = scored
|
||||
.sort((a, b) => {
|
||||
.toSorted((a, b) => {
|
||||
// First by type (oauth > token > api_key)
|
||||
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
|
||||
if (a.typeScore !== b.typeScore) {
|
||||
return a.typeScore - b.typeScore;
|
||||
}
|
||||
// Then by lastUsed (oldest first)
|
||||
return a.lastUsed - b.lastUsed;
|
||||
})
|
||||
@@ -177,7 +203,7 @@ function orderProfilesByMode(order: string[], store: AuthProfileStore): string[]
|
||||
profileId,
|
||||
cooldownUntil: resolveProfileUnusableUntil(store.usageStats?.[profileId] ?? {}) ?? now,
|
||||
}))
|
||||
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
|
||||
.toSorted((a, b) => a.cooldownUntil - b.cooldownUntil)
|
||||
.map((entry) => entry.profileId);
|
||||
|
||||
return [...sorted, ...cooldownSorted];
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
import { saveJsonFile } from "../../infra/json-file.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
||||
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
||||
import { AUTH_PROFILE_FILENAME, AUTH_STORE_VERSION, LEGACY_AUTH_FILENAME } from "./constants.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
export function resolveAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveMoltbotAgentDir());
|
||||
const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir());
|
||||
return path.join(resolved, AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveLegacyAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveMoltbotAgentDir());
|
||||
const resolved = resolveUserPath(agentDir ?? resolveOpenClawAgentDir());
|
||||
return path.join(resolved, LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
@@ -23,7 +22,9 @@ export function resolveAuthStorePathForDisplay(agentDir?: string): string {
|
||||
}
|
||||
|
||||
export function ensureAuthStoreFile(pathname: string) {
|
||||
if (fs.existsSync(pathname)) return;
|
||||
if (fs.existsSync(pathname)) {
|
||||
return;
|
||||
}
|
||||
const payload: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
saveAuthProfileStore,
|
||||
updateAuthProfileStoreWithLock,
|
||||
} from "./store.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
|
||||
|
||||
export async function setAuthProfileOrder(params: {
|
||||
agentDir?: string;
|
||||
@@ -19,7 +19,9 @@ export async function setAuthProfileOrder(params: {
|
||||
|
||||
const deduped: string[] = [];
|
||||
for (const entry of sanitized) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
if (!deduped.includes(entry)) {
|
||||
deduped.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return await updateAuthProfileStoreWithLock({
|
||||
@@ -27,7 +29,9 @@ export async function setAuthProfileOrder(params: {
|
||||
updater: (store) => {
|
||||
store.order = store.order ?? {};
|
||||
if (deduped.length === 0) {
|
||||
if (!store.order[providerKey]) return false;
|
||||
if (!store.order[providerKey]) {
|
||||
return false;
|
||||
}
|
||||
delete store.order[providerKey];
|
||||
if (Object.keys(store.order).length === 0) {
|
||||
store.order = undefined;
|
||||
@@ -68,7 +72,9 @@ export async function markAuthProfileGood(params: {
|
||||
agentDir,
|
||||
updater: (freshStore) => {
|
||||
const profile = freshStore.profiles[profileId];
|
||||
if (!profile || profile.provider !== provider) return false;
|
||||
if (!profile || profile.provider !== provider) {
|
||||
return false;
|
||||
}
|
||||
freshStore.lastGood = { ...freshStore.lastGood, [provider]: profileId };
|
||||
return true;
|
||||
},
|
||||
@@ -78,7 +84,9 @@ export async function markAuthProfileGood(params: {
|
||||
return;
|
||||
}
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || profile.provider !== provider) return;
|
||||
if (!profile || profile.provider !== provider) {
|
||||
return;
|
||||
}
|
||||
store.lastGood = { ...store.lastGood, [provider]: profileId };
|
||||
saveAuthProfileStore(store, agentDir);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileConfig } from "../../config/types.js";
|
||||
import type { AuthProfileIdRepairResult, AuthProfileStore } from "./types.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { listProfilesForProvider } from "./profiles.js";
|
||||
import type { AuthProfileIdRepairResult, AuthProfileStore } from "./types.js";
|
||||
|
||||
function getProfileSuffix(profileId: string): string {
|
||||
const idx = profileId.indexOf(":");
|
||||
if (idx < 0) return "";
|
||||
if (idx < 0) {
|
||||
return "";
|
||||
}
|
||||
return profileId.slice(idx + 1);
|
||||
}
|
||||
|
||||
function isEmailLike(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.includes("@") && trimmed.includes(".");
|
||||
}
|
||||
|
||||
export function suggestOAuthProfileIdForLegacyDefault(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
legacyProfileId: string;
|
||||
}): string | null {
|
||||
const providerKey = normalizeProviderId(params.provider);
|
||||
const legacySuffix = getProfileSuffix(params.legacyProfileId);
|
||||
if (legacySuffix !== "default") return null;
|
||||
if (legacySuffix !== "default") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const legacyCfg = params.cfg?.auth?.profiles?.[params.legacyProfileId];
|
||||
if (
|
||||
@@ -38,33 +44,45 @@ export function suggestOAuthProfileIdForLegacyDefault(params: {
|
||||
const oauthProfiles = listProfilesForProvider(params.store, providerKey).filter(
|
||||
(id) => params.store.profiles[id]?.type === "oauth",
|
||||
);
|
||||
if (oauthProfiles.length === 0) return null;
|
||||
if (oauthProfiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredEmail = legacyCfg?.email?.trim();
|
||||
if (configuredEmail) {
|
||||
const byEmail = oauthProfiles.find((id) => {
|
||||
const cred = params.store.profiles[id];
|
||||
if (!cred || cred.type !== "oauth") return false;
|
||||
const email = (cred.email as string | undefined)?.trim();
|
||||
if (!cred || cred.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
const email = cred.email?.trim();
|
||||
return email === configuredEmail || id === `${providerKey}:${configuredEmail}`;
|
||||
});
|
||||
if (byEmail) return byEmail;
|
||||
if (byEmail) {
|
||||
return byEmail;
|
||||
}
|
||||
}
|
||||
|
||||
const lastGood = params.store.lastGood?.[providerKey] ?? params.store.lastGood?.[params.provider];
|
||||
if (lastGood && oauthProfiles.includes(lastGood)) return lastGood;
|
||||
if (lastGood && oauthProfiles.includes(lastGood)) {
|
||||
return lastGood;
|
||||
}
|
||||
|
||||
const nonLegacy = oauthProfiles.filter((id) => id !== params.legacyProfileId);
|
||||
if (nonLegacy.length === 1) return nonLegacy[0] ?? null;
|
||||
if (nonLegacy.length === 1) {
|
||||
return nonLegacy[0] ?? null;
|
||||
}
|
||||
|
||||
const emailLike = nonLegacy.filter((id) => isEmailLike(getProfileSuffix(id)));
|
||||
if (emailLike.length === 1) return emailLike[0] ?? null;
|
||||
if (emailLike.length === 1) {
|
||||
return emailLike[0] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function repairOAuthProfileIdMismatch(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
legacyProfileId?: string;
|
||||
@@ -93,11 +111,10 @@ export function repairOAuthProfileIdMismatch(params: {
|
||||
}
|
||||
|
||||
const toCred = params.store.profiles[toProfileId];
|
||||
const toEmail =
|
||||
toCred?.type === "oauth" ? (toCred.email as string | undefined)?.trim() : undefined;
|
||||
const toEmail = toCred?.type === "oauth" ? toCred.email?.trim() : undefined;
|
||||
|
||||
const nextProfiles = {
|
||||
...(params.cfg.auth?.profiles as Record<string, AuthProfileConfig> | undefined),
|
||||
...params.cfg.auth?.profiles,
|
||||
} as Record<string, AuthProfileConfig>;
|
||||
delete nextProfiles[legacyProfileId];
|
||||
nextProfiles[toProfileId] = {
|
||||
@@ -108,22 +125,30 @@ export function repairOAuthProfileIdMismatch(params: {
|
||||
const providerKey = normalizeProviderId(params.provider);
|
||||
const nextOrder = (() => {
|
||||
const order = params.cfg.auth?.order;
|
||||
if (!order) return undefined;
|
||||
if (!order) {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedKey = Object.keys(order).find((key) => normalizeProviderId(key) === providerKey);
|
||||
if (!resolvedKey) return order;
|
||||
if (!resolvedKey) {
|
||||
return order;
|
||||
}
|
||||
const existing = order[resolvedKey];
|
||||
if (!Array.isArray(existing)) return order;
|
||||
if (!Array.isArray(existing)) {
|
||||
return order;
|
||||
}
|
||||
const replaced = existing
|
||||
.map((id) => (id === legacyProfileId ? toProfileId : id))
|
||||
.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
||||
const deduped: string[] = [];
|
||||
for (const entry of replaced) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
if (!deduped.includes(entry)) {
|
||||
deduped.push(entry);
|
||||
}
|
||||
}
|
||||
return { ...order, [resolvedKey]: deduped };
|
||||
})();
|
||||
|
||||
const nextCfg: MoltbotConfig = {
|
||||
const nextCfg: OpenClawConfig = {
|
||||
...params.cfg,
|
||||
auth: {
|
||||
...params.cfg.auth,
|
||||
|
||||
@@ -2,8 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveSessionAuthProfileOverride } from "./session-override.js";
|
||||
|
||||
@@ -23,9 +22,9 @@ async function writeAuthStore(agentDir: string) {
|
||||
|
||||
describe("resolveSessionAuthProfileOverride", () => {
|
||||
it("keeps user override when provider alias differs", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tmpDir;
|
||||
try {
|
||||
const agentDir = path.join(tmpDir, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
@@ -40,7 +39,7 @@ describe("resolveSessionAuthProfileOverride", () => {
|
||||
const sessionStore = { "agent:main:main": sessionEntry };
|
||||
|
||||
const resolved = await resolveSessionAuthProfileOverride({
|
||||
cfg: {} as MoltbotConfig,
|
||||
cfg: {} as OpenClawConfig,
|
||||
provider: "z.ai",
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
@@ -53,8 +52,11 @@ describe("resolveSessionAuthProfileOverride", () => {
|
||||
expect(resolved).toBe("zai:work");
|
||||
expect(sessionEntry.authProfileOverride).toBe("zai:work");
|
||||
} finally {
|
||||
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
if (prevStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = prevStateDir;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../auth-profiles.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
|
||||
function isProfileForProvider(params: {
|
||||
provider: string;
|
||||
@@ -13,7 +13,9 @@ function isProfileForProvider(params: {
|
||||
store: ReturnType<typeof ensureAuthProfileStore>;
|
||||
}): boolean {
|
||||
const entry = params.store.profiles[params.profileId];
|
||||
if (!entry?.provider) return false;
|
||||
if (!entry?.provider) {
|
||||
return false;
|
||||
}
|
||||
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
|
||||
}
|
||||
|
||||
@@ -37,7 +39,7 @@ export async function clearSessionAuthProfileOverride(params: {
|
||||
}
|
||||
|
||||
export async function resolveSessionAuthProfileOverride(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
agentDir: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
@@ -56,7 +58,9 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
storePath,
|
||||
isNewSession,
|
||||
} = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) {
|
||||
return sessionEntry?.authProfileOverride;
|
||||
}
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
@@ -77,16 +81,22 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
current = undefined;
|
||||
}
|
||||
|
||||
if (order.length === 0) return undefined;
|
||||
if (order.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pickFirstAvailable = () =>
|
||||
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||
const pickNextAvailable = (active: string) => {
|
||||
const startIndex = order.indexOf(active);
|
||||
if (startIndex < 0) return pickFirstAvailable();
|
||||
if (startIndex < 0) {
|
||||
return pickFirstAvailable();
|
||||
}
|
||||
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||
if (!isProfileInCooldown(store, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return order[startIndex] ?? order[0];
|
||||
};
|
||||
@@ -117,7 +127,9 @@ export async function resolveSessionAuthProfileOverride(params: {
|
||||
next = pickFirstAvailable();
|
||||
}
|
||||
|
||||
if (!next) return current;
|
||||
if (!next) {
|
||||
return current;
|
||||
}
|
||||
const shouldPersist =
|
||||
next !== sessionEntry.authProfileOverride ||
|
||||
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import fs from "node:fs";
|
||||
import lockfile from "proper-lockfile";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
|
||||
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
@@ -48,12 +48,18 @@ export async function updateAuthProfileStoreWithLock(params: {
|
||||
}
|
||||
|
||||
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
if ("profiles" in record) return null;
|
||||
if ("profiles" in record) {
|
||||
return null;
|
||||
}
|
||||
const entries: LegacyAuthStore = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
|
||||
continue;
|
||||
@@ -67,29 +73,41 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
}
|
||||
|
||||
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (!record.profiles || typeof record.profiles !== "object") return null;
|
||||
if (!record.profiles || typeof record.profiles !== "object") {
|
||||
return null;
|
||||
}
|
||||
const profiles = record.profiles as Record<string, unknown>;
|
||||
const normalized: Record<string, AuthProfileCredential> = {};
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth" && typed.type !== "token") {
|
||||
continue;
|
||||
}
|
||||
if (!typed.provider) continue;
|
||||
if (!typed.provider) {
|
||||
continue;
|
||||
}
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
const order =
|
||||
record.order && typeof record.order === "object"
|
||||
? Object.entries(record.order as Record<string, unknown>).reduce(
|
||||
(acc, [provider, value]) => {
|
||||
if (!Array.isArray(value)) return acc;
|
||||
if (!Array.isArray(value)) {
|
||||
return acc;
|
||||
}
|
||||
const list = value
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean);
|
||||
if (list.length === 0) return acc;
|
||||
if (list.length === 0) {
|
||||
return acc;
|
||||
}
|
||||
acc[provider] = list;
|
||||
return acc;
|
||||
},
|
||||
@@ -115,9 +133,15 @@ function mergeRecord<T>(
|
||||
base?: Record<string, T>,
|
||||
override?: Record<string, T>,
|
||||
): Record<string, T> | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
if (!base) return { ...override };
|
||||
if (!override) return { ...base };
|
||||
if (!base && !override) {
|
||||
return undefined;
|
||||
}
|
||||
if (!base) {
|
||||
return { ...override };
|
||||
}
|
||||
if (!override) {
|
||||
return { ...base };
|
||||
}
|
||||
return { ...base, ...override };
|
||||
}
|
||||
|
||||
@@ -145,13 +169,19 @@ function mergeAuthProfileStores(
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") return false;
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
|
||||
let mutated = false;
|
||||
for (const [provider, creds] of Object.entries(oauthEntries)) {
|
||||
if (!creds || typeof creds !== "object") continue;
|
||||
if (!creds || typeof creds !== "object") {
|
||||
continue;
|
||||
}
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) continue;
|
||||
if (store.profiles[profileId]) {
|
||||
continue;
|
||||
}
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
export type ApiKeyCredential = {
|
||||
type: "api_key";
|
||||
@@ -12,7 +11,7 @@ export type ApiKeyCredential = {
|
||||
export type TokenCredential = {
|
||||
/**
|
||||
* Static bearer-style token (often OAuth access token / PAT).
|
||||
* Not refreshable by moltbot (unlike `type: "oauth"`).
|
||||
* Not refreshable by OpenClaw (unlike `type: "oauth"`).
|
||||
*/
|
||||
type: "token";
|
||||
provider: string;
|
||||
@@ -65,7 +64,7 @@ export type AuthProfileStore = {
|
||||
};
|
||||
|
||||
export type AuthProfileIdRepairResult = {
|
||||
config: MoltbotConfig;
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
migrated: boolean;
|
||||
fromProfileId?: string;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js";
|
||||
import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||
|
||||
function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
|
||||
const values = [stats.cooldownUntil, stats.disabledUntil]
|
||||
.filter((value): value is number => typeof value === "number")
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (values.length === 0) return null;
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
@@ -16,7 +18,9 @@ function resolveProfileUnusableUntil(stats: ProfileUsageStats): number | null {
|
||||
*/
|
||||
export function isProfileInCooldown(store: AuthProfileStore, profileId: string): boolean {
|
||||
const stats = store.usageStats?.[profileId];
|
||||
if (!stats) return false;
|
||||
if (!stats) {
|
||||
return false;
|
||||
}
|
||||
const unusableUntil = resolveProfileUnusableUntil(stats);
|
||||
return unusableUntil ? Date.now() < unusableUntil : false;
|
||||
}
|
||||
@@ -34,7 +38,9 @@ export async function markAuthProfileUsed(params: {
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir,
|
||||
updater: (freshStore) => {
|
||||
if (!freshStore.profiles[profileId]) return false;
|
||||
if (!freshStore.profiles[profileId]) {
|
||||
return false;
|
||||
}
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
@@ -52,7 +58,9 @@ export async function markAuthProfileUsed(params: {
|
||||
store.usageStats = updated.usageStats;
|
||||
return;
|
||||
}
|
||||
if (!store.profiles[profileId]) return;
|
||||
if (!store.profiles[profileId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
store.usageStats[profileId] = {
|
||||
@@ -82,7 +90,7 @@ type ResolvedAuthCooldownConfig = {
|
||||
};
|
||||
|
||||
function resolveAuthCooldownConfig(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
providerId: string;
|
||||
}): ResolvedAuthCooldownConfig {
|
||||
const defaults = {
|
||||
@@ -97,9 +105,13 @@ function resolveAuthCooldownConfig(params: {
|
||||
const cooldowns = params.cfg?.auth?.cooldowns;
|
||||
const billingOverride = (() => {
|
||||
const map = cooldowns?.billingBackoffHoursByProvider;
|
||||
if (!map) return undefined;
|
||||
if (!map) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (normalizeProviderId(key) === params.providerId) return value;
|
||||
if (normalizeProviderId(key) === params.providerId) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
@@ -139,7 +151,9 @@ export function resolveProfileUnusableUntilForDisplay(
|
||||
profileId: string,
|
||||
): number | null {
|
||||
const stats = store.usageStats?.[profileId];
|
||||
if (!stats) return null;
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
return resolveProfileUnusableUntil(stats);
|
||||
}
|
||||
|
||||
@@ -192,7 +206,7 @@ export async function markAuthProfileFailure(params: {
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
reason: AuthProfileFailureReason;
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
}): Promise<void> {
|
||||
const { store, profileId, reason, agentDir, cfg } = params;
|
||||
@@ -200,7 +214,9 @@ export async function markAuthProfileFailure(params: {
|
||||
agentDir,
|
||||
updater: (freshStore) => {
|
||||
const profile = freshStore.profiles[profileId];
|
||||
if (!profile) return false;
|
||||
if (!profile) {
|
||||
return false;
|
||||
}
|
||||
freshStore.usageStats = freshStore.usageStats ?? {};
|
||||
const existing = freshStore.usageStats[profileId] ?? {};
|
||||
|
||||
@@ -224,7 +240,9 @@ export async function markAuthProfileFailure(params: {
|
||||
store.usageStats = updated.usageStats;
|
||||
return;
|
||||
}
|
||||
if (!store.profiles[profileId]) return;
|
||||
if (!store.profiles[profileId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats = store.usageStats ?? {};
|
||||
const existing = store.usageStats[profileId] ?? {};
|
||||
@@ -275,7 +293,9 @@ export async function clearAuthProfileCooldown(params: {
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir,
|
||||
updater: (freshStore) => {
|
||||
if (!freshStore.usageStats?.[profileId]) return false;
|
||||
if (!freshStore.usageStats?.[profileId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
freshStore.usageStats[profileId] = {
|
||||
...freshStore.usageStats[profileId],
|
||||
@@ -289,7 +309,9 @@ export async function clearAuthProfileCooldown(params: {
|
||||
store.usageStats = updated.usageStats;
|
||||
return;
|
||||
}
|
||||
if (!store.usageStats?.[profileId]) return;
|
||||
if (!store.usageStats?.[profileId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.usageStats[profileId] = {
|
||||
...store.usageStats[profileId],
|
||||
|
||||
@@ -7,7 +7,9 @@ const MAX_JOB_TTL_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const DEFAULT_PENDING_OUTPUT_CHARS = 30_000;
|
||||
|
||||
function clampTtl(value: number | undefined) {
|
||||
if (!value || Number.isNaN(value)) return DEFAULT_JOB_TTL_MS;
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return DEFAULT_JOB_TTL_MS;
|
||||
}
|
||||
return Math.min(Math.max(value, MIN_JOB_TTL_MS), MAX_JOB_TTL_MS);
|
||||
}
|
||||
|
||||
@@ -155,7 +157,9 @@ export function markBackgrounded(session: ProcessSession) {
|
||||
|
||||
function moveToFinished(session: ProcessSession, status: ProcessStatus) {
|
||||
runningSessions.delete(session.id);
|
||||
if (!session.backgrounded) return;
|
||||
if (!session.backgrounded) {
|
||||
return;
|
||||
}
|
||||
finishedSessions.set(session.id, {
|
||||
id: session.id,
|
||||
command: session.command,
|
||||
@@ -174,18 +178,24 @@ function moveToFinished(session: ProcessSession, status: ProcessStatus) {
|
||||
}
|
||||
|
||||
export function tail(text: string, max = 2000) {
|
||||
if (text.length <= max) return text;
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(text.length - max);
|
||||
}
|
||||
|
||||
function sumPendingChars(buffer: string[]) {
|
||||
let total = 0;
|
||||
for (const chunk of buffer) total += chunk.length;
|
||||
for (const chunk of buffer) {
|
||||
total += chunk.length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function capPendingBuffer(buffer: string[], pendingChars: number, cap: number) {
|
||||
if (pendingChars <= cap) return pendingChars;
|
||||
if (pendingChars <= cap) {
|
||||
return pendingChars;
|
||||
}
|
||||
const last = buffer.at(-1);
|
||||
if (last && last.length >= cap) {
|
||||
buffer.length = 0;
|
||||
@@ -205,7 +215,9 @@ function capPendingBuffer(buffer: string[], pendingChars: number, cap: number) {
|
||||
}
|
||||
|
||||
export function trimWithCap(text: string, max: number) {
|
||||
if (text.length <= max) return text;
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(text.length - max);
|
||||
}
|
||||
|
||||
@@ -228,7 +240,9 @@ export function resetProcessRegistryForTests() {
|
||||
}
|
||||
|
||||
export function setJobTtlMs(value?: number) {
|
||||
if (value === undefined || Number.isNaN(value)) return;
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
jobTtlMs = clampTtl(value);
|
||||
stopSweeper();
|
||||
startSweeper();
|
||||
@@ -244,13 +258,17 @@ function pruneFinishedSessions() {
|
||||
}
|
||||
|
||||
function startSweeper() {
|
||||
if (sweeper) return;
|
||||
if (sweeper) {
|
||||
return;
|
||||
}
|
||||
sweeper = setInterval(pruneFinishedSessions, Math.max(30_000, jobTtlMs / 6));
|
||||
sweeper.unref?.();
|
||||
}
|
||||
|
||||
function stopSweeper() {
|
||||
if (!sweeper) return;
|
||||
if (!sweeper) {
|
||||
return;
|
||||
}
|
||||
clearInterval(sweeper);
|
||||
sweeper = null;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("exec approvals", () => {
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-test-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-"));
|
||||
process.env.HOME = tempDir;
|
||||
// Windows uses USERPROFILE for os.homedir()
|
||||
process.env.USERPROFILE = tempDir;
|
||||
@@ -80,7 +80,7 @@ describe("exec approvals", () => {
|
||||
|
||||
it("skips approval when node allowlist is satisfied", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-test-bin-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-bin-"));
|
||||
const binDir = path.join(tempDir, "bin");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "tool.cmd" : "tool";
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import {
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry";
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import { killProcessTree } from "./shell-utils";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -39,7 +38,9 @@ test("background exec is not killed when tool signal aborts", async () => {
|
||||
expect(running?.exited).toBe(false);
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
if (pid) killProcessTree(pid);
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,7 +77,9 @@ test("background exec still times out after tool signal abort", async () => {
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
if (pid) killProcessTree(pid);
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,7 +108,9 @@ test("yielded background exec is not killed when tool signal aborts", async () =
|
||||
expect(running?.exited).toBe(false);
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
if (pid) killProcessTree(pid);
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,6 +140,8 @@ test("yielded background exec still times out", async () => {
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
if (pid) killProcessTree(pid);
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,7 +61,9 @@ describe("exec PATH login shell merge", () => {
|
||||
});
|
||||
|
||||
it("merges login-shell PATH for host=gateway", async () => {
|
||||
if (isWin) return;
|
||||
if (isWin) {
|
||||
return;
|
||||
}
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
@@ -79,7 +81,9 @@ describe("exec PATH login shell merge", () => {
|
||||
});
|
||||
|
||||
it("skips login-shell PATH when env.PATH is provided", async () => {
|
||||
if (isWin) return;
|
||||
if (isWin) {
|
||||
return;
|
||||
}
|
||||
process.env.PATH = "/usr/bin";
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import {
|
||||
type ExecAsk,
|
||||
type ExecHost,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { logInfo, logWarn } from "../logger.js";
|
||||
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
type ProcessSession,
|
||||
type SessionStdin,
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
markExited,
|
||||
tail,
|
||||
} from "./bash-process-registry.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
import {
|
||||
buildDockerExecArgs,
|
||||
buildSandboxEnv,
|
||||
@@ -51,11 +51,10 @@ import {
|
||||
resolveWorkdir,
|
||||
truncateMiddle,
|
||||
} from "./bash-tools.shared.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||
@@ -64,7 +63,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
200_000,
|
||||
);
|
||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||
readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||
200_000,
|
||||
1_000,
|
||||
200_000,
|
||||
@@ -252,13 +251,19 @@ function normalizeNotifyOutput(value: string) {
|
||||
}
|
||||
|
||||
function normalizePathPrepend(entries?: string[]) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== "string") continue;
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
@@ -266,7 +271,9 @@ function normalizePathPrepend(entries?: string[]) {
|
||||
}
|
||||
|
||||
function mergePathPrepend(existing: string | undefined, prepend: string[]) {
|
||||
if (prepend.length === 0) return existing;
|
||||
if (prepend.length === 0) {
|
||||
return existing;
|
||||
}
|
||||
const partsExisting = (existing ?? "")
|
||||
.split(path.delimiter)
|
||||
.map((part) => part.trim())
|
||||
@@ -274,7 +281,9 @@ function mergePathPrepend(existing: string | undefined, prepend: string[]) {
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const part of [...prepend, ...partsExisting]) {
|
||||
if (seen.has(part)) continue;
|
||||
if (seen.has(part)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(part);
|
||||
merged.push(part);
|
||||
}
|
||||
@@ -286,27 +295,43 @@ function applyPathPrepend(
|
||||
prepend: string[],
|
||||
options?: { requireExisting?: boolean },
|
||||
) {
|
||||
if (prepend.length === 0) return;
|
||||
if (options?.requireExisting && !env.PATH) return;
|
||||
if (prepend.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (options?.requireExisting && !env.PATH) {
|
||||
return;
|
||||
}
|
||||
const merged = mergePathPrepend(env.PATH, prepend);
|
||||
if (merged) env.PATH = merged;
|
||||
if (merged) {
|
||||
env.PATH = merged;
|
||||
}
|
||||
}
|
||||
|
||||
function applyShellPath(env: Record<string, string>, shellPath?: string | null) {
|
||||
if (!shellPath) return;
|
||||
if (!shellPath) {
|
||||
return;
|
||||
}
|
||||
const entries = shellPath
|
||||
.split(path.delimiter)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (entries.length === 0) return;
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const merged = mergePathPrepend(env.PATH, entries);
|
||||
if (merged) env.PATH = merged;
|
||||
if (merged) {
|
||||
env.PATH = merged;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
|
||||
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
|
||||
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) {
|
||||
return;
|
||||
}
|
||||
const sessionKey = session.sessionKey?.trim();
|
||||
if (!sessionKey) return;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
session.exitNotified = true;
|
||||
const exitLabel = session.exitSignal
|
||||
? `signal ${session.exitSignal}`
|
||||
@@ -329,13 +354,17 @@ function resolveApprovalRunningNoticeMs(value?: number) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_APPROVAL_RUNNING_NOTICE_MS;
|
||||
}
|
||||
if (value <= 0) return 0;
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function emitExecSystemEvent(text: string, opts: { sessionKey?: string; contextKey?: string }) {
|
||||
const sessionKey = opts.sessionKey?.trim();
|
||||
if (!sessionKey) return;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
enqueueSystemEvent(text, { sessionKey, contextKey: opts.contextKey });
|
||||
requestHeartbeatNow({ reason: "exec-event" });
|
||||
}
|
||||
@@ -528,13 +557,17 @@ async function runExecProcess(opts: {
|
||||
let resolveFn: ((outcome: ExecProcessOutcome) => void) | null = null;
|
||||
|
||||
const settle = (outcome: ExecProcessOutcome) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolveFn?.(outcome);
|
||||
};
|
||||
|
||||
const finalizeTimeout = () => {
|
||||
if (session.exited) return;
|
||||
if (session.exited) {
|
||||
return;
|
||||
}
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
const aggregated = session.aggregated.trim();
|
||||
@@ -567,7 +600,9 @@ async function runExecProcess(opts: {
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (!opts.onUpdate) return;
|
||||
if (!opts.onUpdate) {
|
||||
return;
|
||||
}
|
||||
const tailText = session.tail || session.aggregated;
|
||||
const warningText = opts.warnings.length ? `${opts.warnings.join("\n")}\n\n` : "";
|
||||
opts.onUpdate({
|
||||
@@ -619,8 +654,12 @@ async function runExecProcess(opts: {
|
||||
const promise = new Promise<ExecProcessOutcome>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (timeoutFinalizeTimer) {
|
||||
clearTimeout(timeoutFinalizeTimer);
|
||||
}
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const wasSignal = exitSignal != null;
|
||||
const isSuccess = code === 0 && !wasSignal && !timedOut;
|
||||
@@ -631,7 +670,9 @@ async function runExecProcess(opts: {
|
||||
session.stdin.destroyed = true;
|
||||
}
|
||||
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
const aggregated = session.aggregated.trim();
|
||||
if (!isSuccess) {
|
||||
const reason = timedOut
|
||||
@@ -675,8 +716,12 @@ async function runExecProcess(opts: {
|
||||
});
|
||||
|
||||
child.once("error", (err) => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (timeoutFinalizeTimer) {
|
||||
clearTimeout(timeoutFinalizeTimer);
|
||||
}
|
||||
markExited(session, null, null, "failed");
|
||||
maybeNotifyOnExit(session, "failed");
|
||||
const aggregated = session.aggregated.trim();
|
||||
@@ -795,8 +840,12 @@ export function createExecTool(
|
||||
const contextParts: string[] = [];
|
||||
const provider = defaults?.messageProvider?.trim();
|
||||
const sessionKey = defaults?.sessionKey?.trim();
|
||||
if (provider) contextParts.push(`provider=${provider}`);
|
||||
if (sessionKey) contextParts.push(`session=${sessionKey}`);
|
||||
if (provider) {
|
||||
contextParts.push(`provider=${provider}`);
|
||||
}
|
||||
if (sessionKey) {
|
||||
contextParts.push(`session=${sessionKey}`);
|
||||
}
|
||||
if (!elevatedDefaults?.enabled) {
|
||||
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
|
||||
} else {
|
||||
@@ -912,6 +961,7 @@ export function createExecTool(
|
||||
if (!nodeQuery && String(err).includes("node required")) {
|
||||
throw new Error(
|
||||
"exec host=node requires a node id when multiple nodes are available (set tools.exec.node or exec.node).",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
@@ -941,11 +991,11 @@ export function createExecTool(
|
||||
let allowlistSatisfied = false;
|
||||
if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) {
|
||||
try {
|
||||
const approvalsSnapshot = (await callGatewayTool(
|
||||
const approvalsSnapshot = await callGatewayTool<{ file: string }>(
|
||||
"exec.approvals.node.get",
|
||||
{ timeoutMs: 10_000 },
|
||||
{ nodeId },
|
||||
)) as { file?: unknown } | null;
|
||||
);
|
||||
const approvalsFile =
|
||||
approvalsSnapshot && typeof approvalsSnapshot === "object"
|
||||
? approvalsSnapshot.file
|
||||
@@ -1016,7 +1066,7 @@ export function createExecTool(
|
||||
void (async () => {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
const decisionResult = (await callGatewayTool(
|
||||
const decisionResult = await callGatewayTool<{ decision: string }>(
|
||||
"exec.approval.request",
|
||||
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
|
||||
{
|
||||
@@ -1031,11 +1081,12 @@ export function createExecTool(
|
||||
sessionKey: defaults?.sessionKey,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
decision =
|
||||
);
|
||||
const decisionValue =
|
||||
decisionResult && typeof decisionResult === "object"
|
||||
? (decisionResult.decision ?? null)
|
||||
: null;
|
||||
? (decisionResult as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
decision = typeof decisionValue === "string" ? decisionValue : null;
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (node=${nodeId} id=${approvalId}, approval-request-failed): ${commandText}`,
|
||||
@@ -1097,7 +1148,9 @@ export function createExecTool(
|
||||
{ sessionKey: notifySessionKey, contextKey },
|
||||
);
|
||||
} finally {
|
||||
if (runningTimer) clearTimeout(runningTimer);
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1124,33 +1177,32 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const raw = (await callGatewayTool(
|
||||
const raw = await callGatewayTool(
|
||||
"node.invoke",
|
||||
{ timeoutMs: invokeTimeoutMs },
|
||||
buildInvokeParams(false, null),
|
||||
)) as {
|
||||
payload?: {
|
||||
exitCode?: number;
|
||||
timedOut?: boolean;
|
||||
success?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string | null;
|
||||
};
|
||||
};
|
||||
const payload = raw?.payload ?? {};
|
||||
);
|
||||
const payload =
|
||||
raw && typeof raw === "object" ? (raw as { payload?: unknown }).payload : undefined;
|
||||
const payloadObj =
|
||||
payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
||||
const stdout = typeof payloadObj.stdout === "string" ? payloadObj.stdout : "";
|
||||
const stderr = typeof payloadObj.stderr === "string" ? payloadObj.stderr : "";
|
||||
const errorText = typeof payloadObj.error === "string" ? payloadObj.error : "";
|
||||
const success = typeof payloadObj.success === "boolean" ? payloadObj.success : false;
|
||||
const exitCode = typeof payloadObj.exitCode === "number" ? payloadObj.exitCode : null;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: payload.stdout || payload.stderr || payload.error || "",
|
||||
text: stdout || stderr || errorText || "",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: payload.success ? "completed" : "failed",
|
||||
exitCode: payload.exitCode ?? null,
|
||||
status: success ? "completed" : "failed",
|
||||
exitCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
aggregated: [payload.stdout, payload.stderr, payload.error].filter(Boolean).join("\n"),
|
||||
aggregated: [stdout, stderr, errorText].filter(Boolean).join("\n"),
|
||||
cwd: workdir,
|
||||
} satisfies ExecToolDetails,
|
||||
};
|
||||
@@ -1197,7 +1249,7 @@ export function createExecTool(
|
||||
void (async () => {
|
||||
let decision: string | null = null;
|
||||
try {
|
||||
const decisionResult = (await callGatewayTool(
|
||||
const decisionResult = await callGatewayTool<{ decision: string }>(
|
||||
"exec.approval.request",
|
||||
{ timeoutMs: DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS },
|
||||
{
|
||||
@@ -1212,11 +1264,12 @@ export function createExecTool(
|
||||
sessionKey: defaults?.sessionKey,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
},
|
||||
)) as { decision?: string } | null;
|
||||
decision =
|
||||
);
|
||||
const decisionValue =
|
||||
decisionResult && typeof decisionResult === "object"
|
||||
? (decisionResult.decision ?? null)
|
||||
: null;
|
||||
? (decisionResult as { decision?: unknown }).decision
|
||||
: undefined;
|
||||
decision = typeof decisionValue === "string" ? decisionValue : null;
|
||||
} catch {
|
||||
emitExecSystemEvent(
|
||||
`Exec denied (gateway id=${approvalId}, approval-request-failed): ${commandText}`,
|
||||
@@ -1275,7 +1328,9 @@ export function createExecTool(
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (seen.has(match.pattern)) continue;
|
||||
if (seen.has(match.pattern)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
@@ -1325,7 +1380,9 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
const outcome = await run.promise;
|
||||
if (runningTimer) clearTimeout(runningTimer);
|
||||
if (runningTimer) {
|
||||
clearTimeout(runningTimer);
|
||||
}
|
||||
const output = normalizeNotifyOutput(
|
||||
tail(outcome.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
|
||||
);
|
||||
@@ -1365,7 +1422,9 @@ export function createExecTool(
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (seen.has(match.pattern)) continue;
|
||||
if (seen.has(match.pattern)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
@@ -1404,12 +1463,15 @@ export function createExecTool(
|
||||
|
||||
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
|
||||
const onAbortSignal = () => {
|
||||
if (yielded || run.session.backgrounded) return;
|
||||
if (yielded || run.session.backgrounded) {
|
||||
return;
|
||||
}
|
||||
run.kill();
|
||||
};
|
||||
|
||||
if (signal?.aborted) onAbortSignal();
|
||||
else if (signal) {
|
||||
if (signal?.aborted) {
|
||||
onAbortSignal();
|
||||
} else if (signal) {
|
||||
signal.addEventListener("abort", onAbortSignal, { once: true });
|
||||
}
|
||||
|
||||
@@ -1438,8 +1500,12 @@ export function createExecTool(
|
||||
});
|
||||
|
||||
const onYieldNow = () => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (yielded) return;
|
||||
if (yieldTimer) {
|
||||
clearTimeout(yieldTimer);
|
||||
}
|
||||
if (yielded) {
|
||||
return;
|
||||
}
|
||||
yielded = true;
|
||||
markBackgrounded(run.session);
|
||||
resolveRunning();
|
||||
@@ -1450,7 +1516,9 @@ export function createExecTool(
|
||||
onYieldNow();
|
||||
} else {
|
||||
yieldTimer = setTimeout(() => {
|
||||
if (yielded) return;
|
||||
if (yielded) {
|
||||
return;
|
||||
}
|
||||
yielded = true;
|
||||
markBackgrounded(run.session);
|
||||
resolveRunning();
|
||||
@@ -1460,8 +1528,12 @@ export function createExecTool(
|
||||
|
||||
run.promise
|
||||
.then((outcome) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (yielded || run.session.backgrounded) return;
|
||||
if (yieldTimer) {
|
||||
clearTimeout(yieldTimer);
|
||||
}
|
||||
if (yielded || run.session.backgrounded) {
|
||||
return;
|
||||
}
|
||||
if (outcome.status === "failed") {
|
||||
reject(new Error(outcome.reason ?? "Command failed."));
|
||||
return;
|
||||
@@ -1483,8 +1555,12 @@ export function createExecTool(
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (yielded || run.session.backgrounded) return;
|
||||
if (yieldTimer) {
|
||||
clearTimeout(yieldTimer);
|
||||
}
|
||||
if (yielded || run.session.backgrounded) {
|
||||
return;
|
||||
}
|
||||
reject(err as Error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry";
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import { createProcessTool } from "./bash-tools.process";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import {
|
||||
deleteSession,
|
||||
drainSession,
|
||||
@@ -116,7 +115,7 @@ export function createProcessTool(
|
||||
exitSignal: s.exitSignal ?? undefined,
|
||||
}));
|
||||
const lines = [...running, ...finished]
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.toSorted((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
@@ -337,8 +336,11 @@ export function createProcessTool(
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(params.data ?? "", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
if (params.eof) {
|
||||
@@ -414,8 +416,11 @@ export function createProcessTool(
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(data, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
@@ -472,8 +477,11 @@ export function createProcessTool(
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write("\r", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
@@ -540,8 +548,11 @@ export function createProcessTool(
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(payload, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { existsSync, statSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { sliceUtf16Safe } from "../utils.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import { killProcessTree } from "./shell-utils.js";
|
||||
@@ -38,9 +37,13 @@ export function buildSandboxEnv(params: {
|
||||
|
||||
export function coerceEnv(env?: NodeJS.ProcessEnv | Record<string, string>) {
|
||||
const record: Record<string, string> = {};
|
||||
if (!env) return record;
|
||||
if (!env) {
|
||||
return record;
|
||||
}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (typeof value === "string") record[key] = value;
|
||||
if (typeof value === "string") {
|
||||
record[key] = value;
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -53,7 +56,9 @@ export function buildDockerExecArgs(params: {
|
||||
tty: boolean;
|
||||
}) {
|
||||
const args = ["exec", "-i"];
|
||||
if (params.tty) args.push("-t");
|
||||
if (params.tty) {
|
||||
args.push("-t");
|
||||
}
|
||||
if (params.workdir) {
|
||||
args.push("-w", params.workdir);
|
||||
}
|
||||
@@ -63,14 +68,14 @@ export function buildDockerExecArgs(params: {
|
||||
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
|
||||
if (hasCustomPath) {
|
||||
// Avoid interpolating PATH into the shell command; pass it via env instead.
|
||||
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
|
||||
args.push("-e", `OPENCLAW_PREPEND_PATH=${params.env.PATH}`);
|
||||
}
|
||||
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||
// while preserving system paths that /etc/profile may have added.
|
||||
const pathExport = hasCustomPath
|
||||
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
|
||||
? 'export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"; unset OPENCLAW_PREPEND_PATH; '
|
||||
: "";
|
||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||
return args;
|
||||
@@ -122,7 +127,9 @@ export function resolveWorkdir(workdir: string, warnings: string[]) {
|
||||
const fallback = current ?? homedir();
|
||||
try {
|
||||
const stats = statSync(workdir);
|
||||
if (stats.isDirectory()) return workdir;
|
||||
if (stats.isDirectory()) {
|
||||
return workdir;
|
||||
}
|
||||
} catch {
|
||||
// ignore, fallback below
|
||||
}
|
||||
@@ -145,13 +152,17 @@ export function clampNumber(
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
if (value === undefined || Number.isNaN(value)) return defaultValue;
|
||||
if (value === undefined || Number.isNaN(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function readEnvInt(key: string) {
|
||||
const raw = process.env[key];
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
@@ -165,7 +176,9 @@ export function chunkString(input: string, limit = CHUNK_LIMIT) {
|
||||
}
|
||||
|
||||
export function truncateMiddle(str: string, max: number) {
|
||||
if (str.length <= max) return str;
|
||||
if (str.length <= max) {
|
||||
return str;
|
||||
}
|
||||
const half = Math.floor((max - 3) / 2);
|
||||
return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`;
|
||||
}
|
||||
@@ -175,7 +188,9 @@ export function sliceLogLines(
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
): { slice: string; totalLines: number; totalChars: number } {
|
||||
if (!text) return { slice: "", totalLines: 0, totalChars: 0 };
|
||||
if (!text) {
|
||||
return { slice: "", totalLines: 0, totalChars: 0 };
|
||||
}
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
@@ -198,11 +213,17 @@ export function sliceLogLines(
|
||||
|
||||
export function deriveSessionName(command: string): string | undefined {
|
||||
const tokens = tokenizeCommand(command);
|
||||
if (tokens.length === 0) return undefined;
|
||||
if (tokens.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const verb = tokens[0];
|
||||
let target = tokens.slice(1).find((t) => !t.startsWith("-"));
|
||||
if (!target) target = tokens[1];
|
||||
if (!target) return verb;
|
||||
if (!target) {
|
||||
target = tokens[1];
|
||||
}
|
||||
if (!target) {
|
||||
return verb;
|
||||
}
|
||||
const cleaned = truncateMiddle(stripQuotes(target), 48);
|
||||
return `${stripQuotes(verb)} ${cleaned}`;
|
||||
}
|
||||
@@ -224,15 +245,21 @@ function stripQuotes(value: string): string {
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rem = seconds % 60;
|
||||
return `${minutes}m${rem.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function pad(str: string, width: number) {
|
||||
if (str.length >= width) return str;
|
||||
if (str.length >= width) {
|
||||
return str;
|
||||
}
|
||||
return str + " ".repeat(width - str.length);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
|
||||
@@ -8,6 +8,26 @@ import { buildDockerExecArgs } from "./bash-tools.shared.js";
|
||||
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const resolveShellFromPath = (name: string) => {
|
||||
const envPath = process.env.PATH ?? "";
|
||||
if (!envPath) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(entry, name);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// ignore missing or non-executable entries
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const defaultShell = isWin
|
||||
? undefined
|
||||
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
||||
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05";
|
||||
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
||||
@@ -52,11 +72,15 @@ describe("exec tool backgrounding", () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isWin) process.env.SHELL = "/bin/bash";
|
||||
if (!isWin && defaultShell) {
|
||||
process.env.SHELL = defaultShell;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (!isWin) process.env.SHELL = originalShell;
|
||||
if (!isWin) {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
@@ -282,12 +306,16 @@ describe("exec PATH handling", () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isWin) process.env.SHELL = "/bin/bash";
|
||||
if (!isWin && defaultShell) {
|
||||
process.env.SHELL = defaultShell;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.PATH = originalPath;
|
||||
if (!isWin) process.env.SHELL = originalShell;
|
||||
if (!isWin) {
|
||||
process.env.SHELL = originalShell;
|
||||
}
|
||||
});
|
||||
|
||||
it("prepends configured path entries", async () => {
|
||||
@@ -318,16 +346,16 @@ describe("buildDockerExecArgs", () => {
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
||||
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
|
||||
expect(args).toContain("OPENCLAW_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
||||
expect(commandArg).toContain('export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"');
|
||||
expect(commandArg).toContain("echo hello");
|
||||
expect(commandArg).toBe(
|
||||
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
|
||||
'export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"; unset OPENCLAW_PREPEND_PATH; echo hello',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not interpolate PATH into the shell command", () => {
|
||||
const injectedPath = "$(touch /tmp/moltbot-path-injection)";
|
||||
const injectedPath = "$(touch /tmp/openclaw-path-injection)";
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
command: "echo hello",
|
||||
@@ -339,9 +367,9 @@ describe("buildDockerExecArgs", () => {
|
||||
});
|
||||
|
||||
const commandArg = args[args.length - 1];
|
||||
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
|
||||
expect(args).toContain(`OPENCLAW_PREPEND_PATH=${injectedPath}`);
|
||||
expect(commandArg).not.toContain(injectedPath);
|
||||
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
|
||||
expect(commandArg).toContain("OPENCLAW_PREPEND_PATH");
|
||||
});
|
||||
|
||||
it("does not add PATH export when PATH is not in env", () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ListFoundationModelsCommand,
|
||||
type ListFoundationModelsCommandOutput,
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
|
||||
import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
|
||||
@@ -28,11 +27,13 @@ const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
|
||||
let hasLoggedBedrockError = false;
|
||||
|
||||
function normalizeProviderFilter(filter?: string[]): string[] {
|
||||
if (!filter || filter.length === 0) return [];
|
||||
if (!filter || filter.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = new Set(
|
||||
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
|
||||
);
|
||||
return Array.from(normalized).sort();
|
||||
return Array.from(normalized).toSorted();
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
@@ -59,10 +60,16 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
|
||||
const mapped = new Set<"text" | "image">();
|
||||
for (const modality of inputs) {
|
||||
const lower = modality.toLowerCase();
|
||||
if (lower === "text") mapped.add("text");
|
||||
if (lower === "image") mapped.add("image");
|
||||
if (lower === "text") {
|
||||
mapped.add("text");
|
||||
}
|
||||
if (lower === "image") {
|
||||
mapped.add("image");
|
||||
}
|
||||
}
|
||||
if (mapped.size === 0) {
|
||||
mapped.add("text");
|
||||
}
|
||||
if (mapped.size === 0) mapped.add("text");
|
||||
return Array.from(mapped);
|
||||
}
|
||||
|
||||
@@ -82,21 +89,35 @@ function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
|
||||
}
|
||||
|
||||
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (filter.length === 0) return true;
|
||||
if (filter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const providerName =
|
||||
summary.providerName ??
|
||||
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
|
||||
const normalized = providerName?.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return filter.includes(normalized);
|
||||
}
|
||||
|
||||
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (!summary.modelId?.trim()) return false;
|
||||
if (!matchesProviderFilter(summary, filter)) return false;
|
||||
if (summary.responseStreamingSupported !== true) return false;
|
||||
if (!includesTextModalities(summary.outputModalities)) return false;
|
||||
if (!isActive(summary)) return false;
|
||||
if (!summary.modelId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesProviderFilter(summary, filter)) {
|
||||
return false;
|
||||
}
|
||||
if (summary.responseStreamingSupported !== true) {
|
||||
return false;
|
||||
}
|
||||
if (!includesTextModalities(summary.outputModalities)) {
|
||||
return false;
|
||||
}
|
||||
if (!isActive(summary)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -160,7 +181,9 @@ export async function discoverBedrockModels(params: {
|
||||
const response = await client.send(new ListFoundationModelsCommand({}));
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
for (const summary of response.modelSummaries ?? []) {
|
||||
if (!shouldIncludeSummary(summary, providerFilter)) continue;
|
||||
if (!shouldIncludeSummary(summary, providerFilter)) {
|
||||
continue;
|
||||
}
|
||||
discovered.push(
|
||||
toModelDefinition(summary, {
|
||||
contextWindow: defaultContextWindow,
|
||||
@@ -168,7 +191,7 @@ export async function discoverBedrockModels(params: {
|
||||
}),
|
||||
);
|
||||
}
|
||||
return discovered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return discovered.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import {
|
||||
clearInternalHooks,
|
||||
registerInternalHook,
|
||||
type AgentBootstrapHookContext,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import { makeTempWorkspace } from "../test-helpers/workspace.js";
|
||||
import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js";
|
||||
|
||||
describe("resolveBootstrapFilesForRun", () => {
|
||||
beforeEach(() => clearInternalHooks());
|
||||
@@ -28,7 +26,7 @@ describe("resolveBootstrapFilesForRun", () => {
|
||||
];
|
||||
});
|
||||
|
||||
const workspaceDir = await makeTempWorkspace("moltbot-bootstrap-");
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
const files = await resolveBootstrapFilesForRun({ workspaceDir });
|
||||
|
||||
expect(files.some((file) => file.name === "EXTRA.md")).toBe(true);
|
||||
@@ -53,7 +51,7 @@ describe("resolveBootstrapContextForRun", () => {
|
||||
];
|
||||
});
|
||||
|
||||
const workspaceDir = await makeTempWorkspace("moltbot-bootstrap-");
|
||||
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
|
||||
const result = await resolveBootstrapContextForRun({ workspaceDir });
|
||||
const extra = result.contextFiles.find((file) => file.path === "EXTRA.md");
|
||||
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
filterBootstrapFilesForSession,
|
||||
loadWorkspaceBootstrapFiles,
|
||||
type WorkspaceBootstrapFile,
|
||||
} from "./workspace.js";
|
||||
import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||
|
||||
export function makeBootstrapWarn(params: {
|
||||
sessionLabel: string;
|
||||
warn?: (message: string) => void;
|
||||
}): ((message: string) => void) | undefined {
|
||||
if (!params.warn) return undefined;
|
||||
if (!params.warn) {
|
||||
return undefined;
|
||||
}
|
||||
return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`);
|
||||
}
|
||||
|
||||
export async function resolveBootstrapFilesForRun(params: {
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
@@ -40,7 +42,7 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
|
||||
export async function resolveBootstrapContextForRun(params: {
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
import {
|
||||
clearInternalHooks,
|
||||
registerInternalHook,
|
||||
type AgentBootstrapHookContext,
|
||||
} from "../hooks/internal-hooks.js";
|
||||
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
||||
import { DEFAULT_SOUL_FILENAME, type WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
function makeFile(name = DEFAULT_SOUL_FILENAME): WorkspaceBootstrapFile {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentBootstrapHookContext } from "../hooks/internal-hooks.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
export async function applyBootstrapHookOverrides(params: {
|
||||
files: WorkspaceBootstrapFile[];
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createCacheTrace } from "./cache-trace.js";
|
||||
|
||||
describe("createCacheTrace", () => {
|
||||
it("returns null when diagnostics cache tracing is disabled", () => {
|
||||
const trace = createCacheTrace({
|
||||
cfg: {} as MoltbotConfig,
|
||||
cfg: {} as OpenClawConfig,
|
||||
env: {},
|
||||
});
|
||||
|
||||
@@ -21,7 +20,7 @@ describe("createCacheTrace", () => {
|
||||
diagnostics: {
|
||||
cacheTrace: {
|
||||
enabled: true,
|
||||
filePath: "~/.clawdbot/logs/cache-trace.jsonl",
|
||||
filePath: "~/.openclaw/logs/cache-trace.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -33,7 +32,7 @@ describe("createCacheTrace", () => {
|
||||
});
|
||||
|
||||
expect(trace).not.toBeNull();
|
||||
expect(trace?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/cache-trace.jsonl"));
|
||||
expect(trace?.filePath).toBe(resolveUserPath("~/.openclaw/logs/cache-trace.jsonl"));
|
||||
|
||||
trace?.recordStage("session:loaded", {
|
||||
messages: [],
|
||||
@@ -80,7 +79,7 @@ describe("createCacheTrace", () => {
|
||||
},
|
||||
},
|
||||
env: {
|
||||
CLAWDBOT_CACHE_TRACE: "0",
|
||||
OPENCLAW_CACHE_TRACE: "0",
|
||||
},
|
||||
writer: {
|
||||
filePath: "memory",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
|
||||
export type CacheTraceStage =
|
||||
| "session:loaded"
|
||||
@@ -52,7 +49,7 @@ export type CacheTrace = {
|
||||
};
|
||||
|
||||
type CacheTraceInit = {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
@@ -82,17 +79,17 @@ const writers = new Map<string, CacheTraceWriter>();
|
||||
function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig {
|
||||
const env = params.env ?? process.env;
|
||||
const config = params.cfg?.diagnostics?.cacheTrace;
|
||||
const envEnabled = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE);
|
||||
const envEnabled = parseBooleanValue(env.OPENCLAW_CACHE_TRACE);
|
||||
const enabled = envEnabled ?? config?.enabled ?? false;
|
||||
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_CACHE_TRACE_FILE?.trim();
|
||||
const fileOverride = config?.filePath?.trim() || env.OPENCLAW_CACHE_TRACE_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "cache-trace.jsonl");
|
||||
|
||||
const includeMessages =
|
||||
parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_MESSAGES) ?? config?.includeMessages;
|
||||
const includePrompt = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_PROMPT) ?? config?.includePrompt;
|
||||
const includeSystem = parseBooleanValue(env.CLAWDBOT_CACHE_TRACE_SYSTEM) ?? config?.includeSystem;
|
||||
parseBooleanValue(env.OPENCLAW_CACHE_TRACE_MESSAGES) ?? config?.includeMessages;
|
||||
const includePrompt = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_PROMPT) ?? config?.includePrompt;
|
||||
const includeSystem = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_SYSTEM) ?? config?.includeSystem;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
@@ -105,7 +102,9 @@ function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig {
|
||||
|
||||
function getWriter(filePath: string): CacheTraceWriter {
|
||||
const existing = writers.get(filePath);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
||||
@@ -126,10 +125,18 @@ function getWriter(filePath: string): CacheTraceWriter {
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || value === undefined) return String(value);
|
||||
if (typeof value === "number" && !Number.isFinite(value)) return JSON.stringify(String(value));
|
||||
if (typeof value === "bigint") return JSON.stringify(value.toString());
|
||||
if (typeof value !== "object") return JSON.stringify(value) ?? "null";
|
||||
if (value === null || value === undefined) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return JSON.stringify(String(value));
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return JSON.stringify(value.toString());
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return stableStringify({
|
||||
name: value.name,
|
||||
@@ -147,7 +154,7 @@ function stableStringify(value: unknown): string {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
const keys = Object.keys(record).toSorted();
|
||||
const entries = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
@@ -175,8 +182,12 @@ function summarizeMessages(messages: AgentMessage[]): {
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (typeof val === "bigint") {
|
||||
return val.toString();
|
||||
}
|
||||
if (typeof val === "function") {
|
||||
return "[Function]";
|
||||
}
|
||||
if (val instanceof Error) {
|
||||
return { name: val.name, message: val.message, stack: val.stack };
|
||||
}
|
||||
@@ -192,7 +203,9 @@ function safeJsonStringify(value: unknown): string | null {
|
||||
|
||||
export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
||||
const cfg = resolveCacheTraceConfig(params);
|
||||
if (!cfg.enabled) return null;
|
||||
if (!cfg.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const writer = params.writer ?? getWriter(cfg.filePath);
|
||||
let seq = 0;
|
||||
@@ -222,8 +235,12 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
||||
event.system = payload.system;
|
||||
event.systemDigest = digest(payload.system);
|
||||
}
|
||||
if (payload.options) event.options = payload.options;
|
||||
if (payload.model) event.model = payload.model;
|
||||
if (payload.options) {
|
||||
event.options = payload.options;
|
||||
}
|
||||
if (payload.model) {
|
||||
event.model = payload.model;
|
||||
}
|
||||
|
||||
const messages = payload.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
@@ -237,11 +254,17 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.note) event.note = payload.note;
|
||||
if (payload.error) event.error = payload.error;
|
||||
if (payload.note) {
|
||||
event.note = payload.note;
|
||||
}
|
||||
if (payload.error) {
|
||||
event.error = payload.error;
|
||||
}
|
||||
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) return;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
writer.write(`${line}\n`);
|
||||
};
|
||||
|
||||
@@ -249,9 +272,9 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
||||
const wrapped: StreamFn = (model, context, options) => {
|
||||
recordStage("stream:context", {
|
||||
model: {
|
||||
id: (model as Model<Api>)?.id,
|
||||
provider: (model as Model<Api>)?.provider,
|
||||
api: (model as Model<Api>)?.api,
|
||||
id: model?.id,
|
||||
provider: model?.provider,
|
||||
api: model?.api,
|
||||
},
|
||||
system: (context as { system?: unknown }).system,
|
||||
messages: (context as { messages?: AgentMessage[] }).messages ?? [],
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { __testing, listAllChannelSupportedActions } from "./channel-tools.js";
|
||||
|
||||
describe("channel tools", () => {
|
||||
@@ -43,7 +42,7 @@ describe("channel tools", () => {
|
||||
});
|
||||
|
||||
it("skips crashing plugins and logs once", () => {
|
||||
const cfg = {} as MoltbotConfig;
|
||||
const cfg = {} as OpenClawConfig;
|
||||
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getChannelDock } from "../channels/dock.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
||||
import type {
|
||||
ChannelAgentTool,
|
||||
ChannelMessageActionName,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getChannelDock } from "../channels/dock.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
/**
|
||||
@@ -14,13 +14,17 @@ import { defaultRuntime } from "../runtime.js";
|
||||
* Returns an empty array if channel is not found or has no actions configured.
|
||||
*/
|
||||
export function listChannelSupportedActions(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
channel?: string;
|
||||
}): ChannelMessageActionName[] {
|
||||
if (!params.channel) return [];
|
||||
if (!params.channel) {
|
||||
return [];
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
if (!plugin?.actions?.listActions) return [];
|
||||
const cfg = params.cfg ?? ({} as MoltbotConfig);
|
||||
if (!plugin?.actions?.listActions) {
|
||||
return [];
|
||||
}
|
||||
const cfg = params.cfg ?? ({} as OpenClawConfig);
|
||||
return runPluginListActions(plugin, cfg);
|
||||
}
|
||||
|
||||
@@ -28,12 +32,14 @@ export function listChannelSupportedActions(params: {
|
||||
* Get the list of all supported message actions across all configured channels.
|
||||
*/
|
||||
export function listAllChannelSupportedActions(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
}): ChannelMessageActionName[] {
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
if (!plugin.actions?.listActions) continue;
|
||||
const cfg = params.cfg ?? ({} as MoltbotConfig);
|
||||
if (!plugin.actions?.listActions) {
|
||||
continue;
|
||||
}
|
||||
const cfg = params.cfg ?? ({} as OpenClawConfig);
|
||||
const channelActions = runPluginListActions(plugin, cfg);
|
||||
for (const action of channelActions) {
|
||||
actions.add(action);
|
||||
@@ -42,29 +48,37 @@ export function listAllChannelSupportedActions(params: {
|
||||
return Array.from(actions);
|
||||
}
|
||||
|
||||
export function listChannelAgentTools(params: { cfg?: MoltbotConfig }): ChannelAgentTool[] {
|
||||
export function listChannelAgentTools(params: { cfg?: OpenClawConfig }): ChannelAgentTool[] {
|
||||
// Channel docking: aggregate channel-owned tools (login, etc.).
|
||||
const tools: ChannelAgentTool[] = [];
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const entry = plugin.agentTools;
|
||||
if (!entry) continue;
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const resolved = typeof entry === "function" ? entry(params) : entry;
|
||||
if (Array.isArray(resolved)) tools.push(...resolved);
|
||||
if (Array.isArray(resolved)) {
|
||||
tools.push(...resolved);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function resolveChannelMessageToolHints(params: {
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const channelId = normalizeAnyChannelId(params.channel);
|
||||
if (!channelId) return [];
|
||||
if (!channelId) {
|
||||
return [];
|
||||
}
|
||||
const dock = getChannelDock(channelId);
|
||||
const resolve = dock?.agentPrompt?.messageToolHints;
|
||||
if (!resolve) return [];
|
||||
const cfg = params.cfg ?? ({} as MoltbotConfig);
|
||||
if (!resolve) {
|
||||
return [];
|
||||
}
|
||||
const cfg = params.cfg ?? ({} as OpenClawConfig);
|
||||
return (resolve({ cfg, accountId: params.accountId }) ?? [])
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
@@ -74,9 +88,11 @@ const loggedListActionErrors = new Set<string>();
|
||||
|
||||
function runPluginListActions(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
): ChannelMessageActionName[] {
|
||||
if (!plugin.actions?.listActions) return [];
|
||||
if (!plugin.actions?.listActions) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const listed = plugin.actions.listActions({ cfg });
|
||||
return Array.isArray(listed) ? listed : [];
|
||||
@@ -89,7 +105,9 @@ function runPluginListActions(
|
||||
function logListActionsError(pluginId: string, err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const key = `${pluginId}:${message}`;
|
||||
if (loggedListActionErrors.has(key)) return;
|
||||
if (loggedListActionErrors.has(key)) {
|
||||
return;
|
||||
}
|
||||
loggedListActionErrors.add(key);
|
||||
const stack = err instanceof Error && err.stack ? err.stack : null;
|
||||
const details = stack ?? message;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
CHUTES_USERINFO_ENDPOINT,
|
||||
@@ -61,7 +60,9 @@ describe("chutes-oauth", () => {
|
||||
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as URLSearchParams;
|
||||
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
|
||||
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
|
||||
@@ -39,13 +38,17 @@ export function parseOAuthCallbackInput(
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
if (!trimmed) {
|
||||
return { error: "No input provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
}
|
||||
@@ -71,9 +74,13 @@ export async function fetchChutesUserInfo(params: {
|
||||
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
const typed = data as ChutesUserInfo;
|
||||
return typed;
|
||||
}
|
||||
@@ -119,7 +126,9 @@ export async function exchangeChutesCodeForTokens(params: {
|
||||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Chutes token exchange returned no access_token");
|
||||
if (!access) {
|
||||
throw new Error("Chutes token exchange returned no access_token");
|
||||
}
|
||||
if (!refresh) {
|
||||
throw new Error("Chutes token exchange returned no refresh_token");
|
||||
}
|
||||
@@ -160,7 +169,9 @@ export async function refreshChutesTokens(params: {
|
||||
client_id: clientId,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
if (clientSecret) body.set("client_secret", clientSecret);
|
||||
if (clientSecret) {
|
||||
body.set("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
@@ -181,7 +192,9 @@ export async function refreshChutesTokens(params: {
|
||||
const newRefresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Chutes token refresh returned no access_token");
|
||||
if (!access) {
|
||||
throw new Error("Chutes token refresh returned no access_token");
|
||||
}
|
||||
|
||||
return {
|
||||
...params.credential,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runClaudeCliAgent } from "./claude-cli-runner.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
@@ -20,7 +19,9 @@ function createDeferred<T>() {
|
||||
|
||||
async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: number) {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
if (mockFn.mock.calls.length >= count) return;
|
||||
if (mockFn.mock.calls.length >= count) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error(`Expected ${count} calls, got ${mockFn.mock.calls.length}`);
|
||||
@@ -45,7 +46,7 @@ describe("runClaudeCliAgent", () => {
|
||||
});
|
||||
|
||||
await runClaudeCliAgent({
|
||||
sessionId: "moltbot-session",
|
||||
sessionId: "openclaw-session",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
@@ -71,7 +72,7 @@ describe("runClaudeCliAgent", () => {
|
||||
});
|
||||
|
||||
await runClaudeCliAgent({
|
||||
sessionId: "moltbot-session",
|
||||
sessionId: "openclaw-session",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
@@ -83,13 +83,17 @@ function pickBackendConfig(
|
||||
normalizedId: string,
|
||||
): CliBackendConfig | undefined {
|
||||
for (const [key, entry] of Object.entries(config)) {
|
||||
if (normalizeBackendKey(key) === normalizedId) return entry;
|
||||
if (normalizeBackendKey(key) === normalizedId) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig): CliBackendConfig {
|
||||
if (!override) return { ...base };
|
||||
if (!override) {
|
||||
return { ...base };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
@@ -103,7 +107,7 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCliBackendIds(cfg?: MoltbotConfig): Set<string> {
|
||||
export function resolveCliBackendIds(cfg?: OpenClawConfig): Set<string> {
|
||||
const ids = new Set<string>([
|
||||
normalizeBackendKey("claude-cli"),
|
||||
normalizeBackendKey("codex-cli"),
|
||||
@@ -117,7 +121,7 @@ export function resolveCliBackendIds(cfg?: MoltbotConfig): Set<string> {
|
||||
|
||||
export function resolveCliBackendConfig(
|
||||
provider: string,
|
||||
cfg?: MoltbotConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
): ResolvedCliBackend | null {
|
||||
const normalized = normalizeBackendKey(provider);
|
||||
const configured = cfg?.agents?.defaults?.cliBackends ?? {};
|
||||
@@ -126,18 +130,26 @@ export function resolveCliBackendConfig(
|
||||
if (normalized === "claude-cli") {
|
||||
const merged = mergeBackendConfig(DEFAULT_CLAUDE_BACKEND, override);
|
||||
const command = merged.command?.trim();
|
||||
if (!command) return null;
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { id: normalized, config: { ...merged, command } };
|
||||
}
|
||||
if (normalized === "codex-cli") {
|
||||
const merged = mergeBackendConfig(DEFAULT_CODEX_BACKEND, override);
|
||||
const command = merged.command?.trim();
|
||||
if (!command) return null;
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { id: normalized, config: { ...merged, command } };
|
||||
}
|
||||
|
||||
if (!override) return null;
|
||||
if (!override) {
|
||||
return null;
|
||||
}
|
||||
const command = override.command?.trim();
|
||||
if (!command) return null;
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { id: normalized, config: { ...override, command } };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execSyncMock = vi.fn();
|
||||
@@ -59,7 +58,7 @@ describe("cli credentials", () => {
|
||||
});
|
||||
|
||||
it("falls back to the file store when the keychain update fails", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-"));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-"));
|
||||
const credPath = path.join(tempDir, ".claude", ".credentials.json");
|
||||
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
|
||||
@@ -182,7 +181,7 @@ describe("cli credentials", () => {
|
||||
});
|
||||
|
||||
it("reads Codex credentials from keychain when available", async () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-codex-"));
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
|
||||
const accountHash = "cli|";
|
||||
@@ -211,7 +210,7 @@ describe("cli credentials", () => {
|
||||
});
|
||||
|
||||
it("falls back to Codex auth.json when keychain is unavailable", async () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-codex-"));
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
|
||||
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -14,6 +12,7 @@ const log = createSubsystemLogger("agents/auth-profiles");
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
|
||||
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
|
||||
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
||||
@@ -27,11 +26,13 @@ type CachedValue<T> = {
|
||||
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
|
||||
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
|
||||
|
||||
export function resetCliCredentialCachesForTest(): void {
|
||||
claudeCliCache = null;
|
||||
codexCliCache = null;
|
||||
qwenCliCache = null;
|
||||
minimaxCliCache = null;
|
||||
}
|
||||
|
||||
export type ClaudeCliCredential =
|
||||
@@ -66,6 +67,14 @@ export type QwenCliCredential = {
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type MiniMaxCliCredential = {
|
||||
type: "oauth";
|
||||
provider: "minimax-portal";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
type ClaudeCliFileOptions = {
|
||||
homeDir?: string;
|
||||
};
|
||||
@@ -102,6 +111,11 @@ function resolveQwenCliCredentialsPath(homeDir?: string) {
|
||||
return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function resolveMiniMaxCliCredentialsPath(homeDir?: string) {
|
||||
const baseDir = homeDir ?? resolveUserPath("~");
|
||||
return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function computeCodexKeychainAccount(codexHome: string) {
|
||||
const hash = createHash("sha256").update(codexHome).digest("hex");
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
@@ -112,7 +126,9 @@ function readCodexKeychainCredentials(options?: {
|
||||
execSync?: ExecSyncFn;
|
||||
}): CodexCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
if (platform !== "darwin") return null;
|
||||
if (platform !== "darwin") {
|
||||
return null;
|
||||
}
|
||||
const execSyncImpl = options?.execSync ?? execSync;
|
||||
|
||||
const codexHome = resolveCodexHomePath();
|
||||
@@ -132,8 +148,12 @@ function readCodexKeychainCredentials(options?: {
|
||||
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
||||
const accessToken = tokens?.access_token;
|
||||
const refreshToken = tokens?.refresh_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof refreshToken !== "string" || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No explicit expiry stored; treat as fresh for an hour from last_refresh or now.
|
||||
const lastRefreshRaw = parsed.last_refresh;
|
||||
@@ -167,15 +187,23 @@ function readCodexKeychainCredentials(options?: {
|
||||
function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredential | null {
|
||||
const credPath = resolveQwenCliCredentialsPath(options?.homeDir);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = raw as Record<string, unknown>;
|
||||
const accessToken = data.access_token;
|
||||
const refreshToken = data.refresh_token;
|
||||
const expiresAt = data.expiry_date;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof refreshToken !== "string" || !refreshToken) return null;
|
||||
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof refreshToken !== "string" || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
@@ -186,6 +214,36 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
|
||||
};
|
||||
}
|
||||
|
||||
function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCredential | null {
|
||||
const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const data = raw as Record<string, unknown>;
|
||||
const accessToken = data.access_token;
|
||||
const refreshToken = data.refresh_token;
|
||||
const expiresAt = data.expiry_date;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof refreshToken !== "string" || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
provider: "minimax-portal",
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readClaudeCliKeychainCredentials(
|
||||
execSyncImpl: ExecSyncFn = execSync,
|
||||
): ClaudeCliCredential | null {
|
||||
@@ -197,14 +255,20 @@ function readClaudeCliKeychainCredentials(
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
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 accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
@@ -246,18 +310,26 @@ export function readClaudeCliCredentials(options?: {
|
||||
|
||||
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
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 accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
@@ -362,11 +434,15 @@ export function writeClaudeCliFileCredentials(
|
||||
|
||||
try {
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const existingOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!existingOauth || typeof existingOauth !== "object") return false;
|
||||
if (!existingOauth || typeof existingOauth !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
data.claudeAiOauth = {
|
||||
...existingOauth,
|
||||
@@ -416,21 +492,31 @@ export function readCodexCliCredentials(options?: {
|
||||
platform: options?.platform,
|
||||
execSync: options?.execSync,
|
||||
});
|
||||
if (keychain) return keychain;
|
||||
if (keychain) {
|
||||
return keychain;
|
||||
}
|
||||
|
||||
const authPath = resolveCodexCliAuthPath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const tokens = data.tokens as Record<string, unknown> | undefined;
|
||||
if (!tokens || typeof tokens !== "object") return null;
|
||||
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;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return null;
|
||||
}
|
||||
if (typeof refreshToken !== "string" || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let expires: number;
|
||||
try {
|
||||
@@ -497,3 +583,25 @@ export function readQwenCliCredentialsCached(options?: {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readMiniMaxCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
homeDir?: string;
|
||||
}): MiniMaxCliCredential | null {
|
||||
const ttlMs = options?.ttlMs ?? 0;
|
||||
const now = Date.now();
|
||||
const cacheKey = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
||||
if (
|
||||
ttlMs > 0 &&
|
||||
minimaxCliCache &&
|
||||
minimaxCliCache.cacheKey === cacheKey &&
|
||||
now - minimaxCliCache.readAt < ttlMs
|
||||
) {
|
||||
return minimaxCliCache.value;
|
||||
}
|
||||
const value = readMiniMaxCliCredentials({ homeDir: options?.homeDir });
|
||||
if (ttlMs > 0) {
|
||||
minimaxCliCache = { value, readAt: now, cacheKey };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveMoltbotDocsPath } from "./docs-path.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
resolveSystemPromptUsage,
|
||||
writeCliImages,
|
||||
} from "./cli-runner/helpers.js";
|
||||
import { resolveOpenClawDocsPath } from "./docs-path.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
|
||||
const log = createSubsystemLogger("agent/claude-cli");
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function runCliAgent(params: {
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
prompt: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
@@ -86,7 +86,7 @@ export async function runCliAgent(params: {
|
||||
sessionAgentId === defaultAgentId
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
: undefined;
|
||||
const docsPath = await resolveMoltbotDocsPath({
|
||||
const docsPath = await resolveOpenClawDocsPath({
|
||||
workspaceDir,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
@@ -167,7 +167,7 @@ export async function runCliAgent(params: {
|
||||
log.info(
|
||||
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
|
||||
);
|
||||
const logOutputText = isTruthyEnvValue(process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT);
|
||||
const logOutputText = isTruthyEnvValue(process.env.OPENCLAW_CLAUDE_CLI_LOG_OUTPUT);
|
||||
if (logOutputText) {
|
||||
const logArgs: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
@@ -228,12 +228,20 @@ export async function runCliAgent(params: {
|
||||
const stdout = result.stdout.trim();
|
||||
const stderr = result.stderr.trim();
|
||||
if (logOutputText) {
|
||||
if (stdout) log.info(`cli stdout:\n${stdout}`);
|
||||
if (stderr) log.info(`cli stderr:\n${stderr}`);
|
||||
if (stdout) {
|
||||
log.info(`cli stdout:\n${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
log.info(`cli stderr:\n${stderr}`);
|
||||
}
|
||||
}
|
||||
if (shouldLogVerbose()) {
|
||||
if (stdout) log.debug(`cli stdout:\n${stdout}`);
|
||||
if (stderr) log.debug(`cli stderr:\n${stderr}`);
|
||||
if (stdout) {
|
||||
log.debug(`cli stdout:\n${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
log.debug(`cli stderr:\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
@@ -278,7 +286,9 @@ export async function runCliAgent(params: {
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof FailoverError) throw err;
|
||||
if (err instanceof FailoverError) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isFailoverErrorMessage(message)) {
|
||||
const reason = classifyFailoverReason(message) ?? "unknown";
|
||||
@@ -303,7 +313,7 @@ export async function runClaudeCliAgent(params: {
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { MoltbotConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -25,19 +24,29 @@ export async function cleanupResumeProcesses(
|
||||
backend: CliBackendConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
if (resumeArgs.length === 0) return;
|
||||
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) return;
|
||||
if (resumeArgs.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) {
|
||||
return;
|
||||
}
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) return;
|
||||
if (!commandToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeTokens = resumeArgs.map((arg) => arg.replaceAll("{sessionId}", sessionId));
|
||||
const pattern = [commandToken, ...resumeTokens]
|
||||
.filter(Boolean)
|
||||
.map((token) => escapeRegex(token))
|
||||
.join(".*");
|
||||
if (!pattern) return;
|
||||
if (!pattern) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runExec("pkill", ["-f", pattern]);
|
||||
@@ -48,14 +57,18 @@ export async function cleanupResumeProcesses(
|
||||
|
||||
function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) return [];
|
||||
if (!commandToken) {
|
||||
return [];
|
||||
}
|
||||
const matchers: RegExp[] = [];
|
||||
const sessionArg = backend.sessionArg?.trim();
|
||||
const sessionArgs = backend.sessionArgs ?? [];
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
|
||||
const addMatcher = (args: string[]) => {
|
||||
if (args.length === 0) return;
|
||||
if (args.length === 0) {
|
||||
return;
|
||||
}
|
||||
const tokens = [commandToken, ...args];
|
||||
const pattern = tokens
|
||||
.map((token, index) => {
|
||||
@@ -80,37 +93,53 @@ function buildSessionMatchers(backend: CliBackendConfig): RegExp[] {
|
||||
}
|
||||
|
||||
function tokenToRegex(token: string): string {
|
||||
if (!token.includes("{sessionId}")) return escapeRegex(token);
|
||||
if (!token.includes("{sessionId}")) {
|
||||
return escapeRegex(token);
|
||||
}
|
||||
const parts = token.split("{sessionId}").map((part) => escapeRegex(part));
|
||||
return parts.join("\\S+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup suspended Moltbot CLI processes that have accumulated.
|
||||
* Cleanup suspended OpenClaw CLI processes that have accumulated.
|
||||
* Only cleans up if there are more than the threshold (default: 10).
|
||||
*/
|
||||
export async function cleanupSuspendedCliProcesses(
|
||||
backend: CliBackendConfig,
|
||||
threshold = 10,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const matchers = buildSessionMatchers(backend);
|
||||
if (matchers.length === 0) return;
|
||||
if (matchers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await runExec("ps", ["-ax", "-o", "pid=,stat=,command="]);
|
||||
const suspended: number[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const match = /^(\d+)\s+(\S+)\s+(.*)$/.exec(trimmed);
|
||||
if (!match) continue;
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const pid = Number(match[1]);
|
||||
const stat = match[2] ?? "";
|
||||
const command = match[3] ?? "";
|
||||
if (!Number.isFinite(pid)) continue;
|
||||
if (!stat.includes("T")) continue;
|
||||
if (!matchers.some((matcher) => matcher.test(command))) continue;
|
||||
if (!Number.isFinite(pid)) {
|
||||
continue;
|
||||
}
|
||||
if (!stat.includes("T")) {
|
||||
continue;
|
||||
}
|
||||
if (!matchers.some((matcher) => matcher.test(command))) {
|
||||
continue;
|
||||
}
|
||||
suspended.push(pid);
|
||||
}
|
||||
|
||||
@@ -148,24 +177,28 @@ export type CliOutput = {
|
||||
usage?: CliUsage;
|
||||
};
|
||||
|
||||
function buildModelAliasLines(cfg?: MoltbotConfig) {
|
||||
function buildModelAliasLines(cfg?: OpenClawConfig) {
|
||||
const models = cfg?.agents?.defaults?.models ?? {};
|
||||
const entries: Array<{ alias: string; model: string }> = [];
|
||||
for (const [keyRaw, entryRaw] of Object.entries(models)) {
|
||||
const model = String(keyRaw ?? "").trim();
|
||||
if (!model) continue;
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
|
||||
if (!alias) continue;
|
||||
if (!alias) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ alias, model });
|
||||
}
|
||||
return entries
|
||||
.sort((a, b) => a.alias.localeCompare(b.alias))
|
||||
.toSorted((a, b) => a.alias.localeCompare(b.alias))
|
||||
.map((entry) => `- ${entry.alias}: ${entry.model}`);
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(params: {
|
||||
workspaceDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
defaultThinkLevel?: ThinkLevel;
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
@@ -187,7 +220,7 @@ export function buildSystemPrompt(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
cwd: process.cwd(),
|
||||
runtime: {
|
||||
host: "moltbot",
|
||||
host: "openclaw",
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
@@ -217,25 +250,33 @@ export function buildSystemPrompt(params: {
|
||||
|
||||
export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const direct = backend.modelAliases?.[trimmed];
|
||||
if (direct) return direct;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
const mapped = backend.modelAliases?.[lower];
|
||||
if (mapped) return mapped;
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function toUsage(raw: Record<string, unknown>): CliUsage | undefined {
|
||||
const pick = (key: string) =>
|
||||
typeof raw[key] === "number" && raw[key] > 0 ? (raw[key] as number) : undefined;
|
||||
typeof raw[key] === "number" && raw[key] > 0 ? raw[key] : undefined;
|
||||
const input = pick("input_tokens") ?? pick("inputTokens");
|
||||
const output = pick("output_tokens") ?? pick("outputTokens");
|
||||
const cacheRead =
|
||||
pick("cache_read_input_tokens") ?? pick("cached_input_tokens") ?? pick("cacheRead");
|
||||
const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite");
|
||||
const total = pick("total_tokens") ?? pick("total");
|
||||
if (!input && !output && !cacheRead && !cacheWrite && !total) return undefined;
|
||||
if (!input && !output && !cacheRead && !cacheWrite && !total) {
|
||||
return undefined;
|
||||
}
|
||||
return { input, output, cacheRead, cacheWrite, total };
|
||||
}
|
||||
|
||||
@@ -244,15 +285,30 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function collectText(value: unknown): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map((entry) => collectText(entry)).join("");
|
||||
if (!isRecord(value)) return "";
|
||||
if (typeof value.text === "string") return value.text;
|
||||
if (typeof value.content === "string") return value.content;
|
||||
if (Array.isArray(value.content))
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => collectText(entry)).join("");
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value.text === "string") {
|
||||
return value.text;
|
||||
}
|
||||
if (typeof value.content === "string") {
|
||||
return value.content;
|
||||
}
|
||||
if (Array.isArray(value.content)) {
|
||||
return value.content.map((entry) => collectText(entry)).join("");
|
||||
if (isRecord(value.message)) return collectText(value.message);
|
||||
}
|
||||
if (isRecord(value.message)) {
|
||||
return collectText(value.message);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -268,21 +324,27 @@ function pickSessionId(
|
||||
];
|
||||
for (const field of fields) {
|
||||
const value = parsed[field];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(parsed)) return null;
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const sessionId = pickSessionId(parsed, backend);
|
||||
const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined;
|
||||
const text =
|
||||
@@ -298,7 +360,9 @@ export function parseCliJsonl(raw: string, backend: CliBackendConfig): CliOutput
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return null;
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let sessionId: string | undefined;
|
||||
let usage: CliUsage | undefined;
|
||||
const texts: string[] = [];
|
||||
@@ -309,8 +373,12 @@ export function parseCliJsonl(raw: string, backend: CliBackendConfig): CliOutput
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(parsed)) continue;
|
||||
if (!sessionId) sessionId = pickSessionId(parsed, backend);
|
||||
if (!isRecord(parsed)) {
|
||||
continue;
|
||||
}
|
||||
if (!sessionId) {
|
||||
sessionId = pickSessionId(parsed, backend);
|
||||
}
|
||||
if (!sessionId && typeof parsed.thread_id === "string") {
|
||||
sessionId = parsed.thread_id.trim();
|
||||
}
|
||||
@@ -326,7 +394,9 @@ export function parseCliJsonl(raw: string, backend: CliBackendConfig): CliOutput
|
||||
}
|
||||
}
|
||||
const text = texts.join("\n").trim();
|
||||
if (!text) return null;
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return { text, sessionId, usage };
|
||||
}
|
||||
|
||||
@@ -336,11 +406,19 @@ export function resolveSystemPromptUsage(params: {
|
||||
systemPrompt?: string;
|
||||
}): string | null {
|
||||
const systemPrompt = params.systemPrompt?.trim();
|
||||
if (!systemPrompt) return null;
|
||||
if (!systemPrompt) {
|
||||
return null;
|
||||
}
|
||||
const when = params.backend.systemPromptWhen ?? "first";
|
||||
if (when === "never") return null;
|
||||
if (when === "first" && !params.isNewSession) return null;
|
||||
if (!params.backend.systemPromptArg?.trim()) return null;
|
||||
if (when === "never") {
|
||||
return null;
|
||||
}
|
||||
if (when === "first" && !params.isNewSession) {
|
||||
return null;
|
||||
}
|
||||
if (!params.backend.systemPromptArg?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return systemPrompt;
|
||||
}
|
||||
|
||||
@@ -350,9 +428,15 @@ export function resolveSessionIdToSend(params: {
|
||||
}): { sessionId?: string; isNew: boolean } {
|
||||
const mode = params.backend.sessionMode ?? "always";
|
||||
const existing = params.cliSessionId?.trim();
|
||||
if (mode === "none") return { sessionId: undefined, isNew: !existing };
|
||||
if (mode === "existing") return { sessionId: existing, isNew: !existing };
|
||||
if (existing) return { sessionId: existing, isNew: false };
|
||||
if (mode === "none") {
|
||||
return { sessionId: undefined, isNew: !existing };
|
||||
}
|
||||
if (mode === "existing") {
|
||||
return { sessionId: existing, isNew: !existing };
|
||||
}
|
||||
if (existing) {
|
||||
return { sessionId: existing, isNew: false };
|
||||
}
|
||||
return { sessionId: crypto.randomUUID(), isNew: true };
|
||||
}
|
||||
|
||||
@@ -372,15 +456,25 @@ export function resolvePromptInput(params: { backend: CliBackendConfig; prompt:
|
||||
|
||||
function resolveImageExtension(mimeType: string): string {
|
||||
const normalized = mimeType.toLowerCase();
|
||||
if (normalized.includes("png")) return "png";
|
||||
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
|
||||
if (normalized.includes("gif")) return "gif";
|
||||
if (normalized.includes("webp")) return "webp";
|
||||
if (normalized.includes("png")) {
|
||||
return "png";
|
||||
}
|
||||
if (normalized.includes("jpeg") || normalized.includes("jpg")) {
|
||||
return "jpg";
|
||||
}
|
||||
if (normalized.includes("gif")) {
|
||||
return "gif";
|
||||
}
|
||||
if (normalized.includes("webp")) {
|
||||
return "webp";
|
||||
}
|
||||
return "bin";
|
||||
}
|
||||
|
||||
export function appendImagePathsToPrompt(prompt: string, paths: string[]): string {
|
||||
if (!paths.length) return prompt;
|
||||
if (!paths.length) {
|
||||
return prompt;
|
||||
}
|
||||
const trimmed = prompt.trimEnd();
|
||||
const separator = trimmed ? "\n\n" : "";
|
||||
return `${trimmed}${separator}${paths.join("\n")}`;
|
||||
@@ -389,7 +483,7 @@ export function appendImagePathsToPrompt(prompt: string, paths: string[]): strin
|
||||
export async function writeCliImages(
|
||||
images: ImageContent[],
|
||||
): Promise<{ paths: string[]; cleanup: () => Promise<void> }> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-cli-images-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-images-"));
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < images.length; i += 1) {
|
||||
const image = images[i];
|
||||
|
||||
@@ -5,13 +5,19 @@ export function getCliSessionId(
|
||||
entry: SessionEntry | undefined,
|
||||
provider: string,
|
||||
): string | undefined {
|
||||
if (!entry) return undefined;
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const fromMap = entry.cliSessionIds?.[normalized];
|
||||
if (fromMap?.trim()) return fromMap.trim();
|
||||
if (fromMap?.trim()) {
|
||||
return fromMap.trim();
|
||||
}
|
||||
if (normalized === "claude-cli") {
|
||||
const legacy = entry.claudeCliSessionId?.trim();
|
||||
if (legacy) return legacy;
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -19,7 +25,9 @@ export function getCliSessionId(
|
||||
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) return;
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const existing = entry.cliSessionIds ?? {};
|
||||
entry.cliSessionIds = { ...existing };
|
||||
entry.cliSessionIds[normalized] = trimmed;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
estimateMessagesTokens,
|
||||
pruneHistoryForContextShare,
|
||||
@@ -128,8 +127,8 @@ describe("pruneHistoryForContextShare", () => {
|
||||
const allIds = [
|
||||
...pruned.droppedMessagesList.map((m) => m.timestamp),
|
||||
...pruned.messages.map((m) => m.timestamp),
|
||||
].sort((a, b) => a - b);
|
||||
const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
|
||||
].toSorted((a, b) => a - b);
|
||||
const originalIds = messages.map((m) => m.timestamp).toSorted((a, b) => a - b);
|
||||
expect(allIds).toEqual(originalIds);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
|
||||
|
||||
export const BASE_CHUNK_RATIO = 0.4;
|
||||
@@ -18,7 +17,9 @@ export function estimateMessagesTokens(messages: AgentMessage[]): number {
|
||||
}
|
||||
|
||||
function normalizeParts(parts: number, messageCount: number): number {
|
||||
if (!Number.isFinite(parts) || parts <= 1) return 1;
|
||||
if (!Number.isFinite(parts) || parts <= 1) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
|
||||
}
|
||||
|
||||
@@ -26,9 +27,13 @@ export function splitMessagesByTokenShare(
|
||||
messages: AgentMessage[],
|
||||
parts = DEFAULT_PARTS,
|
||||
): AgentMessage[][] {
|
||||
if (messages.length === 0) return [];
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const normalizedParts = normalizeParts(parts, messages.length);
|
||||
if (normalizedParts <= 1) return [messages];
|
||||
if (normalizedParts <= 1) {
|
||||
return [messages];
|
||||
}
|
||||
|
||||
const totalTokens = estimateMessagesTokens(messages);
|
||||
const targetTokens = totalTokens / normalizedParts;
|
||||
@@ -63,7 +68,9 @@ export function chunkMessagesByMaxTokens(
|
||||
messages: AgentMessage[],
|
||||
maxTokens: number,
|
||||
): AgentMessage[][] {
|
||||
if (messages.length === 0) return [];
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: AgentMessage[][] = [];
|
||||
let currentChunk: AgentMessage[] = [];
|
||||
@@ -100,7 +107,9 @@ export function chunkMessagesByMaxTokens(
|
||||
* When messages are large, we use smaller chunks to avoid exceeding model limits.
|
||||
*/
|
||||
export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
|
||||
if (messages.length === 0) return BASE_CHUNK_RATIO;
|
||||
if (messages.length === 0) {
|
||||
return BASE_CHUNK_RATIO;
|
||||
}
|
||||
|
||||
const totalTokens = estimateMessagesTokens(messages);
|
||||
const avgTokens = totalTokens / messages.length;
|
||||
@@ -320,7 +329,9 @@ export function pruneHistoryForContextShare(params: {
|
||||
|
||||
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
|
||||
const chunks = splitMessagesByTokenShare(keptMessages, parts);
|
||||
if (chunks.length <= 1) break;
|
||||
if (chunks.length <= 1) {
|
||||
break;
|
||||
}
|
||||
const [dropped, ...rest] = chunks;
|
||||
droppedChunks += 1;
|
||||
droppedMessages += dropped.length;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
|
||||
@@ -72,7 +71,7 @@ describe("context-window-guard", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies MoltbotConfig;
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const info = resolveContextWindowInfo({
|
||||
cfg,
|
||||
@@ -89,7 +88,7 @@ describe("context-window-guard", () => {
|
||||
it("falls back to agents.defaults.contextTokens", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { contextTokens: 20_000 } },
|
||||
} satisfies MoltbotConfig;
|
||||
} satisfies OpenClawConfig;
|
||||
const info = resolveContextWindowInfo({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
|
||||
export const CONTEXT_WINDOW_WARN_BELOW_TOKENS = 32_000;
|
||||
@@ -11,20 +11,24 @@ export type ContextWindowInfo = {
|
||||
};
|
||||
|
||||
function normalizePositiveInt(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const int = Math.floor(value);
|
||||
return int > 0 ? int : null;
|
||||
}
|
||||
|
||||
export function resolveContextWindowInfo(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelContextWindow?: number;
|
||||
defaultTokens: number;
|
||||
}): ContextWindowInfo {
|
||||
const fromModel = normalizePositiveInt(params.modelContextWindow);
|
||||
if (fromModel) return { tokens: fromModel, source: "model" };
|
||||
if (fromModel) {
|
||||
return { tokens: fromModel, source: "model" };
|
||||
}
|
||||
|
||||
const fromModelsConfig = (() => {
|
||||
const providers = params.cfg?.models?.providers as
|
||||
@@ -35,10 +39,14 @@ export function resolveContextWindowInfo(params: {
|
||||
const match = models.find((m) => m?.id === params.modelId);
|
||||
return normalizePositiveInt(match?.contextWindow);
|
||||
})();
|
||||
if (fromModelsConfig) return { tokens: fromModelsConfig, source: "modelsConfig" };
|
||||
if (fromModelsConfig) {
|
||||
return { tokens: fromModelsConfig, source: "modelsConfig" };
|
||||
}
|
||||
|
||||
const fromAgentConfig = normalizePositiveInt(params.cfg?.agents?.defaults?.contextTokens);
|
||||
if (fromAgentConfig) return { tokens: fromAgentConfig, source: "agentContextTokens" };
|
||||
if (fromAgentConfig) {
|
||||
return { tokens: fromAgentConfig, source: "agentContextTokens" };
|
||||
}
|
||||
|
||||
return { tokens: Math.floor(params.defaultTokens), source: "default" };
|
||||
}
|
||||
|
||||
@@ -2,23 +2,25 @@
|
||||
// the agent reports a model id. This includes custom models.json entries.
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
import { ensureMoltbotModelsJson } from "./models-config.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
|
||||
type ModelEntry = { id: string; contextWindow?: number };
|
||||
|
||||
const MODEL_CACHE = new Map<string, number>();
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const { discoverAuthStorage, discoverModels } = await import("@mariozechner/pi-coding-agent");
|
||||
const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
|
||||
const cfg = loadConfig();
|
||||
await ensureMoltbotModelsJson(cfg);
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
const models = modelRegistry.getAll() as ModelEntry[];
|
||||
for (const m of models) {
|
||||
if (!m?.id) continue;
|
||||
if (!m?.id) {
|
||||
continue;
|
||||
}
|
||||
if (typeof m.contextWindow === "number" && m.contextWindow > 0) {
|
||||
MODEL_CACHE.set(m.id, m.contextWindow);
|
||||
}
|
||||
@@ -29,7 +31,9 @@ const loadPromise = (async () => {
|
||||
})();
|
||||
|
||||
export function lookupContextTokens(modelId?: string): number | undefined {
|
||||
if (!modelId) return undefined;
|
||||
if (!modelId) {
|
||||
return undefined;
|
||||
}
|
||||
// Best-effort: kick off loading, but don't block.
|
||||
void loadPromise;
|
||||
return MODEL_CACHE.get(modelId);
|
||||
|
||||
@@ -20,8 +20,12 @@ export function resolveUserTimezone(configured?: string): string {
|
||||
}
|
||||
|
||||
export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat {
|
||||
if (preference === "12" || preference === "24") return preference;
|
||||
if (cachedTimeFormat) return cachedTimeFormat;
|
||||
if (preference === "12" || preference === "24") {
|
||||
return preference;
|
||||
}
|
||||
if (cachedTimeFormat) {
|
||||
return cachedTimeFormat;
|
||||
}
|
||||
cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12";
|
||||
return cachedTimeFormat;
|
||||
}
|
||||
@@ -29,7 +33,9 @@ export function resolveUserTimeFormat(preference?: TimeFormatPreference): Resolv
|
||||
export function normalizeTimestamp(
|
||||
raw: unknown,
|
||||
): { timestampMs: number; timestampUtc: string } | undefined {
|
||||
if (raw == null) return undefined;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
let timestampMs: number | undefined;
|
||||
|
||||
if (raw instanceof Date) {
|
||||
@@ -38,7 +44,9 @@ export function normalizeTimestamp(
|
||||
timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
|
||||
} else if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
||||
const num = Number(trimmed);
|
||||
if (Number.isFinite(num)) {
|
||||
@@ -52,11 +60,15 @@ export function normalizeTimestamp(
|
||||
}
|
||||
} else {
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) timestampMs = parsed;
|
||||
if (!Number.isNaN(parsed)) {
|
||||
timestampMs = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined;
|
||||
if (timestampMs === undefined || !Number.isFinite(timestampMs)) {
|
||||
return undefined;
|
||||
}
|
||||
return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() };
|
||||
}
|
||||
|
||||
@@ -65,7 +77,9 @@ export function withNormalizedTimestamp<T extends Record<string, unknown>>(
|
||||
rawTimestamp: unknown,
|
||||
): T & { timestampMs?: number; timestampUtc?: string } {
|
||||
const normalized = normalizeTimestamp(rawTimestamp);
|
||||
if (!normalized) return value;
|
||||
if (!normalized) {
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
timestampMs:
|
||||
@@ -86,8 +100,12 @@ function detectSystemTimeFormat(): boolean {
|
||||
encoding: "utf8",
|
||||
timeout: 500,
|
||||
}).trim();
|
||||
if (result === "1") return true;
|
||||
if (result === "0") return false;
|
||||
if (result === "1") {
|
||||
return true;
|
||||
}
|
||||
if (result === "0") {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Not set, fall through
|
||||
}
|
||||
@@ -99,8 +117,12 @@ function detectSystemTimeFormat(): boolean {
|
||||
'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"',
|
||||
{ encoding: "utf8", timeout: 1000 },
|
||||
).trim();
|
||||
if (result.startsWith("H")) return true;
|
||||
if (result.startsWith("h")) return false;
|
||||
if (result.startsWith("H")) {
|
||||
return true;
|
||||
}
|
||||
if (result.startsWith("h")) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
@@ -116,7 +138,9 @@ function detectSystemTimeFormat(): boolean {
|
||||
}
|
||||
|
||||
function ordinalSuffix(day: number): string {
|
||||
if (day >= 11 && day <= 13) return "th";
|
||||
if (day >= 11 && day <= 13) {
|
||||
return "th";
|
||||
}
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
return "st";
|
||||
@@ -148,10 +172,13 @@ export function formatUserTime(
|
||||
}).formatToParts(date);
|
||||
const map: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
if (part.type !== "literal") map[part.type] = part.value;
|
||||
if (part.type !== "literal") {
|
||||
map[part.type] = part.value;
|
||||
}
|
||||
}
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
|
||||
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) {
|
||||
return undefined;
|
||||
}
|
||||
const dayNum = parseInt(map.day, 10);
|
||||
const suffix = ordinalSuffix(dayNum);
|
||||
const timePart = use24Hour
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||
|
||||
import { resolveMoltbotPackageRoot } from "../infra/moltbot-root.js";
|
||||
|
||||
export async function resolveMoltbotDocsPath(params: {
|
||||
export async function resolveOpenClawDocsPath(params: {
|
||||
workspaceDir?: string;
|
||||
argv1?: string;
|
||||
cwd?: string;
|
||||
@@ -12,15 +11,19 @@ export async function resolveMoltbotDocsPath(params: {
|
||||
const workspaceDir = params.workspaceDir?.trim();
|
||||
if (workspaceDir) {
|
||||
const workspaceDocs = path.join(workspaceDir, "docs");
|
||||
if (fs.existsSync(workspaceDocs)) return workspaceDocs;
|
||||
if (fs.existsSync(workspaceDocs)) {
|
||||
return workspaceDocs;
|
||||
}
|
||||
}
|
||||
|
||||
const packageRoot = await resolveMoltbotPackageRoot({
|
||||
const packageRoot = await resolveOpenClawPackageRoot({
|
||||
cwd: params.cwd,
|
||||
argv1: params.argv1,
|
||||
moduleUrl: params.moduleUrl,
|
||||
});
|
||||
if (!packageRoot) return null;
|
||||
if (!packageRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageDocs = path.join(packageRoot, "docs");
|
||||
return fs.existsSync(packageDocs) ? packageDocs : null;
|
||||
|
||||
@@ -56,11 +56,15 @@ export function resolveFailoverStatus(reason: FailoverReason): number | undefine
|
||||
}
|
||||
|
||||
function getStatusCode(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") return undefined;
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate =
|
||||
(err as { status?: unknown; statusCode?: unknown }).status ??
|
||||
(err as { statusCode?: unknown }).statusCode;
|
||||
if (typeof candidate === "number") return candidate;
|
||||
if (typeof candidate === "number") {
|
||||
return candidate;
|
||||
}
|
||||
if (typeof candidate === "string" && /^\d+$/.test(candidate)) {
|
||||
return Number(candidate);
|
||||
}
|
||||
@@ -68,67 +72,107 @@ function getStatusCode(err: unknown): number | undefined {
|
||||
}
|
||||
|
||||
function getErrorName(err: unknown): string {
|
||||
if (!err || typeof err !== "object") return "";
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
}
|
||||
return "name" in err ? String(err.name) : "";
|
||||
}
|
||||
|
||||
function getErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== "object") return undefined;
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = (err as { code?: unknown }).code;
|
||||
if (typeof candidate !== "string") return undefined;
|
||||
if (typeof candidate !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") {
|
||||
return String(err);
|
||||
}
|
||||
if (typeof err === "symbol") return err.description ?? "";
|
||||
if (typeof err === "symbol") {
|
||||
return err.description ?? "";
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasTimeoutHint(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
if (getErrorName(err) === "TimeoutError") return true;
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
if (getErrorName(err) === "TimeoutError") {
|
||||
return true;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
return Boolean(message && TIMEOUT_HINT_RE.test(message));
|
||||
}
|
||||
|
||||
export function isTimeoutError(err: unknown): boolean {
|
||||
if (hasTimeoutHint(err)) return true;
|
||||
if (!err || typeof err !== "object") return false;
|
||||
if (getErrorName(err) !== "AbortError") return false;
|
||||
if (hasTimeoutHint(err)) {
|
||||
return true;
|
||||
}
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (getErrorName(err) !== "AbortError") {
|
||||
return false;
|
||||
}
|
||||
const message = getErrorMessage(err);
|
||||
if (message && ABORT_TIMEOUT_RE.test(message)) return true;
|
||||
if (message && ABORT_TIMEOUT_RE.test(message)) {
|
||||
return true;
|
||||
}
|
||||
const cause = "cause" in err ? (err as { cause?: unknown }).cause : undefined;
|
||||
const reason = "reason" in err ? (err as { reason?: unknown }).reason : undefined;
|
||||
return hasTimeoutHint(cause) || hasTimeoutHint(reason);
|
||||
}
|
||||
|
||||
export function resolveFailoverReasonFromError(err: unknown): FailoverReason | null {
|
||||
if (isFailoverError(err)) return err.reason;
|
||||
if (isFailoverError(err)) {
|
||||
return err.reason;
|
||||
}
|
||||
|
||||
const status = getStatusCode(err);
|
||||
if (status === 402) return "billing";
|
||||
if (status === 429) return "rate_limit";
|
||||
if (status === 401 || status === 403) return "auth";
|
||||
if (status === 408) return "timeout";
|
||||
if (status === 402) {
|
||||
return "billing";
|
||||
}
|
||||
if (status === 429) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return "auth";
|
||||
}
|
||||
if (status === 408) {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
const code = (getErrorCode(err) ?? "").toUpperCase();
|
||||
if (["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes(code)) {
|
||||
return "timeout";
|
||||
}
|
||||
if (isTimeoutError(err)) return "timeout";
|
||||
if (isTimeoutError(err)) {
|
||||
return "timeout";
|
||||
}
|
||||
|
||||
const message = getErrorMessage(err);
|
||||
if (!message) return null;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return classifyFailoverReason(message);
|
||||
}
|
||||
|
||||
@@ -163,9 +207,13 @@ export function coerceToFailoverError(
|
||||
profileId?: string;
|
||||
},
|
||||
): FailoverError | null {
|
||||
if (isFailoverError(err)) return err;
|
||||
if (isFailoverError(err)) {
|
||||
return err;
|
||||
}
|
||||
const reason = resolveFailoverReasonFromError(err);
|
||||
if (!reason) return null;
|
||||
if (!reason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = getErrorMessage(err) || String(err);
|
||||
const status = getStatusCode(err) ?? resolveFailoverStatus(reason);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveAgentAvatar } from "./identity-avatar.js";
|
||||
|
||||
async function writeFile(filePath: string, contents = "avatar") {
|
||||
@@ -14,12 +12,12 @@ async function writeFile(filePath: string, contents = "avatar") {
|
||||
|
||||
describe("resolveAgentAvatar", () => {
|
||||
it("resolves local avatar from config when inside workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-avatar-"));
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "main.png");
|
||||
await writeFile(avatarPath);
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
@@ -41,13 +39,13 @@ describe("resolveAgentAvatar", () => {
|
||||
});
|
||||
|
||||
it("rejects avatars outside the workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-avatar-"));
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
await writeFile(outsidePath);
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
@@ -67,7 +65,7 @@ describe("resolveAgentAvatar", () => {
|
||||
});
|
||||
|
||||
it("falls back to IDENTITY.md when config has no avatar", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-avatar-"));
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "fallback.png");
|
||||
await writeFile(avatarPath);
|
||||
@@ -78,7 +76,7 @@ describe("resolveAgentAvatar", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace }],
|
||||
},
|
||||
@@ -94,7 +92,7 @@ describe("resolveAgentAvatar", () => {
|
||||
});
|
||||
|
||||
it("accepts remote and data avatars", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", identity: { avatar: "https://example.com/avatar.png" } },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||
@@ -20,9 +19,11 @@ function normalizeAvatarValue(value: string | undefined | null): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveAvatarSource(cfg: MoltbotConfig, agentId: string): string | null {
|
||||
function resolveAvatarSource(cfg: OpenClawConfig, agentId: string): string | null {
|
||||
const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar);
|
||||
if (fromConfig) return fromConfig;
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const fromIdentity = normalizeAvatarValue(loadAgentIdentityFromWorkspace(workspace)?.avatar);
|
||||
return fromIdentity;
|
||||
@@ -47,7 +48,9 @@ function resolveExistingPath(value: string): string {
|
||||
|
||||
function isPathWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative) return true;
|
||||
if (!relative) {
|
||||
return true;
|
||||
}
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ function resolveLocalAvatarPath(params: {
|
||||
return { ok: true, filePath: realPath };
|
||||
}
|
||||
|
||||
export function resolveAgentAvatar(cfg: MoltbotConfig, agentId: string): AgentAvatarResolution {
|
||||
export function resolveAgentAvatar(cfg: OpenClawConfig, agentId: string): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId);
|
||||
if (!source) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseIdentityMarkdown } from "./identity-file.js";
|
||||
|
||||
describe("parseIdentityMarkdown", () => {
|
||||
@@ -23,7 +22,7 @@ describe("parseIdentityMarkdown", () => {
|
||||
- **Creature:** Robot
|
||||
- **Vibe:** Warm
|
||||
- **Emoji:** :robot:
|
||||
- **Avatar:** avatars/clawd.png
|
||||
- **Avatar:** avatars/openclaw.png
|
||||
`;
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
expect(parsed).toEqual({
|
||||
@@ -31,7 +30,7 @@ describe("parseIdentityMarkdown", () => {
|
||||
creature: "Robot",
|
||||
vibe: "Warm",
|
||||
emoji: ":robot:",
|
||||
avatar: "avatars/clawd.png",
|
||||
avatar: "avatars/openclaw.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
|
||||
|
||||
export type AgentIdentityFile = {
|
||||
@@ -42,20 +41,38 @@ export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
for (const line of lines) {
|
||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||
const colonIndex = cleaned.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||
const value = cleaned
|
||||
.slice(colonIndex + 1)
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (isIdentityPlaceholder(value)) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
if (label === "avatar") identity.avatar = value;
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (isIdentityPlaceholder(value)) {
|
||||
continue;
|
||||
}
|
||||
if (label === "name") {
|
||||
identity.name = value;
|
||||
}
|
||||
if (label === "emoji") {
|
||||
identity.emoji = value;
|
||||
}
|
||||
if (label === "creature") {
|
||||
identity.creature = value;
|
||||
}
|
||||
if (label === "vibe") {
|
||||
identity.vibe = value;
|
||||
}
|
||||
if (label === "theme") {
|
||||
identity.theme = value;
|
||||
}
|
||||
if (label === "avatar") {
|
||||
identity.avatar = value;
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -75,7 +92,9 @@ export function loadIdentityFromFile(identityPath: string): AgentIdentityFile |
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!identityHasValues(parsed)) return null;
|
||||
if (!identityHasValues(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveHumanDelayConfig } from "./identity.js";
|
||||
|
||||
describe("resolveHumanDelayConfig", () => {
|
||||
it("returns undefined when no humanDelay config is set", () => {
|
||||
const cfg: MoltbotConfig = {};
|
||||
const cfg: OpenClawConfig = {};
|
||||
expect(resolveHumanDelayConfig(cfg, "main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges defaults with per-agent overrides", () => {
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
humanDelay: { mode: "natural", minMs: 800, maxMs: 1800 },
|
||||
|
||||
@@ -1,48 +1,59 @@
|
||||
import type { MoltbotConfig, HumanDelayConfig, IdentityConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig, HumanDelayConfig, IdentityConfig } from "../config/config.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
const DEFAULT_ACK_REACTION = "👀";
|
||||
|
||||
export function resolveAgentIdentity(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): IdentityConfig | undefined {
|
||||
return resolveAgentConfig(cfg, agentId)?.identity;
|
||||
}
|
||||
|
||||
export function resolveAckReaction(cfg: MoltbotConfig, agentId: string): string {
|
||||
export function resolveAckReaction(cfg: OpenClawConfig, agentId: string): string {
|
||||
const configured = cfg.messages?.ackReaction;
|
||||
if (configured !== undefined) return configured.trim();
|
||||
if (configured !== undefined) {
|
||||
return configured.trim();
|
||||
}
|
||||
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
|
||||
return emoji || DEFAULT_ACK_REACTION;
|
||||
}
|
||||
|
||||
export function resolveIdentityNamePrefix(cfg: MoltbotConfig, agentId: string): string | undefined {
|
||||
export function resolveIdentityNamePrefix(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): string | undefined {
|
||||
const name = resolveAgentIdentity(cfg, agentId)?.name?.trim();
|
||||
if (!name) return undefined;
|
||||
if (!name) {
|
||||
return undefined;
|
||||
}
|
||||
return `[${name}]`;
|
||||
}
|
||||
|
||||
/** Returns just the identity name (without brackets) for template context. */
|
||||
export function resolveIdentityName(cfg: MoltbotConfig, agentId: string): string | undefined {
|
||||
export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
return resolveAgentIdentity(cfg, agentId)?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveMessagePrefix(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { configured?: string; hasAllowFrom?: boolean; fallback?: string },
|
||||
): string {
|
||||
const configured = opts?.configured ?? cfg.messages?.messagePrefix;
|
||||
if (configured !== undefined) return configured;
|
||||
if (configured !== undefined) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
const hasAllowFrom = opts?.hasAllowFrom === true;
|
||||
if (hasAllowFrom) return "";
|
||||
if (hasAllowFrom) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[moltbot]";
|
||||
return resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[openclaw]";
|
||||
}
|
||||
|
||||
export function resolveResponsePrefix(cfg: MoltbotConfig, agentId: string): string | undefined {
|
||||
export function resolveResponsePrefix(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
const configured = cfg.messages?.responsePrefix;
|
||||
if (configured !== undefined) {
|
||||
if (configured === "auto") {
|
||||
@@ -54,7 +65,7 @@ export function resolveResponsePrefix(cfg: MoltbotConfig, agentId: string): stri
|
||||
}
|
||||
|
||||
export function resolveEffectiveMessagesConfig(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string },
|
||||
): { messagePrefix: string; responsePrefix?: string } {
|
||||
@@ -68,12 +79,14 @@ export function resolveEffectiveMessagesConfig(
|
||||
}
|
||||
|
||||
export function resolveHumanDelayConfig(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): HumanDelayConfig | undefined {
|
||||
const defaults = cfg.agents?.defaults?.humanDelay;
|
||||
const overrides = resolveAgentConfig(cfg, agentId)?.humanDelay;
|
||||
if (!defaults && !overrides) return undefined;
|
||||
if (!defaults && !overrides) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
mode: overrides?.mode ?? defaults?.mode,
|
||||
minMs: overrides?.minMs ?? defaults?.minMs,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const KEY_SPLIT_RE = /[\s,;]+/g;
|
||||
|
||||
function parseKeyList(raw?: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(KEY_SPLIT_RE)
|
||||
.map((value) => value.trim())
|
||||
@@ -11,51 +13,85 @@ function parseKeyList(raw?: string | null): string[] {
|
||||
function collectEnvPrefixedKeys(prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
if (!name.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
keys.push(trimmed);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function collectAnthropicApiKeys(): string[] {
|
||||
const forcedSingle = process.env.CLAWDBOT_LIVE_ANTHROPIC_KEY?.trim();
|
||||
if (forcedSingle) return [forcedSingle];
|
||||
const forcedSingle = process.env.OPENCLAW_LIVE_ANTHROPIC_KEY?.trim();
|
||||
if (forcedSingle) {
|
||||
return [forcedSingle];
|
||||
}
|
||||
|
||||
const fromList = parseKeyList(process.env.CLAWDBOT_LIVE_ANTHROPIC_KEYS);
|
||||
const fromList = parseKeyList(process.env.OPENCLAW_LIVE_ANTHROPIC_KEYS);
|
||||
const fromEnv = collectEnvPrefixedKeys("ANTHROPIC_API_KEY");
|
||||
const primary = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
|
||||
const seen = new Set<string>();
|
||||
const add = (value?: string) => {
|
||||
if (!value) return;
|
||||
if (seen.has(value)) return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
};
|
||||
|
||||
for (const value of fromList) add(value);
|
||||
if (primary) add(primary);
|
||||
for (const value of fromEnv) add(value);
|
||||
for (const value of fromList) {
|
||||
add(value);
|
||||
}
|
||||
if (primary) {
|
||||
add(primary);
|
||||
}
|
||||
for (const value of fromEnv) {
|
||||
add(value);
|
||||
}
|
||||
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export function isAnthropicRateLimitError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("rate_limit")) return true;
|
||||
if (lower.includes("rate limit")) return true;
|
||||
if (lower.includes("429")) return true;
|
||||
if (lower.includes("rate_limit")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("rate limit")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("429")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAnthropicBillingError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("credit balance")) return true;
|
||||
if (lower.includes("insufficient credit")) return true;
|
||||
if (lower.includes("insufficient credits")) return true;
|
||||
if (lower.includes("payment required")) return true;
|
||||
if (lower.includes("billing") && lower.includes("disabled")) return true;
|
||||
if (lower.includes("402")) return true;
|
||||
if (lower.includes("credit balance")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("insufficient credit")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("insufficient credits")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("payment required")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("billing") && lower.includes("disabled")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.includes("402")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ function matchesAny(id: string, values: string[]): boolean {
|
||||
export function isModernModelRef(ref: ModelRef): boolean {
|
||||
const provider = ref.provider?.trim().toLowerCase() ?? "";
|
||||
const id = ref.id?.trim().toLowerCase() ?? "";
|
||||
if (!provider || !id) return false;
|
||||
if (!provider || !id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return matchesPrefix(id, ANTHROPIC_PREFIXES);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMemorySearchConfig } from "./memory-search.js";
|
||||
|
||||
describe("memory search config", () => {
|
||||
@@ -82,6 +81,29 @@ describe("memory search config", () => {
|
||||
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
|
||||
});
|
||||
|
||||
it("merges extra memory paths from defaults and overrides", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/notes", " docs "],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/notes", "../team-notes"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]);
|
||||
});
|
||||
|
||||
it("includes batch defaults for openai without remote overrides", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { MoltbotConfig, MemorySearchConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig, MemorySearchConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { clampInt, clampNumber, resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
@@ -9,6 +8,7 @@ import { resolveAgentConfig } from "./agent-scope.js";
|
||||
export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
sources: Array<"memory" | "sessions">;
|
||||
extraPaths: string[];
|
||||
provider: "openai" | "local" | "gemini" | "auto";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
@@ -93,17 +93,25 @@ function normalizeSources(
|
||||
const normalized = new Set<"memory" | "sessions">();
|
||||
const input = sources?.length ? sources : DEFAULT_SOURCES;
|
||||
for (const source of input) {
|
||||
if (source === "memory") normalized.add("memory");
|
||||
if (source === "sessions" && sessionMemoryEnabled) normalized.add("sessions");
|
||||
if (source === "memory") {
|
||||
normalized.add("memory");
|
||||
}
|
||||
if (source === "sessions" && sessionMemoryEnabled) {
|
||||
normalized.add("sessions");
|
||||
}
|
||||
}
|
||||
if (normalized.size === 0) {
|
||||
normalized.add("memory");
|
||||
}
|
||||
if (normalized.size === 0) normalized.add("memory");
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function resolveStorePath(agentId: string, raw?: string): string {
|
||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
const fallback = path.join(stateDir, "memory", `${agentId}.sqlite`);
|
||||
if (!raw) return fallback;
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const withToken = raw.includes("{agentId}") ? raw.replaceAll("{agentId}", agentId) : raw;
|
||||
return resolveUserPath(withToken);
|
||||
}
|
||||
@@ -162,6 +170,10 @@ function mergeConfig(
|
||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||
};
|
||||
const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory);
|
||||
const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])]
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const extraPaths = Array.from(new Set(rawPaths));
|
||||
const vector = {
|
||||
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
|
||||
extensionPath:
|
||||
@@ -236,6 +248,7 @@ function mergeConfig(
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
extraPaths,
|
||||
provider,
|
||||
remote,
|
||||
experimental: {
|
||||
@@ -274,12 +287,14 @@ function mergeConfig(
|
||||
}
|
||||
|
||||
export function resolveMemorySearchConfig(
|
||||
cfg: MoltbotConfig,
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): ResolvedMemorySearchConfig | null {
|
||||
const defaults = cfg.agents?.defaults?.memorySearch;
|
||||
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
|
||||
const resolved = mergeConfig(defaults, overrides, agentId);
|
||||
if (!resolved.enabled) return null;
|
||||
if (!resolved.enabled) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -45,11 +45,17 @@ export async function minimaxUnderstandImage(params: {
|
||||
modelBaseUrl?: string;
|
||||
}): Promise<string> {
|
||||
const apiKey = params.apiKey.trim();
|
||||
if (!apiKey) throw new Error("MiniMax VLM: apiKey required");
|
||||
if (!apiKey) {
|
||||
throw new Error("MiniMax VLM: apiKey required");
|
||||
}
|
||||
const prompt = params.prompt.trim();
|
||||
if (!prompt) throw new Error("MiniMax VLM: prompt required");
|
||||
if (!prompt) {
|
||||
throw new Error("MiniMax VLM: prompt required");
|
||||
}
|
||||
const imageDataUrl = params.imageDataUrl.trim();
|
||||
if (!imageDataUrl) throw new Error("MiniMax VLM: imageDataUrl required");
|
||||
if (!imageDataUrl) {
|
||||
throw new Error("MiniMax VLM: imageDataUrl required");
|
||||
}
|
||||
if (!/^data:image\/(png|jpeg|webp);base64,/i.test(imageDataUrl)) {
|
||||
throw new Error("MiniMax VLM: imageDataUrl must be a base64 data:image/(png|jpeg|webp) URL");
|
||||
}
|
||||
@@ -65,7 +71,7 @@ export async function minimaxUnderstandImage(params: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"MM-API-Source": "Moltbot",
|
||||
"MM-API-Source": "OpenClaw",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const oauthFixture = {
|
||||
@@ -13,15 +13,15 @@ const oauthFixture = {
|
||||
|
||||
describe("getApiKeyForModel", () => {
|
||||
it("migrates legacy oauth.json into auth-profiles.json", async () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-oauth-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
|
||||
|
||||
try {
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
|
||||
|
||||
const oauthDir = path.join(tempDir, "credentials");
|
||||
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
|
||||
@@ -41,7 +41,7 @@ describe("getApiKeyForModel", () => {
|
||||
api: "openai-codex-responses",
|
||||
} as Model<Api>;
|
||||
|
||||
const store = ensureAuthProfileStore(process.env.CLAWDBOT_AGENT_DIR, {
|
||||
const store = ensureAuthProfileStore(process.env.OPENCLAW_AGENT_DIR, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const apiKey = await getApiKeyForModel({
|
||||
@@ -57,7 +57,7 @@ describe("getApiKeyForModel", () => {
|
||||
},
|
||||
},
|
||||
store,
|
||||
agentDir: process.env.CLAWDBOT_AGENT_DIR,
|
||||
agentDir: process.env.OPENCLAW_AGENT_DIR,
|
||||
});
|
||||
expect(apiKey.apiKey).toBe(oauthFixture.access);
|
||||
|
||||
@@ -76,14 +76,14 @@ describe("getApiKeyForModel", () => {
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
@@ -95,17 +95,17 @@ describe("getApiKeyForModel", () => {
|
||||
});
|
||||
|
||||
it("suggests openai-codex when only Codex OAuth is configured", async () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
|
||||
try {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
|
||||
|
||||
const authProfilesPath = path.join(tempDir, "agent", "auth-profiles.json");
|
||||
await fs.mkdir(path.dirname(authProfilesPath), {
|
||||
@@ -148,14 +148,14 @@ describe("getApiKeyForModel", () => {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
@@ -23,29 +22,29 @@ const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY";
|
||||
const AWS_PROFILE_ENV = "AWS_PROFILE";
|
||||
|
||||
function resolveProviderConfig(
|
||||
cfg: MoltbotConfig | undefined,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
): ModelProviderConfig | undefined {
|
||||
const providers = cfg?.models?.providers ?? {};
|
||||
const direct = providers[provider] as ModelProviderConfig | undefined;
|
||||
if (direct) return direct;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = normalizeProviderId(provider);
|
||||
if (normalized === provider) {
|
||||
const matched = Object.entries(providers).find(
|
||||
([key]) => normalizeProviderId(key) === normalized,
|
||||
);
|
||||
return matched?.[1] as ModelProviderConfig | undefined;
|
||||
return matched?.[1];
|
||||
}
|
||||
return (
|
||||
(providers[normalized] as ModelProviderConfig | undefined) ??
|
||||
(Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1] as
|
||||
| ModelProviderConfig
|
||||
| undefined)
|
||||
Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1]
|
||||
);
|
||||
}
|
||||
|
||||
export function getCustomProviderApiKey(
|
||||
cfg: MoltbotConfig | undefined,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
): string | undefined {
|
||||
const entry = resolveProviderConfig(cfg, provider);
|
||||
@@ -54,7 +53,7 @@ export function getCustomProviderApiKey(
|
||||
}
|
||||
|
||||
function resolveProviderAuthOverride(
|
||||
cfg: MoltbotConfig | undefined,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
): ModelProviderAuthMode | undefined {
|
||||
const entry = resolveProviderConfig(cfg, provider);
|
||||
@@ -76,11 +75,15 @@ function resolveEnvSourceLabel(params: {
|
||||
}
|
||||
|
||||
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
if (env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
|
||||
if (env[AWS_BEARER_ENV]?.trim()) {
|
||||
return AWS_BEARER_ENV;
|
||||
}
|
||||
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
|
||||
return AWS_ACCESS_KEY_ENV;
|
||||
}
|
||||
if (env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
|
||||
if (env[AWS_PROFILE_ENV]?.trim()) {
|
||||
return AWS_PROFILE_ENV;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -128,7 +131,7 @@ export type ResolvedProviderAuth = {
|
||||
|
||||
export async function resolveApiKeyForProvider(params: {
|
||||
provider: string;
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
@@ -221,7 +224,7 @@ export async function resolveApiKeyForProvider(params: {
|
||||
[
|
||||
`No API key found for provider "${provider}".`,
|
||||
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
|
||||
`Configure auth for this agent (${formatCliCommand("moltbot agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
|
||||
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
@@ -234,7 +237,9 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = process.env[envVar]?.trim();
|
||||
if (!value) return null;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
|
||||
return { apiKey: value, source };
|
||||
};
|
||||
@@ -257,7 +262,9 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
|
||||
if (normalized === "google-vertex") {
|
||||
const envKey = getEnvApiKey(normalized);
|
||||
if (!envKey) return null;
|
||||
if (!envKey) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey: envKey, source: "gcloud adc" };
|
||||
}
|
||||
|
||||
@@ -269,6 +276,14 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "minimax-portal") {
|
||||
return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "kimi-coding") {
|
||||
return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY");
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
@@ -279,28 +294,34 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||
moonshot: "MOONSHOT_API_KEY",
|
||||
"kimi-code": "KIMICODE_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
xiaomi: "XIAOMI_API_KEY",
|
||||
synthetic: "SYNTHETIC_API_KEY",
|
||||
venice: "VENICE_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
};
|
||||
const envVar = envMap[normalized];
|
||||
if (!envVar) return null;
|
||||
if (!envVar) {
|
||||
return null;
|
||||
}
|
||||
return pick(envVar);
|
||||
}
|
||||
|
||||
export function resolveModelAuthMode(
|
||||
provider?: string,
|
||||
cfg?: MoltbotConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
store?: AuthProfileStore,
|
||||
): ModelAuthMode | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authOverride = resolveProviderAuthOverride(cfg, resolved);
|
||||
if (authOverride === "aws-sdk") return "aws-sdk";
|
||||
if (authOverride === "aws-sdk") {
|
||||
return "aws-sdk";
|
||||
}
|
||||
|
||||
const authStore = store ?? ensureAuthProfileStore();
|
||||
const profiles = listProfilesForProvider(authStore, resolved);
|
||||
@@ -313,10 +334,18 @@ export function resolveModelAuthMode(
|
||||
const distinct = ["oauth", "token", "api_key"].filter((k) =>
|
||||
modes.has(k as "oauth" | "token" | "api_key"),
|
||||
);
|
||||
if (distinct.length >= 2) return "mixed";
|
||||
if (modes.has("oauth")) return "oauth";
|
||||
if (modes.has("token")) return "token";
|
||||
if (modes.has("api_key")) return "api-key";
|
||||
if (distinct.length >= 2) {
|
||||
return "mixed";
|
||||
}
|
||||
if (modes.has("oauth")) {
|
||||
return "oauth";
|
||||
}
|
||||
if (modes.has("token")) {
|
||||
return "token";
|
||||
}
|
||||
if (modes.has("api_key")) {
|
||||
return "api-key";
|
||||
}
|
||||
}
|
||||
|
||||
if (authOverride === undefined && normalizeProviderId(resolved) === "amazon-bedrock") {
|
||||
@@ -328,14 +357,16 @@ export function resolveModelAuthMode(
|
||||
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
||||
}
|
||||
|
||||
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
|
||||
if (getCustomProviderApiKey(cfg, resolved)) {
|
||||
return "api-key";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: MoltbotConfig;
|
||||
cfg?: OpenClawConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
@@ -353,6 +384,8 @@ export async function getApiKeyForModel(params: {
|
||||
|
||||
export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string {
|
||||
const key = auth.apiKey?.trim();
|
||||
if (key) return key;
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
__setModelCatalogImportForTest,
|
||||
loadModelCatalog,
|
||||
resetModelCatalogCacheForTest,
|
||||
} from "./model-catalog.js";
|
||||
|
||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||
type PiSdkModule = typeof import("./pi-model-discovery.js");
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureMoltbotModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
|
||||
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-paths.js", () => ({
|
||||
resolveMoltbotAgentDir: () => "/tmp/moltbot",
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw",
|
||||
}));
|
||||
|
||||
describe("loadModelCatalog", () => {
|
||||
@@ -38,12 +37,16 @@ describe("loadModelCatalog", () => {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return {
|
||||
discoverAuthStorage: () => ({}),
|
||||
discoverModels: () => [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }],
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }];
|
||||
}
|
||||
},
|
||||
} as unknown as PiSdkModule;
|
||||
});
|
||||
|
||||
const cfg = {} as MoltbotConfig;
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const first = await loadModelCatalog({ config: cfg });
|
||||
expect(first).toEqual([]);
|
||||
|
||||
@@ -59,23 +62,25 @@ describe("loadModelCatalog", () => {
|
||||
__setModelCatalogImportForTest(
|
||||
async () =>
|
||||
({
|
||||
discoverAuthStorage: () => ({}),
|
||||
discoverModels: () => ({
|
||||
getAll: () => [
|
||||
{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" },
|
||||
{
|
||||
get id() {
|
||||
throw new Error("boom");
|
||||
AuthStorage: class {},
|
||||
ModelRegistry: class {
|
||||
getAll() {
|
||||
return [
|
||||
{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" },
|
||||
{
|
||||
get id() {
|
||||
throw new Error("boom");
|
||||
},
|
||||
provider: "openai",
|
||||
name: "bad",
|
||||
},
|
||||
provider: "openai",
|
||||
name: "bad",
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
},
|
||||
}) as unknown as PiSdkModule,
|
||||
);
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as MoltbotConfig });
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
|
||||
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type MoltbotConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
import { ensureMoltbotModelsJson } from "./models-config.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
|
||||
export type ModelCatalogEntry = {
|
||||
id: string;
|
||||
@@ -20,11 +20,11 @@ type DiscoveredModel = {
|
||||
input?: Array<"text" | "image">;
|
||||
};
|
||||
|
||||
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
|
||||
type PiSdkModule = typeof import("./pi-model-discovery.js");
|
||||
|
||||
let modelCatalogPromise: Promise<ModelCatalogEntry[]> | null = null;
|
||||
let hasLoggedModelCatalogError = false;
|
||||
const defaultImportPiSdk = () => import("@mariozechner/pi-coding-agent");
|
||||
const defaultImportPiSdk = () => import("./pi-model-discovery.js");
|
||||
let importPiSdk = defaultImportPiSdk;
|
||||
|
||||
export function resetModelCatalogCacheForTest() {
|
||||
@@ -39,33 +39,38 @@ export function __setModelCatalogImportForTest(loader?: () => Promise<PiSdkModul
|
||||
}
|
||||
|
||||
export async function loadModelCatalog(params?: {
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
useCache?: boolean;
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
if (params?.useCache === false) {
|
||||
modelCatalogPromise = null;
|
||||
}
|
||||
if (modelCatalogPromise) return modelCatalogPromise;
|
||||
if (modelCatalogPromise) {
|
||||
return modelCatalogPromise;
|
||||
}
|
||||
|
||||
modelCatalogPromise = (async () => {
|
||||
const models: ModelCatalogEntry[] = [];
|
||||
const sortModels = (entries: ModelCatalogEntry[]) =>
|
||||
entries.sort((a, b) => {
|
||||
const p = a.provider.localeCompare(b.provider);
|
||||
if (p !== 0) return p;
|
||||
if (p !== 0) {
|
||||
return p;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
try {
|
||||
const cfg = params?.config ?? loadConfig();
|
||||
await ensureMoltbotModelsJson(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
// IMPORTANT: keep the dynamic import *inside* the try/catch.
|
||||
// If this fails once (e.g. during a pnpm install that temporarily swaps node_modules),
|
||||
// we must not poison the cache with a rejected promise (otherwise all channel handlers
|
||||
// will keep failing until restart).
|
||||
const piSdk = await importPiSdk();
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const authStorage = piSdk.discoverAuthStorage(agentDir);
|
||||
const registry = piSdk.discoverModels(authStorage, agentDir) as
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const { join } = await import("node:path");
|
||||
const authStorage = new piSdk.AuthStorage(join(agentDir, "auth.json"));
|
||||
const registry = new piSdk.ModelRegistry(authStorage, join(agentDir, "models.json")) as
|
||||
| {
|
||||
getAll: () => Array<DiscoveredModel>;
|
||||
}
|
||||
@@ -73,18 +78,20 @@ export async function loadModelCatalog(params?: {
|
||||
const entries = Array.isArray(registry) ? registry : registry.getAll();
|
||||
for (const entry of entries) {
|
||||
const id = String(entry?.id ?? "").trim();
|
||||
if (!id) continue;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const provider = String(entry?.provider ?? "").trim();
|
||||
if (!provider) continue;
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
const name = String(entry?.name ?? id).trim() || id;
|
||||
const contextWindow =
|
||||
typeof entry?.contextWindow === "number" && entry.contextWindow > 0
|
||||
? entry.contextWindow
|
||||
: undefined;
|
||||
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
|
||||
const input = Array.isArray(entry?.input)
|
||||
? (entry.input as Array<"text" | "image">)
|
||||
: undefined;
|
||||
const input = Array.isArray(entry?.input) ? entry.input : undefined;
|
||||
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,15 @@ function isOpenAiCompletionsModel(model: Model<Api>): model is Model<"openai-com
|
||||
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
const baseUrl = model.baseUrl ?? "";
|
||||
const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
|
||||
if (!isZai || !isOpenAiCompletionsModel(model)) return model;
|
||||
if (!isZai || !isOpenAiCompletionsModel(model)) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const openaiModel = model as Model<"openai-completions">;
|
||||
const openaiModel = model;
|
||||
const compat = openaiModel.compat ?? undefined;
|
||||
if (compat?.supportsDeveloperRole === false) return model;
|
||||
if (compat?.supportsDeveloperRole === false) {
|
||||
return model;
|
||||
}
|
||||
|
||||
openaiModel.compat = compat
|
||||
? { ...compat, supportsDeveloperRole: false }
|
||||
|
||||
@@ -3,14 +3,13 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { saveAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
import { runWithModelFallback } from "./model-fallback.js";
|
||||
|
||||
function makeCfg(overrides: Partial<MoltbotConfig> = {}): MoltbotConfig {
|
||||
function makeCfg(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -21,7 +20,7 @@ function makeCfg(overrides: Partial<MoltbotConfig> = {}): MoltbotConfig {
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as MoltbotConfig;
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("runWithModelFallback", () => {
|
||||
@@ -125,7 +124,7 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
|
||||
it("skips providers when all profiles are in cooldown", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
const provider = `cooldown-test-${crypto.randomUUID()}`;
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
@@ -158,7 +157,9 @@ describe("runWithModelFallback", () => {
|
||||
},
|
||||
});
|
||||
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
|
||||
if (providerId === "fallback") return "ok";
|
||||
if (providerId === "fallback") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
|
||||
});
|
||||
|
||||
@@ -180,7 +181,7 @@ describe("runWithModelFallback", () => {
|
||||
});
|
||||
|
||||
it("does not skip when any profile is available", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-auth-"));
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
|
||||
const provider = `cooldown-mixed-${crypto.randomUUID()}`;
|
||||
const profileA = `${provider}:a`;
|
||||
const profileB = `${provider}:b`;
|
||||
@@ -219,7 +220,9 @@ describe("runWithModelFallback", () => {
|
||||
},
|
||||
});
|
||||
const run = vi.fn().mockImplementation(async (providerId) => {
|
||||
if (providerId === provider) return "ok";
|
||||
if (providerId === provider) {
|
||||
return "ok";
|
||||
}
|
||||
return "unexpected";
|
||||
});
|
||||
|
||||
@@ -279,7 +282,7 @@ describe("runWithModelFallback", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
} as OpenClawConfig;
|
||||
|
||||
const calls: Array<{ provider: string; model: string }> = [];
|
||||
|
||||
@@ -316,7 +319,7 @@ describe("runWithModelFallback", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
} as OpenClawConfig;
|
||||
|
||||
const calls: Array<{ provider: string; model: string }> = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
coerceToFailoverError,
|
||||
@@ -13,12 +19,6 @@ import {
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
import type { FailoverReason } from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
@@ -35,8 +35,12 @@ type FallbackAttempt = {
|
||||
};
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
if (isFailoverError(err)) return false;
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (isFailoverError(err)) {
|
||||
return false;
|
||||
}
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
// Only treat explicit AbortError names as user aborts.
|
||||
// Message-based checks (e.g., "aborted") can mask timeouts and skip fallback.
|
||||
@@ -48,25 +52,29 @@ function shouldRethrowAbort(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function buildAllowedModelKeys(
|
||||
cfg: MoltbotConfig | undefined,
|
||||
cfg: OpenClawConfig | undefined,
|
||||
defaultProvider: string,
|
||||
): Set<string> | null {
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = cfg?.agents?.defaults?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
if (rawAllowlist.length === 0) return null;
|
||||
if (rawAllowlist.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
for (const raw of rawAllowlist) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
|
||||
if (!parsed) continue;
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
keys.add(modelKey(parsed.provider, parsed.model));
|
||||
}
|
||||
return keys.size > 0 ? keys : null;
|
||||
}
|
||||
|
||||
function resolveImageFallbackCandidates(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
defaultProvider: string;
|
||||
modelOverride?: string;
|
||||
}): ModelCandidate[] {
|
||||
@@ -79,10 +87,16 @@ function resolveImageFallbackCandidates(params: {
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
if (!candidate.provider || !candidate.model) {
|
||||
return;
|
||||
}
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
@@ -93,7 +107,9 @@ function resolveImageFallbackCandidates(params: {
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
addCandidate(resolved.ref, enforceAllowlist);
|
||||
};
|
||||
|
||||
@@ -105,7 +121,9 @@ function resolveImageFallbackCandidates(params: {
|
||||
| string
|
||||
| undefined;
|
||||
const primary = typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
if (primary?.trim()) addRaw(primary, false);
|
||||
if (primary?.trim()) {
|
||||
addRaw(primary, false);
|
||||
}
|
||||
}
|
||||
|
||||
const imageFallbacks = (() => {
|
||||
@@ -127,7 +145,7 @@ function resolveImageFallbackCandidates(params: {
|
||||
}
|
||||
|
||||
function resolveFallbackCandidates(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
@@ -153,10 +171,16 @@ function resolveFallbackCandidates(params: {
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
if (!candidate.provider || !candidate.model) {
|
||||
return;
|
||||
}
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
@@ -164,12 +188,16 @@ function resolveFallbackCandidates(params: {
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
const modelFallbacks = (() => {
|
||||
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
|
||||
if (params.fallbacksOverride !== undefined) {
|
||||
return params.fallbacksOverride;
|
||||
}
|
||||
const model = params.cfg?.agents?.defaults?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (model && typeof model === "object") return model.fallbacks ?? [];
|
||||
if (model && typeof model === "object") {
|
||||
return model.fallbacks ?? [];
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
@@ -179,7 +207,9 @@ function resolveFallbackCandidates(params: {
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
@@ -191,7 +221,7 @@ function resolveFallbackCandidates(params: {
|
||||
}
|
||||
|
||||
export async function runWithModelFallback<T>(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
agentDir?: string;
|
||||
@@ -224,7 +254,7 @@ export async function runWithModelFallback<T>(params: {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i] as ModelCandidate;
|
||||
const candidate = candidates[i];
|
||||
if (authStore) {
|
||||
const profileIds = resolveAuthProfileOrder({
|
||||
cfg: params.cfg,
|
||||
@@ -253,13 +283,17 @@ export async function runWithModelFallback<T>(params: {
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) throw err;
|
||||
if (shouldRethrowAbort(err)) {
|
||||
throw err;
|
||||
}
|
||||
const normalized =
|
||||
coerceToFailoverError(err, {
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
}) ?? err;
|
||||
if (!isFailoverError(normalized)) throw err;
|
||||
if (!isFailoverError(normalized)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
lastError = normalized;
|
||||
const described = describeFailoverError(normalized);
|
||||
@@ -281,7 +315,9 @@ export async function runWithModelFallback<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts.length <= 1 && lastError) throw lastError;
|
||||
if (attempts.length <= 1 && lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
@@ -299,7 +335,7 @@ export async function runWithModelFallback<T>(params: {
|
||||
}
|
||||
|
||||
export async function runWithImageModelFallback<T>(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
cfg: OpenClawConfig | undefined;
|
||||
modelOverride?: string;
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
onError?: (attempt: {
|
||||
@@ -330,7 +366,7 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i] as ModelCandidate;
|
||||
const candidate = candidates[i];
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
@@ -340,7 +376,9 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) throw err;
|
||||
if (shouldRethrowAbort(err)) {
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
@@ -357,7 +395,9 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts.length <= 1 && lastError) throw lastError;
|
||||
if (attempts.length <= 1 && lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scanOpenRouterModels } from "./model-scan.js";
|
||||
|
||||
function createFetchFixture(payload: unknown): typeof fetch {
|
||||
|
||||
@@ -85,9 +85,15 @@ export type OpenRouterScanOptions = {
|
||||
type OpenAIModel = Model<"openai-completions">;
|
||||
|
||||
function normalizeCreatedAtMs(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
if (value <= 0) return null;
|
||||
if (value > 1e12) return Math.round(value);
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (value > 1e12) {
|
||||
return Math.round(value);
|
||||
}
|
||||
return Math.round(value * 1000);
|
||||
}
|
||||
|
||||
@@ -97,16 +103,24 @@ function inferParamBFromIdOrName(text: string): number | null {
|
||||
let best: number | null = null;
|
||||
for (const match of matches) {
|
||||
const numRaw = match[1];
|
||||
if (!numRaw) continue;
|
||||
if (!numRaw) {
|
||||
continue;
|
||||
}
|
||||
const value = Number(numRaw);
|
||||
if (!Number.isFinite(value) || value <= 0) continue;
|
||||
if (best === null || value > best) best = value;
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (best === null || value > best) {
|
||||
best = value;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseModality(modality: string | null): Array<"text" | "image"> {
|
||||
if (!modality) return ["text"];
|
||||
if (!modality) {
|
||||
return ["text"];
|
||||
}
|
||||
const normalized = modality.toLowerCase();
|
||||
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
|
||||
const hasImage = parts.includes("image");
|
||||
@@ -114,17 +128,27 @@ function parseModality(modality: string | null): Array<"text" | "image"> {
|
||||
}
|
||||
|
||||
function parseNumberString(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const num = Number(trimmed);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
if (!Number.isFinite(num)) {
|
||||
return null;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const prompt = parseNumberString(obj.prompt);
|
||||
const completion = parseNumberString(obj.completion);
|
||||
@@ -133,7 +157,9 @@ function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
|
||||
const webSearch = parseNumberString(obj.web_search) ?? 0;
|
||||
const internalReasoning = parseNumberString(obj.internal_reasoning) ?? 0;
|
||||
|
||||
if (prompt === null || completion === null) return null;
|
||||
if (prompt === null || completion === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
completion,
|
||||
@@ -145,8 +171,12 @@ function parseOpenRouterPricing(value: unknown): OpenRouterModelPricing | null {
|
||||
}
|
||||
|
||||
function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean {
|
||||
if (entry.id.endsWith(":free")) return true;
|
||||
if (!entry.pricing) return false;
|
||||
if (entry.id.endsWith(":free")) {
|
||||
return true;
|
||||
}
|
||||
if (!entry.pricing) {
|
||||
return false;
|
||||
}
|
||||
return entry.pricing.prompt === 0 && entry.pricing.completion === 0;
|
||||
}
|
||||
|
||||
@@ -175,10 +205,14 @@ async function fetchOpenRouterModels(fetchImpl: typeof fetch): Promise<OpenRoute
|
||||
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
||||
if (!id) return null;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
|
||||
|
||||
const contextLength =
|
||||
@@ -311,7 +345,9 @@ async function probeImage(
|
||||
}
|
||||
|
||||
function ensureImageInput(model: OpenAIModel): OpenAIModel {
|
||||
if (model.input.includes("image")) return model;
|
||||
if (model.input.includes("image")) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
input: Array.from(new Set([...model.input, "image"])),
|
||||
@@ -325,7 +361,7 @@ async function mapWithConcurrency<T, R>(
|
||||
opts?: { onProgress?: (completed: number, total: number) => void },
|
||||
): Promise<R[]> {
|
||||
const limit = Math.max(1, Math.floor(concurrency));
|
||||
const results = Array.from({ length: items.length }) as R[];
|
||||
const results: R[] = Array.from({ length: items.length }, () => undefined as R);
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
@@ -333,8 +369,10 @@ async function mapWithConcurrency<T, R>(
|
||||
while (true) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (current >= items.length) return;
|
||||
results[current] = await fn(items[current] as T, current);
|
||||
if (current >= items.length) {
|
||||
return;
|
||||
}
|
||||
results[current] = await fn(items[current], current);
|
||||
completed += 1;
|
||||
opts?.onProgress?.(completed, items.length);
|
||||
}
|
||||
@@ -369,19 +407,27 @@ export async function scanOpenRouterModels(
|
||||
const now = Date.now();
|
||||
|
||||
const filtered = catalog.filter((entry) => {
|
||||
if (!isFreeOpenRouterModel(entry)) return false;
|
||||
if (!isFreeOpenRouterModel(entry)) {
|
||||
return false;
|
||||
}
|
||||
if (providerFilter) {
|
||||
const prefix = entry.id.split("/")[0]?.toLowerCase() ?? "";
|
||||
if (prefix !== providerFilter) return false;
|
||||
if (prefix !== providerFilter) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (minParamB > 0) {
|
||||
const params = entry.inferredParamB ?? 0;
|
||||
if (params < minParamB) return false;
|
||||
if (params < minParamB) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (maxAgeDays > 0 && entry.createdAtMs) {
|
||||
const ageMs = now - entry.createdAtMs;
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
if (ageDays > maxAgeDays) return false;
|
||||
if (ageDays > maxAgeDays) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
parseModelRef,
|
||||
resolveModelRefFromString,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
normalizeProviderId,
|
||||
modelKey,
|
||||
} from "./model-selection.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
describe("model-selection", () => {
|
||||
describe("normalizeProviderId", () => {
|
||||
@@ -17,6 +17,7 @@ describe("model-selection", () => {
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||
expect(normalizeProviderId("kimi-code")).toBe("kimi-coding");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +50,7 @@ describe("model-selection", () => {
|
||||
|
||||
describe("buildModelAliasIndex", () => {
|
||||
it("should build alias index from config", () => {
|
||||
const cfg: Partial<MoltbotConfig> = {
|
||||
const cfg: Partial<OpenClawConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
@@ -61,7 +62,7 @@ describe("model-selection", () => {
|
||||
};
|
||||
|
||||
const index = buildModelAliasIndex({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
cfg: cfg as OpenClawConfig,
|
||||
defaultProvider: "anthropic",
|
||||
});
|
||||
|
||||
@@ -105,7 +106,7 @@ describe("model-selection", () => {
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const cfg: Partial<MoltbotConfig> = {
|
||||
const cfg: Partial<OpenClawConfig> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "claude-3-5-sonnet",
|
||||
@@ -114,7 +115,7 @@ describe("model-selection", () => {
|
||||
};
|
||||
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
cfg: cfg as OpenClawConfig,
|
||||
defaultProvider: "google",
|
||||
defaultModel: "gemini-pro",
|
||||
});
|
||||
@@ -127,9 +128,9 @@ describe("model-selection", () => {
|
||||
});
|
||||
|
||||
it("should use default provider/model if config is empty", () => {
|
||||
const cfg: Partial<MoltbotConfig> = {};
|
||||
const cfg: Partial<OpenClawConfig> = {};
|
||||
const result = resolveConfiguredModelRef({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
cfg: cfg as OpenClawConfig,
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.js";
|
||||
import { normalizeGoogleModelId } from "./models-config.providers.js";
|
||||
import { resolveAgentModelPrimary } from "./agent-scope.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { normalizeGoogleModelId } from "./models-config.providers.js";
|
||||
|
||||
export type ModelRef = {
|
||||
provider: string;
|
||||
@@ -26,38 +26,63 @@ export function modelKey(provider: string, model: string) {
|
||||
|
||||
export function normalizeProviderId(provider: string): string {
|
||||
const normalized = provider.trim().toLowerCase();
|
||||
if (normalized === "z.ai" || normalized === "z-ai") return "zai";
|
||||
if (normalized === "opencode-zen") return "opencode";
|
||||
if (normalized === "qwen") return "qwen-portal";
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||
return "zai";
|
||||
}
|
||||
if (normalized === "opencode-zen") {
|
||||
return "opencode";
|
||||
}
|
||||
if (normalized === "qwen") {
|
||||
return "qwen-portal";
|
||||
}
|
||||
if (normalized === "kimi-code") {
|
||||
return "kimi-coding";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isCliProvider(provider: string, cfg?: MoltbotConfig): boolean {
|
||||
export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
if (normalized === "claude-cli") return true;
|
||||
if (normalized === "codex-cli") return true;
|
||||
if (normalized === "claude-cli") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "codex-cli") {
|
||||
return true;
|
||||
}
|
||||
const backends = cfg?.agents?.defaults?.cliBackends ?? {};
|
||||
return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized);
|
||||
}
|
||||
|
||||
function normalizeAnthropicModelId(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "opus-4.5") return "claude-opus-4-5";
|
||||
if (lower === "sonnet-4.5") return "claude-sonnet-4-5";
|
||||
if (lower === "opus-4.5") {
|
||||
return "claude-opus-4-5";
|
||||
}
|
||||
if (lower === "sonnet-4.5") {
|
||||
return "claude-sonnet-4-5";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeProviderModelId(provider: string, model: string): string {
|
||||
if (provider === "anthropic") return normalizeAnthropicModelId(model);
|
||||
if (provider === "google") return normalizeGoogleModelId(model);
|
||||
if (provider === "anthropic") {
|
||||
return normalizeAnthropicModelId(model);
|
||||
}
|
||||
if (provider === "google") {
|
||||
return normalizeGoogleModelId(model);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export function parseModelRef(raw: string, defaultProvider: string): ModelRef | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash === -1) {
|
||||
const provider = normalizeProviderId(defaultProvider);
|
||||
@@ -67,13 +92,15 @@ export function parseModelRef(raw: string, defaultProvider: string): ModelRef |
|
||||
const providerRaw = trimmed.slice(0, slash).trim();
|
||||
const provider = normalizeProviderId(providerRaw);
|
||||
const model = trimmed.slice(slash + 1).trim();
|
||||
if (!provider || !model) return null;
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
const normalizedModel = normalizeProviderModelId(provider, model);
|
||||
return { provider, model: normalizedModel };
|
||||
}
|
||||
|
||||
export function buildModelAliasIndex(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
defaultProvider: string;
|
||||
}): ModelAliasIndex {
|
||||
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
|
||||
@@ -82,9 +109,13 @@ export function buildModelAliasIndex(params: {
|
||||
const rawModels = params.cfg.agents?.defaults?.models ?? {};
|
||||
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
|
||||
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const alias = String((entryRaw as { alias?: string } | undefined)?.alias ?? "").trim();
|
||||
if (!alias) continue;
|
||||
if (!alias) {
|
||||
continue;
|
||||
}
|
||||
const aliasKey = normalizeAliasKey(alias);
|
||||
byAlias.set(aliasKey, { alias, ref: parsed });
|
||||
const key = modelKey(parsed.provider, parsed.model);
|
||||
@@ -102,7 +133,9 @@ export function resolveModelRefFromString(params: {
|
||||
aliasIndex?: ModelAliasIndex;
|
||||
}): { ref: ModelRef; alias?: string } | null {
|
||||
const trimmed = params.raw.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!trimmed.includes("/")) {
|
||||
const aliasKey = normalizeAliasKey(trimmed);
|
||||
const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey);
|
||||
@@ -111,18 +144,22 @@ export function resolveModelRefFromString(params: {
|
||||
}
|
||||
}
|
||||
const parsed = parseModelRef(trimmed, params.defaultProvider);
|
||||
if (!parsed) return null;
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return { ref: parsed };
|
||||
}
|
||||
|
||||
export function resolveConfiguredModelRef(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
}): ModelRef {
|
||||
const rawModel = (() => {
|
||||
const raw = params.cfg.agents?.defaults?.model as { primary?: string } | string | undefined;
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim();
|
||||
}
|
||||
return raw?.primary?.trim() ?? "";
|
||||
})();
|
||||
if (rawModel) {
|
||||
@@ -134,11 +171,13 @@ export function resolveConfiguredModelRef(params: {
|
||||
if (!trimmed.includes("/")) {
|
||||
const aliasKey = normalizeAliasKey(trimmed);
|
||||
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
|
||||
if (aliasMatch) return aliasMatch.ref;
|
||||
if (aliasMatch) {
|
||||
return aliasMatch.ref;
|
||||
}
|
||||
|
||||
// Default to anthropic if no provider is specified, but warn as this is deprecated.
|
||||
console.warn(
|
||||
`[moltbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
|
||||
`[openclaw] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
|
||||
);
|
||||
return { provider: "anthropic", model: trimmed };
|
||||
}
|
||||
@@ -148,13 +187,15 @@ export function resolveConfiguredModelRef(params: {
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) return resolved.ref;
|
||||
if (resolved) {
|
||||
return resolved.ref;
|
||||
}
|
||||
}
|
||||
return { provider: params.defaultProvider, model: params.defaultModel };
|
||||
}
|
||||
|
||||
export function resolveDefaultModelForAgent(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string;
|
||||
}): ModelRef {
|
||||
const agentModelOverride = params.agentId
|
||||
@@ -186,7 +227,7 @@ export function resolveDefaultModelForAgent(params: {
|
||||
}
|
||||
|
||||
export function buildAllowedModelSet(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
catalog: ModelCatalogEntry[];
|
||||
defaultProvider: string;
|
||||
defaultModel?: string;
|
||||
@@ -208,7 +249,9 @@ export function buildAllowedModelSet(params: {
|
||||
const catalogKeys = new Set(params.catalog.map((entry) => modelKey(entry.provider, entry.id)));
|
||||
|
||||
if (allowAny) {
|
||||
if (defaultKey) catalogKeys.add(defaultKey);
|
||||
if (defaultKey) {
|
||||
catalogKeys.add(defaultKey);
|
||||
}
|
||||
return {
|
||||
allowAny: true,
|
||||
allowedCatalog: params.catalog,
|
||||
@@ -220,7 +263,9 @@ export function buildAllowedModelSet(params: {
|
||||
const configuredProviders = (params.cfg.models?.providers ?? {}) as Record<string, unknown>;
|
||||
for (const raw of rawAllowlist) {
|
||||
const parsed = parseModelRef(String(raw), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const key = modelKey(parsed.provider, parsed.model);
|
||||
const providerKey = normalizeProviderId(parsed.provider);
|
||||
if (isCliProvider(parsed.provider, params.cfg)) {
|
||||
@@ -243,7 +288,9 @@ export function buildAllowedModelSet(params: {
|
||||
);
|
||||
|
||||
if (allowedCatalog.length === 0 && allowedKeys.size === 0) {
|
||||
if (defaultKey) catalogKeys.add(defaultKey);
|
||||
if (defaultKey) {
|
||||
catalogKeys.add(defaultKey);
|
||||
}
|
||||
return {
|
||||
allowAny: true,
|
||||
allowedCatalog: params.catalog,
|
||||
@@ -262,7 +309,7 @@ export type ModelRefStatus = {
|
||||
};
|
||||
|
||||
export function getModelRefStatus(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
catalog: ModelCatalogEntry[];
|
||||
ref: ModelRef;
|
||||
defaultProvider: string;
|
||||
@@ -284,7 +331,7 @@ export function getModelRefStatus(params: {
|
||||
}
|
||||
|
||||
export function resolveAllowedModelRef(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
catalog: ModelCatalogEntry[];
|
||||
raw: string;
|
||||
defaultProvider: string;
|
||||
@@ -295,7 +342,9 @@ export function resolveAllowedModelRef(params: {
|
||||
error: string;
|
||||
} {
|
||||
const trimmed = params.raw.trim();
|
||||
if (!trimmed) return { error: "invalid model: empty" };
|
||||
if (!trimmed) {
|
||||
return { error: "invalid model: empty" };
|
||||
}
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
@@ -306,7 +355,9 @@ export function resolveAllowedModelRef(params: {
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return { error: `invalid model: ${trimmed}` };
|
||||
if (!resolved) {
|
||||
return { error: `invalid model: ${trimmed}` };
|
||||
}
|
||||
|
||||
const status = getModelRefStatus({
|
||||
cfg: params.cfg,
|
||||
@@ -323,17 +374,21 @@ export function resolveAllowedModelRef(params: {
|
||||
}
|
||||
|
||||
export function resolveThinkingDefault(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
model: string;
|
||||
catalog?: ModelCatalogEntry[];
|
||||
}): ThinkLevel {
|
||||
const configured = params.cfg.agents?.defaults?.thinkingDefault;
|
||||
if (configured) return configured;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const candidate = params.catalog?.find(
|
||||
(entry) => entry.provider === params.provider && entry.id === params.model,
|
||||
);
|
||||
if (candidate?.reasoning) return "low";
|
||||
if (candidate?.reasoning) {
|
||||
return "low";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
@@ -342,11 +397,13 @@ export function resolveThinkingDefault(params: {
|
||||
* Returns null if hooks.gmail.model is not set.
|
||||
*/
|
||||
export function resolveHooksGmailModel(params: {
|
||||
cfg: MoltbotConfig;
|
||||
cfg: OpenClawConfig;
|
||||
defaultProvider: string;
|
||||
}): ModelRef | null {
|
||||
const hooksModel = params.cfg.hooks?.gmail?.model;
|
||||
if (!hooksModel?.trim()) return null;
|
||||
if (!hooksModel?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const _MODELS_CONFIG: MoltbotConfig = {
|
||||
const _MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -61,10 +61,10 @@ describe("models-config", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
|
||||
const agentDir = path.join(home, "agent-default-base-url");
|
||||
await ensureMoltbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
@@ -102,9 +102,9 @@ describe("models-config", () => {
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureMoltbotModelsJson({ models: { providers: {} } });
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } });
|
||||
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const _MODELS_CONFIG: MoltbotConfig = {
|
||||
const _MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -56,12 +56,12 @@ describe("models-config", () => {
|
||||
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureMoltbotModelsJson({ models: { providers: {} } });
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } });
|
||||
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
@@ -115,9 +115,9 @@ describe("models-config", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureMoltbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
@@ -126,12 +126,21 @@ describe("models-config", () => {
|
||||
|
||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
if (previousGh === undefined) delete process.env.GH_TOKEN;
|
||||
else process.env.GH_TOKEN = previousGh;
|
||||
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
|
||||
else process.env.GITHUB_TOKEN = previousGithub;
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
if (previousGh === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
}
|
||||
if (previousGithub === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const MODELS_CONFIG: MoltbotConfig = {
|
||||
const MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -49,10 +49,10 @@ describe("models-config", () => {
|
||||
const prevKey = process.env.MINIMAX_API_KEY;
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-test";
|
||||
try {
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
@@ -74,9 +74,9 @@ describe("models-config", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await ensureMoltbotModelsJson(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelPath = path.join(resolveMoltbotAgentDir(), "models.json");
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
|
||||
@@ -85,18 +85,21 @@ describe("models-config", () => {
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-VL-01");
|
||||
} finally {
|
||||
if (prevKey === undefined) delete process.env.MINIMAX_API_KEY;
|
||||
else process.env.MINIMAX_API_KEY = prevKey;
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
it("merges providers by default", async () => {
|
||||
await withTempHome(async () => {
|
||||
vi.resetModules();
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
@@ -128,7 +131,7 @@ describe("models-config", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await ensureMoltbotModelsJson(MODELS_CONFIG);
|
||||
await ensureOpenClawModelsJson(MODELS_CONFIG);
|
||||
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const _MODELS_CONFIG: MoltbotConfig = {
|
||||
const _MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -46,10 +46,10 @@ describe("models-config", () => {
|
||||
it("normalizes gemini 3 ids to preview for google providers", async () => {
|
||||
await withTempHome(async () => {
|
||||
vi.resetModules();
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
const cfg: MoltbotConfig = {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
@@ -83,9 +83,9 @@ describe("models-config", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await ensureMoltbotModelsJson(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelPath = path.join(resolveMoltbotAgentDir(), "models.json");
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { models: Array<{ id: string }> }>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("Ollama provider", () => {
|
||||
it("should not include ollama when no API key is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
|
||||
// Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
import { discoverBedrockModels } from "./bedrock-discovery.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
@@ -14,14 +14,16 @@ import {
|
||||
} from "./synthetic-models.js";
|
||||
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<MoltbotConfig["models"]>;
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
|
||||
const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1";
|
||||
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1";
|
||||
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MINIMAX_OAUTH_PLACEHOLDER = "minimax-oauth";
|
||||
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
|
||||
const MINIMAX_API_COST = {
|
||||
input: 15,
|
||||
@@ -30,23 +32,22 @@ const MINIMAX_API_COST = {
|
||||
cacheWrite: 10,
|
||||
};
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
|
||||
const XIAOMI_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
||||
const KIMI_CODE_MODEL_ID = "kimi-for-coding";
|
||||
const KIMI_CODE_CONTEXT_WINDOW = 262144;
|
||||
const KIMI_CODE_MAX_TOKENS = 32768;
|
||||
const KIMI_CODE_HEADERS = { "User-Agent": "KimiCLI/0.77" } as const;
|
||||
const KIMI_CODE_COMPAT = { supportsDeveloperRole: false } as const;
|
||||
const KIMI_CODE_DEFAULT_COST = {
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
@@ -136,7 +137,9 @@ function normalizeApiKeyConfig(value: string): string {
|
||||
|
||||
function resolveEnvApiKeyVarName(provider: string): string | undefined {
|
||||
const resolved = resolveEnvApiKey(provider);
|
||||
if (!resolved) return undefined;
|
||||
if (!resolved) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^(?:env: |shell env: )([A-Z0-9_]+)$/.exec(resolved.source);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
@@ -152,16 +155,26 @@ function resolveApiKeyFromProfiles(params: {
|
||||
const ids = listProfilesForProvider(params.store, params.provider);
|
||||
for (const id of ids) {
|
||||
const cred = params.store.profiles[id];
|
||||
if (!cred) continue;
|
||||
if (cred.type === "api_key") return cred.key;
|
||||
if (cred.type === "token") return cred.token;
|
||||
if (!cred) {
|
||||
continue;
|
||||
}
|
||||
if (cred.type === "api_key") {
|
||||
return cred.key;
|
||||
}
|
||||
if (cred.type === "token") {
|
||||
return cred.token;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3-pro") return "gemini-3-pro-preview";
|
||||
if (id === "gemini-3-flash") return "gemini-3-flash-preview";
|
||||
if (id === "gemini-3-pro") {
|
||||
return "gemini-3-pro-preview";
|
||||
}
|
||||
if (id === "gemini-3-flash") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -169,7 +182,9 @@ function normalizeGoogleProvider(provider: ProviderConfig): ProviderConfig {
|
||||
let mutated = false;
|
||||
const models = provider.models.map((model) => {
|
||||
const nextId = normalizeGoogleModelId(model.id);
|
||||
if (nextId === model.id) return model;
|
||||
if (nextId === model.id) {
|
||||
return model;
|
||||
}
|
||||
mutated = true;
|
||||
return { ...model, id: nextId };
|
||||
});
|
||||
@@ -181,7 +196,9 @@ export function normalizeProviders(params: {
|
||||
agentDir: string;
|
||||
}): ModelsConfig["providers"] {
|
||||
const { providers } = params;
|
||||
if (!providers) return providers;
|
||||
if (!providers) {
|
||||
return providers;
|
||||
}
|
||||
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@@ -231,7 +248,9 @@ export function normalizeProviders(params: {
|
||||
|
||||
if (normalizedKey === "google") {
|
||||
const googleNormalized = normalizeGoogleProvider(normalizedProvider);
|
||||
if (googleNormalized !== normalizedProvider) mutated = true;
|
||||
if (googleNormalized !== normalizedProvider) {
|
||||
mutated = true;
|
||||
}
|
||||
normalizedProvider = googleNormalized;
|
||||
}
|
||||
|
||||
@@ -268,6 +287,24 @@ function buildMinimaxProvider(): ProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMoonshotProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
@@ -286,26 +323,6 @@ function buildMoonshotProvider(): ProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function buildKimiCodeProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KIMI_CODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: KIMI_CODE_MODEL_ID,
|
||||
name: "Kimi For Coding",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: KIMI_CODE_DEFAULT_COST,
|
||||
contextWindow: KIMI_CODE_CONTEXT_WINDOW,
|
||||
maxTokens: KIMI_CODE_MAX_TOKENS,
|
||||
headers: KIMI_CODE_HEADERS,
|
||||
compat: KIMI_CODE_COMPAT,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildQwenPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_PORTAL_BASE_URL,
|
||||
@@ -341,6 +358,24 @@ function buildSyntheticProvider(): ProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXiaomiProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: XIAOMI_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: XIAOMI_DEFAULT_MODEL_ID,
|
||||
name: "Xiaomi MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: XIAOMI_DEFAULT_COST,
|
||||
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function buildVeniceProvider(): Promise<ProviderConfig> {
|
||||
const models = await discoverVeniceModels();
|
||||
return {
|
||||
@@ -374,6 +409,14 @@ export async function resolveImplicitProviders(params: {
|
||||
providers.minimax = { ...buildMinimaxProvider(), apiKey: minimaxKey };
|
||||
}
|
||||
|
||||
const minimaxOauthProfile = listProfilesForProvider(authStore, "minimax-portal");
|
||||
if (minimaxOauthProfile.length > 0) {
|
||||
providers["minimax-portal"] = {
|
||||
...buildMinimaxPortalProvider(),
|
||||
apiKey: MINIMAX_OAUTH_PLACEHOLDER,
|
||||
};
|
||||
}
|
||||
|
||||
const moonshotKey =
|
||||
resolveEnvApiKeyVarName("moonshot") ??
|
||||
resolveApiKeyFromProfiles({ provider: "moonshot", store: authStore });
|
||||
@@ -381,13 +424,6 @@ export async function resolveImplicitProviders(params: {
|
||||
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
|
||||
}
|
||||
|
||||
const kimiCodeKey =
|
||||
resolveEnvApiKeyVarName("kimi-code") ??
|
||||
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
|
||||
if (kimiCodeKey) {
|
||||
providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey };
|
||||
}
|
||||
|
||||
const syntheticKey =
|
||||
resolveEnvApiKeyVarName("synthetic") ??
|
||||
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
|
||||
@@ -410,6 +446,13 @@ export async function resolveImplicitProviders(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const xiaomiKey =
|
||||
resolveEnvApiKeyVarName("xiaomi") ??
|
||||
resolveApiKeyFromProfiles({ provider: "xiaomi", store: authStore });
|
||||
if (xiaomiKey) {
|
||||
providers.xiaomi = { ...buildXiaomiProvider(), apiKey: xiaomiKey };
|
||||
}
|
||||
|
||||
// Ollama provider - only add if explicitly configured
|
||||
const ollamaKey =
|
||||
resolveEnvApiKeyVarName("ollama") ??
|
||||
@@ -431,7 +474,9 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||
const githubToken = (envToken ?? "").trim();
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
if (!hasProfile && !githubToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
@@ -459,15 +504,15 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
// Our Copilot auth lives in Moltbot's auth-profiles store instead, so we also
|
||||
// Our Copilot auth lives in OpenClaw's auth-profiles store instead, so we also
|
||||
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
|
||||
//
|
||||
// This is safe because it's (1) within Moltbot's agent dir, (2) contains the
|
||||
// This is safe because it's (1) within OpenClaw's agent dir, (2) contains the
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Moltbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Moltbot's auth heuristics for availability.
|
||||
// OpenClaw uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses OpenClaw's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
||||
@@ -480,19 +525,25 @@ export async function resolveImplicitCopilotProvider(params: {
|
||||
|
||||
export async function resolveImplicitBedrockProvider(params: {
|
||||
agentDir: string;
|
||||
config?: MoltbotConfig;
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<ProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const discoveryConfig = params.config?.models?.bedrockDiscovery;
|
||||
const enabled = discoveryConfig?.enabled;
|
||||
const hasAwsCreds = resolveAwsSdkEnvVarName(env) !== undefined;
|
||||
if (enabled === false) return null;
|
||||
if (enabled !== true && !hasAwsCreds) return null;
|
||||
if (enabled === false) {
|
||||
return null;
|
||||
}
|
||||
if (enabled !== true && !hasAwsCreds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
const models = await discoverBedrockModels({ region, config: discoveryConfig });
|
||||
if (models.length === 0) return null;
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const MODELS_CONFIG: MoltbotConfig = {
|
||||
const MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -48,26 +48,28 @@ describe("models-config", () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const previousKimiCode = process.env.KIMICODE_API_KEY;
|
||||
const previousKimiCode = process.env.KIMI_API_KEY;
|
||||
const previousMinimax = process.env.MINIMAX_API_KEY;
|
||||
const previousMoonshot = process.env.MOONSHOT_API_KEY;
|
||||
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
|
||||
const previousVenice = process.env.VENICE_API_KEY;
|
||||
const previousXiaomi = process.env.XIAOMI_API_KEY;
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
delete process.env.KIMICODE_API_KEY;
|
||||
delete process.env.KIMI_API_KEY;
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
delete process.env.VENICE_API_KEY;
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
|
||||
const agentDir = path.join(home, "agent-empty");
|
||||
const result = await ensureMoltbotModelsJson(
|
||||
const result = await ensureOpenClawModelsJson(
|
||||
{
|
||||
models: { providers: {} },
|
||||
},
|
||||
@@ -77,34 +79,63 @@ describe("models-config", () => {
|
||||
await expect(fs.stat(path.join(agentDir, "models.json"))).rejects.toThrow();
|
||||
expect(result.wrote).toBe(false);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
if (previousGh === undefined) delete process.env.GH_TOKEN;
|
||||
else process.env.GH_TOKEN = previousGh;
|
||||
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
|
||||
else process.env.GITHUB_TOKEN = previousGithub;
|
||||
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
|
||||
else process.env.KIMICODE_API_KEY = previousKimiCode;
|
||||
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
|
||||
else process.env.MINIMAX_API_KEY = previousMinimax;
|
||||
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
|
||||
else process.env.MOONSHOT_API_KEY = previousMoonshot;
|
||||
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
|
||||
else process.env.VENICE_API_KEY = previousVenice;
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
if (previousGh === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
}
|
||||
if (previousGithub === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
if (previousKimiCode === undefined) {
|
||||
delete process.env.KIMI_API_KEY;
|
||||
} else {
|
||||
process.env.KIMI_API_KEY = previousKimiCode;
|
||||
}
|
||||
if (previousMinimax === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = previousMinimax;
|
||||
}
|
||||
if (previousMoonshot === undefined) {
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
} else {
|
||||
process.env.MOONSHOT_API_KEY = previousMoonshot;
|
||||
}
|
||||
if (previousSynthetic === undefined) {
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
} else {
|
||||
process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
}
|
||||
if (previousVenice === undefined) {
|
||||
delete process.env.VENICE_API_KEY;
|
||||
} else {
|
||||
process.env.VENICE_API_KEY = previousVenice;
|
||||
}
|
||||
if (previousXiaomi === undefined) {
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
} else {
|
||||
process.env.XIAOMI_API_KEY = previousXiaomi;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
it("writes models.json for configured providers", async () => {
|
||||
await withTempHome(async () => {
|
||||
vi.resetModules();
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureMoltbotModelsJson(MODELS_CONFIG);
|
||||
await ensureOpenClawModelsJson(MODELS_CONFIG);
|
||||
|
||||
const modelPath = path.join(resolveMoltbotAgentDir(), "models.json");
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
@@ -119,12 +150,12 @@ describe("models-config", () => {
|
||||
const prevKey = process.env.MINIMAX_API_KEY;
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-test";
|
||||
try {
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureMoltbotModelsJson({});
|
||||
await ensureOpenClawModelsJson({});
|
||||
|
||||
const modelPath = path.join(resolveMoltbotAgentDir(), "models.json");
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<
|
||||
@@ -142,8 +173,11 @@ describe("models-config", () => {
|
||||
expect(ids).toContain("MiniMax-M2.1");
|
||||
expect(ids).toContain("MiniMax-VL-01");
|
||||
} finally {
|
||||
if (prevKey === undefined) delete process.env.MINIMAX_API_KEY;
|
||||
else process.env.MINIMAX_API_KEY = prevKey;
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -153,12 +187,12 @@ describe("models-config", () => {
|
||||
const prevKey = process.env.SYNTHETIC_API_KEY;
|
||||
process.env.SYNTHETIC_API_KEY = "sk-synthetic-test";
|
||||
try {
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureMoltbotModelsJson({});
|
||||
await ensureOpenClawModelsJson({});
|
||||
|
||||
const modelPath = path.join(resolveMoltbotAgentDir(), "models.json");
|
||||
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<
|
||||
@@ -175,8 +209,11 @@ describe("models-config", () => {
|
||||
const ids = parsed.providers.synthetic?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
|
||||
} finally {
|
||||
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = prevKey;
|
||||
if (prevKey === undefined) {
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
} else {
|
||||
process.env.SYNTHETIC_API_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type MoltbotConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
resolveImplicitProviders,
|
||||
} from "./models-config.providers.js";
|
||||
|
||||
type ModelsConfig = NonNullable<MoltbotConfig["models"]>;
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
|
||||
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
|
||||
|
||||
@@ -22,10 +21,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
|
||||
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
|
||||
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
|
||||
if (implicitModels.length === 0) return { ...implicit, ...explicit };
|
||||
if (implicitModels.length === 0) {
|
||||
return { ...implicit, ...explicit };
|
||||
}
|
||||
|
||||
const getId = (model: unknown): string => {
|
||||
if (!model || typeof model !== "object") return "";
|
||||
if (!model || typeof model !== "object") {
|
||||
return "";
|
||||
}
|
||||
const id = (model as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
};
|
||||
@@ -35,8 +38,12 @@ function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig)
|
||||
...explicitModels,
|
||||
...implicitModels.filter((model) => {
|
||||
const id = getId(model);
|
||||
if (!id) return false;
|
||||
if (seen.has(id)) return false;
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(id);
|
||||
return true;
|
||||
}),
|
||||
@@ -56,7 +63,9 @@ function mergeProviders(params: {
|
||||
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
|
||||
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
|
||||
const providerKey = key.trim();
|
||||
if (!providerKey) continue;
|
||||
if (!providerKey) {
|
||||
continue;
|
||||
}
|
||||
const implicit = out[providerKey];
|
||||
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
|
||||
}
|
||||
@@ -72,14 +81,14 @@ async function readJson(pathname: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureMoltbotModelsJson(
|
||||
config?: MoltbotConfig,
|
||||
export async function ensureOpenClawModelsJson(
|
||||
config?: OpenClawConfig,
|
||||
agentDirOverride?: string,
|
||||
): Promise<{ agentDir: string; wrote: boolean }> {
|
||||
const cfg = config ?? loadConfig();
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveMoltbotAgentDir();
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
|
||||
|
||||
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
|
||||
const explicitProviders = cfg.models?.providers ?? {};
|
||||
const implicitProviders = await resolveImplicitProviders({ agentDir });
|
||||
const providers: Record<string, ProviderConfig> = mergeProviders({
|
||||
implicit: implicitProviders,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "moltbot-models-" });
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
}
|
||||
|
||||
const _MODELS_CONFIG: MoltbotConfig = {
|
||||
const _MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"custom-proxy": {
|
||||
@@ -92,20 +92,29 @@ describe("models-config", () => {
|
||||
resolveCopilotApiToken,
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
|
||||
await ensureMoltbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||
await ensureOpenClawModelsJson({ models: { providers: {} } }, agentDir);
|
||||
|
||||
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||
);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
if (previousGh === undefined) delete process.env.GH_TOKEN;
|
||||
else process.env.GH_TOKEN = previousGh;
|
||||
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
|
||||
else process.env.GITHUB_TOKEN = previousGithub;
|
||||
if (previous === undefined) {
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||
}
|
||||
if (previousGh === undefined) {
|
||||
delete process.env.GH_TOKEN;
|
||||
} else {
|
||||
process.env.GH_TOKEN = previousGh;
|
||||
}
|
||||
if (previousGithub === undefined) {
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
} else {
|
||||
process.env.GITHUB_TOKEN = previousGithub;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -127,10 +136,10 @@ describe("models-config", () => {
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ensureMoltbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveMoltbotAgentDir } = await import("./agent-paths.js");
|
||||
const { ensureOpenClawModelsJson } = await import("./models-config.js");
|
||||
const { resolveOpenClawAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureMoltbotModelsJson({
|
||||
await ensureOpenClawModelsJson({
|
||||
models: {
|
||||
providers: {
|
||||
"github-copilot": {
|
||||
@@ -142,7 +151,7 @@ describe("models-config", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<string, { baseUrl?: string }>;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { resolveMoltbotAgentDir } from "./agent-paths.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
collectAnthropicApiKeys,
|
||||
isAnthropicBillingError,
|
||||
@@ -12,18 +11,21 @@ import {
|
||||
} from "./live-auth-keys.js";
|
||||
import { isModernModelRef } from "./live-model-filter.js";
|
||||
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
|
||||
import { ensureMoltbotModelsJson } from "./models-config.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js";
|
||||
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
|
||||
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
|
||||
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
|
||||
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
|
||||
const DIRECT_ENABLED = Boolean(process.env.OPENCLAW_LIVE_MODELS?.trim());
|
||||
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS);
|
||||
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
function parseProviderFilter(raw?: string): Set<string> | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "all") return null;
|
||||
if (!trimmed || trimmed === "all") {
|
||||
return null;
|
||||
}
|
||||
const ids = trimmed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
@@ -33,7 +35,9 @@ function parseProviderFilter(raw?: string): Set<string> | null {
|
||||
|
||||
function parseModelFilter(raw?: string): Set<string> | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "all") return null;
|
||||
if (!trimmed || trimmed === "all") {
|
||||
return null;
|
||||
}
|
||||
const ids = trimmed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
@@ -47,19 +51,35 @@ function logProgress(message: string): void {
|
||||
|
||||
function isGoogleModelNotFoundError(err: unknown): boolean {
|
||||
const msg = String(err);
|
||||
if (!/not found/i.test(msg)) return false;
|
||||
if (/models\/.+ is not found for api version/i.test(msg)) return true;
|
||||
if (/"status"\\s*:\\s*"NOT_FOUND"/.test(msg)) return true;
|
||||
if (/"code"\\s*:\\s*404/.test(msg)) return true;
|
||||
if (!/not found/i.test(msg)) {
|
||||
return false;
|
||||
}
|
||||
if (/models\/.+ is not found for api version/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
if (/"status"\\s*:\\s*"NOT_FOUND"/.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
if (/"code"\\s*:\\s*404/.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isModelNotFoundErrorMessage(raw: string): boolean {
|
||||
const msg = raw.trim();
|
||||
if (!msg) return false;
|
||||
if (/\b404\b/.test(msg) && /not[_-]?found/i.test(msg)) return true;
|
||||
if (/not_found_error/i.test(msg)) return true;
|
||||
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg)) return true;
|
||||
if (!msg) {
|
||||
return false;
|
||||
}
|
||||
if (/\b404\b/.test(msg) && /not[_-]?found/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
if (/not_found_error/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not[_-]?found/i.test(msg)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -74,7 +94,9 @@ function isInstructionsRequiredError(raw: string): boolean {
|
||||
|
||||
function toInt(value: string | undefined, fallback: number): number {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return fallback;
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
@@ -82,10 +104,14 @@ function toInt(value: string | undefined, fallback: number): number {
|
||||
function resolveTestReasoning(
|
||||
model: Model<Api>,
|
||||
): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (!model.reasoning) return undefined;
|
||||
if (!model.reasoning) {
|
||||
return undefined;
|
||||
}
|
||||
const id = model.id.toLowerCase();
|
||||
if (model.provider === "openai" || model.provider === "openai-codex") {
|
||||
if (id.includes("pro")) return "high";
|
||||
if (id.includes("pro")) {
|
||||
return "high";
|
||||
}
|
||||
return "medium";
|
||||
}
|
||||
return "low";
|
||||
@@ -142,7 +168,9 @@ async function completeOkWithRetry(params: {
|
||||
};
|
||||
|
||||
const first = await runOnce();
|
||||
if (first.text.length > 0) return first;
|
||||
if (first.text.length > 0) {
|
||||
return first;
|
||||
}
|
||||
return await runOnce();
|
||||
}
|
||||
|
||||
@@ -151,10 +179,10 @@ describeLive("live models (profile keys)", () => {
|
||||
"completes across selected models",
|
||||
async () => {
|
||||
const cfg = loadConfig();
|
||||
await ensureMoltbotModelsJson(cfg);
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
if (!DIRECT_ENABLED) {
|
||||
logProgress(
|
||||
"[live-models] skipping (set CLAWDBOT_LIVE_MODELS=modern|all|<list>; all=modern)",
|
||||
"[live-models] skipping (set OPENCLAW_LIVE_MODELS=modern|all|<list>; all=modern)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -164,18 +192,18 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`[live-models] anthropic keys loaded: ${anthropicKeys.length}`);
|
||||
}
|
||||
|
||||
const agentDir = resolveMoltbotAgentDir();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
const models = modelRegistry.getAll() as Array<Model<Api>>;
|
||||
const models = modelRegistry.getAll();
|
||||
|
||||
const rawModels = process.env.CLAWDBOT_LIVE_MODELS?.trim();
|
||||
const rawModels = process.env.OPENCLAW_LIVE_MODELS?.trim();
|
||||
const useModern = rawModels === "modern" || rawModels === "all";
|
||||
const useExplicit = Boolean(rawModels) && !useModern;
|
||||
const filter = useExplicit ? parseModelFilter(rawModels) : null;
|
||||
const allowNotFoundSkip = useModern;
|
||||
const providers = parseProviderFilter(process.env.CLAWDBOT_LIVE_PROVIDERS);
|
||||
const perModelTimeoutMs = toInt(process.env.CLAWDBOT_LIVE_MODEL_TIMEOUT_MS, 30_000);
|
||||
const providers = parseProviderFilter(process.env.OPENCLAW_LIVE_PROVIDERS);
|
||||
const perModelTimeoutMs = toInt(process.env.OPENCLAW_LIVE_MODEL_TIMEOUT_MS, 30_000);
|
||||
|
||||
const failures: Array<{ model: string; error: string }> = [];
|
||||
const skipped: Array<{ model: string; reason: string }> = [];
|
||||
@@ -185,9 +213,13 @@ describeLive("live models (profile keys)", () => {
|
||||
}> = [];
|
||||
|
||||
for (const model of models) {
|
||||
if (providers && !providers.has(model.provider)) continue;
|
||||
if (providers && !providers.has(model.provider)) {
|
||||
continue;
|
||||
}
|
||||
const id = `${model.provider}/${model.id}`;
|
||||
if (filter && !filter.has(id)) continue;
|
||||
if (filter && !filter.has(id)) {
|
||||
continue;
|
||||
}
|
||||
if (!filter && useModern) {
|
||||
if (!isModernModelRef({ provider: model.provider, id: model.id })) {
|
||||
continue;
|
||||
|
||||
@@ -25,11 +25,18 @@ function installFailingFetchCapture() {
|
||||
const fetchImpl: typeof fetch = async (_input, init) => {
|
||||
const rawBody = init?.body;
|
||||
const bodyText = (() => {
|
||||
if (!rawBody) return "";
|
||||
if (typeof rawBody === "string") return rawBody;
|
||||
if (rawBody instanceof Uint8Array) return Buffer.from(rawBody).toString("utf8");
|
||||
if (rawBody instanceof ArrayBuffer)
|
||||
if (!rawBody) {
|
||||
return "";
|
||||
}
|
||||
if (typeof rawBody === "string") {
|
||||
return rawBody;
|
||||
}
|
||||
if (rawBody instanceof Uint8Array) {
|
||||
return Buffer.from(rawBody).toString("utf8");
|
||||
}
|
||||
if (rawBody instanceof ArrayBuffer) {
|
||||
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
|
||||
}
|
||||
return String(rawBody);
|
||||
})();
|
||||
lastBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user